Contexte
Depuis quelques jours, la tâche m'incombe de comparer les performances de la JRE face à du code natif, le benchmark porte principalement sur Kotlin vs Rust.
Depuis Java 17, les choses ont é-nor-mé-ment évoluées au point où en termes de temps de calcul CPU ou de débits I/O purs, la JRE est plus rapide que C/C++ ou Rust sauf si l'on active les options de compilation de C/C++/Rust qui retirent la portabilité des exécutables entre Intel et AMD.
L'idée est donc d'obtenir, au moyen du paramétrage de la JRE, le meilleur compromis entre :
- Les temps de calcul CPU
- La charge moyenne CPU
- Les débits I/O
- Les temps de latence I/O
- La quantité de mémoire consommée
- Les temps de démarrage
- Le poids total de l'application
- La capacité à être supervisé/debuggé
Ce poste va donc regrouper les différentes options à passer soit aux compilateurs Kotlin et Java, soit à la JRE elle-même, ainsi que les pré-requis matériels qui en découlent en expliquant le pourquoi du comment (ça va être long mais le cours est gratuit alors profitez-en 😘).
Pré-requis
Depuis Java 11, la JRE est prévue pour fonctionner de manière optimale sur du multi-threads avec une certaine quantité de mémoire (au minimum 2 threads CPU et 2 Go de RAM). Aussitôt qu'elle s'exécute sur un seul thread CPU/vCPU ou moins de 2 Go de mémoire, alors elle considère être en environnement contraint et va activer des stratégies d'exécution plus lentes afin de fonctionner correctement (notamment SerialGC).
Sur une architecture x64/Aarch64, il faut donc une configuration matérielle minimale afin de ne pas tomber dans ce mode d'exécution aux performances limitées.
C'est pourquoi toutes les options ci-après porteront sur un matériel disposant :
- De 4 threads CPU (2 physiques + 2 virtuels ou 4 physiques)
- De 8 Go de RAM (car sur du 64 bits, en-dessous, c'est du gâchis)
- D'un disque SSD ou NVMe (c'est-à-dire avec au moins 2 threads d'écriture/lecture simultanés)
Enfin, nous parlons ici de Java 17 et rien d'autre.
Quantité de mémoire réservée (non affectable à l'application)
Dans notre cas de figure, nous avons 8 Go à allouer de manière optimale. De ces 8 Go retranchons ce que nous ne pouvons pas prendre car pris par autre chose :
- La taille de l'OS + Services (System-D, SSH Pare-feu, fail2ban, borg backup, monitoring, etc) => 136 Mo
- La taille de la JRE qui est elle-même un programme => 24 Mo.
- Le taux d'espace réservé par la swappiness (chez moi 1%) => 80 Mo
- La marge d'erreur => 16 Mo
Soit un total de 256 Mo sur 8 Go qui ne seront jamais affectés à notre application.
Fonctionnement de Java 17
La mémoire est répartie en quatre zones :
- La Stack ou pile d'appels (il y en a une par thread).
- Le Young Space qui regroupe les espaces Eden et les Survivors où sont gérées les nouvelles instances.
- Le Tenured Space, aka Old Space, qui regroupe toutes les instances ayant survécus au GC dans le Young Space.
- Le Metaspace (qui reprend le rôle du PermGen) qui retrouve tout ce qui est statique, le byte-code compilé, les informations du compilateur JIT (Just In Time) et les méta-données de classes.
Depuis Java 8, on représente les nouvelles zones de la JRE par le diagramme suivant :

Dans notre cas de figure, ces zones vont se partager 8 Go - 256 Mo = 7 744 Mo. En sachant que la consommation réelle de la mémoire d'une JRE se calcule au moyen de l'addition suivante : [Taille de la pile d'appel x Nb Threads] + [Taille du Heap] + [Taille du Metaspace] + [Taille de la JRE (C-Heap)].
Choisir le bon Garbage Collector
Globalement trois choix s'imposent sur Java 17 :
G1 (Garbage-First)
- S'active via l'option
-XX:+UseG1GC. - À utiliser par défaut mais pour s'en servir de manière optimale il faut que votre application nécessite au moins 6 Go de mémoire OU qu'au moins 50% du heap contienne des objets encore en vie.
Shenandoah
- S'active via l'option
-XX:+UseShenandoahGC. - À utiliser si vous souhaitez minimiser le plus possible les temps de pause dus au GC (< 1 ms) OU que vous souhaitez des temps de pause semblables quelque soit la taille du heap entre 2 Go et 200 Go.
ZGC (expérimental)
- S'active via l'option
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC. - À utiliser si votre application requiert plusieurs téraoctets de RAM (oui téra ou a minima quelques centaines de Go) OU que vous ayez besoin de régler le seuil de concurrence des cycles du GC parce que votre hardware dispose de trouze-mille threads matériels.
Je mets volontairement au rebus les GC trop lents ou dépréciés tels que Serial collector, Parallel collector, Concurrent Mark and Sweep et évidemment NoGC.
=> Dans notre cas de figure nous partirons sur Garbage-First / G1.
Paramétrage de la JRE
Toute la configuration de notre JRE se fera au moyen de la variable d'environnement JRE_OPTIONS qui sera passée en argument à la commande java. Par défaut nous exécuterons notre JRE en mode serveur.
Dimensionnement de la Stack
Nous avons un CPU à 4 threads. Dans un monde idéal, la JRE s'appuierait sur un pool de 4 threads CPU et chaque frameworks utiliserait un thread virtuel qui serait dépilé/traité par ceux du pool.
Mais nous ne vivons pas dans un monde idéal, nous allons donc limiter le nombre de threads de notre application à 16 threads physiques par threads CPU soit 4 x 16 = 64 threads au total.
Notre système ne disposant que de 8 Go de RAM, nous allons limiter la taille de chaque pile d'appels via l'option -Xss à 1 Mo soit 1 x 64 Mo = 64 Mo cumulés.
N.B : par défaut la JRE définit déjà une taille de pile d'appels à 1 Mo mais je préfère la forcer au cas où une mise à jour changerait cette valeur.
Cette limitation vient aussi du fait que je souhaite que le poids de la Stack ne dépasse pas 1% de la RAM. Il n'y a pas de raison particulière à cela mais afin de se représenter la chose, avec 1 Mo d'appels en cascade il est possible d'imbriquer dans le même algorithme :
- ~2 000 méthodes dont chaque signature expose 6 paramètres + un type de retour.
- ~3 900 méthodes dont chaque signature expose 3 paramètres sans type de retour.
Étant donné que je travail avec Jooby sur Netty il n'y aura pas de problème pour configurer le nombre de threads et la taille de la pile d'appel. Mais dans la pratique, il faut bien connaître ses frameworks ou tout profiler sinon.
Dimensionnement du Metaspace
Le Metaspace a pour comportement d'occuper tout l'espace restant afin d'éviter les OutOfMemory ; contrairement au PermGen que nous pouvions contraindre mais qui occupait l'espace du Heap.
Dans la pratique, le poids cumulé de tous les jars des frameworks que j'utilise pour un service RESTful est inférieur à 16 Mo mais une fois les jars décompressés ce sont ~40 Mo de fichiers répartis sur ~6 000 .class qui sont à charger.
En jouant avec les options -Xcomp, -Xbatch, XnoclassGC et -XCompileThreshold=1 j'ai forcé la JRE a compiler 100 % du bytecode en mémoire dans le Metaspace. Et en analysant le poids avec l'utilitaire JConsole, j'ai pu constater que 32 Mo étaient occupés.
Je suppose que la taille maximale du Metaspace devrait être égale au poids des .class + au poids des ressources chargées dans le classpath (même si a priori non). Comme mon système a beaucoup de RAM, je préfère ajouter une nouvelle fois 16 Mo de marge et ensuite profiler l'application pour déterminer si cette marge est toujours nécessaire.
Je vais donc passer la taille par défaut du Metaspace ainsi que la taille de ses partitions à 64 Mo. Je considère aussi que le Metaspace peut évoluer entre 5% et 95% de sa capacité avant d'être redimensionné à la hausse ou à la baisse.
Au final, la stratégie consistera à définir des options -Xmx et -Xms qui ne laisseront au Metaspace que les 64 Mo dont il aura besoin pour mon application. Évidemment, certains programmes codés en Spring Bouse réclameront facilement 100 Mo ou 200 Mo de Metaspace. Il faut donc mesurer l'espace occupé dans le pire cas de figure pour correctement paramétrer le reste.
Dimensionnement du Heap
Dans mon cas, ce sera facile. Il me suffit de reprendre la taille maximale disponible et de lui retrancher la taille du Metaspace, de la Thread Stack, de la JRE et de la marge d'erreur.
On obtient 7 744 - 64 - 64 - 24 - 16 = 7 576 Mo
Dimensionnement des zones du Heap
Comme vu plus haut, le Heap est décomposé en Young et Old spaces. Pour savoir quel espace allouer au Young Space (et donc à laisse au Old Space) il faut superviser l'application.
Dans mon cas de figure, comme j'ai appris à coder avec de petites instances immutables, imbriquées et jetables, j'ai besoin d'un gros Young Space par défaut, en sachant que celui-ci prendra toute la place qu'il peut dans le Heap s'il en a le besoin.
Sans profiling, je pars sur une répartition 3/4 Young et 1/4 Old, soit respectivement 5 682 Mo et 1 894 Mo sur les 7 576 Mo affectables. J'arrondis le 5 682 Mo à 5 680 Mo car ce sera plus pratique pour déclarer les ratios Eden vs Survivor.
Supervision de la JRE
La JRE produit elle-même des logs qui sont indispensables pour comprendre ses dysfonctionnement en production. L'idée est donc de définir une rolling policy ainsi qu'un fichier de sortie.
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=< number of log files >
-XX:GCLogFileSize=< file size >[ unit ]
-Xloggc:/path/to/gc.log
Paramètre de la ligne de commande
Au final nous obtenons ce script de démarrage
#!/usr/bin/env dash
## Mode server
JRE_OPTIONS="-server"
## Heap
* Nous avons min = max pour que la JRE s'affecte tout dès son démarrage et s'évite de perdre du temps aux resize
JRE_OPTIONS="${JRE_OPTIONS} -Xms7576m" # Taille minimale du Heap
JRE_OPTIONS="${JRE_OPTIONS} -Xmx7576m" # Taille maximal du Heap
## Young Space
JRE_OPTIONS="${JRE_OPTIONS} -XX:NewSize=5680m" # Taille par défaut du Young Space
JRE_OPTIONS="${JRE_OPTIONS} -XX:MaxNewSize=5680m" # Taille maximale du Young Space
## Thread Stack
JRE_OPTIONS="${JRE_OPTIONS} -Xss1m" # Taille de la Stack d'un thread sur 16
## Taille du Metaspace
JRE_OPTIONS="${JRE_OPTIONS} -XX:MetaspaceSize=64m" # Seuil au-delà duquel le Metaspace grossi
JRE_OPTIONS="${JRE_OPTIONS} -XX:MaxMetaspaceSize=64m" # Taille maximale du Metaspace avant un OutOfMemory
JRE_OPTIONS="${JRE_OPTIONS} -XX:MinMetaspaceFreeRatio=5" # Après un GC complet, pourcentage d'espace libre minimal du Metaspace avant son augmentation
JRE_OPTIONS="${JRE_OPTIONS} -XX:MaxMetaspaceFreeRatio=95" # Après un GC complet, pourcentage d'espace libre maximal du Metaspace avant sa réduction
## Logs
JRE_OPTIONS="${JRE_OPTIONS} -Xloggc:/var/log/my-app/jre.log" # Emplacement du fichier de log
JRE_OPTIONS="${JRE_OPTIONS} -XX:+UseGCLogFileRotation" # Mise en place d'une rotation des fichiers de log
JRE_OPTIONS="${JRE_OPTIONS} -XX:NumberOfGCLogFiles=10" # Nombre maximum de fichiers de logs
JRE_OPTIONS="${JRE_OPTIONS} -XX:GCLogFileSize=100m" # Taille maximale d'un fichier avant rotation
JRE_OPTIONS="${JRE_OPTIONS} -XX:+HeapDumpOnOutOfMemoryError" # Dump de la mémoire en cas de OOM
JRE_OPTIONS="${JRE_OPTIONS} -XX:HeapDumpPath=/var/log/my-app/oom.log" # Chemin vers le fichier
JRE_OPTIONS="${JRE_OPTIONS} -XX:OnOutOfMemoryError='shutdown -r '" # Commande a exécuter en cas de OutOfMemory
JRE_OPTIONS="${JRE_OPTIONS} -XX:+UseGCOverheadLimit" # Limitation des temps GC avant qu'un OOM ne soit levé
## Optimisation du byte-code
JRE_OPTIONS="${JRE_OPTIONS} -XX:+UseStringDeduplication" # Evite d'instancier plusieurs fois la même String
JRE_OPTIONS="${JRE_OPTIONS} -XX:+OptimizeStringConcat" # Utilise un StringBuffer pour concaténer des String quand c'est possible mais pas fait par le développeur
java ${JRE_OPTIONS} -cp ${CLASSPATH} ${mainClass}