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}
Une introduction à ce qu'il est possible de mesurer à l'aide de l'utilitaire jcmd
embarqué dans les JDK.
Tout est dans le titre. Je suis en train de comparer plusieurs choses actuellement :
-
Le surcoût que représente la JRE 17 sur une application Kotlin.
-
Le gain qu'apporte une JRE 17 custom produite à l'aide de l'utilitaire JLink.
-
Les performances de (1) et (2) face à la même application codée en Rust, en termes de consommation mémoire et d'opérations par seconde.
-
Les nouvelles options que la JRE prends en paramètre et leurs effets sur le CPU, la mémoire et le débit.
Le tuning de JRE a toujours été compliqué, mais pour obtenir une JRE de 30 Mo optimisée comme il faut, il y a encore plus de choses à connaître et comprendre qu'avant. Damned !
Réduire la taille de votre lib Font Awesome en ne conservant que les icônes dont vous vous servez. La webapp permettant de le faire est disponible ici.
Je vais regarder pour faire la même chose dans une tâche Gulp car les Font Awesome consomment entre 120 Ko et 440 Ko, ce qui n'est pas négligeable du tout !
Edit : voici un article complémentaire tiré de la documentation du projet OpenJDK sur le class-sharing de la JVM.
Précharger des classes pour démarrer une JVM plus vite. Je viens de faire le test avec Maven et ça a l'air de marcher un petit peu (mais le gain est négligeable lorsque l'on compare les temps de build à Gradle).
Toujours est-il que si cela peut apporter un peu de confort à certains, ou encore réduire les temps de démarrage des conteneurs, alors ça vaut le coup.
Merci à @Philou pour le lien.
Je le note pour @Kysofer qui doit gérer une PIC.
Si vos requêtes de push/pull/clone sont trop grosses et que Git vous affiche une erreur du type Github Push Error: RPC failed; result=22, HTTP code = 413
alors vous avez probablement deux choses à faire :
1) Augmenter la taille du buffer côté Git.
J'ai fixé la valeur à 1 Gio, car tous mes PC ont au moins 8 Go de RAM et tournent sous Linux. À noter que cela a grandement accéléré mes requêtes de pull/push/clone (je suis passée de 4 Mo/sec à 25 Mo/sec) :
git config --global http.postBuffer 1073741824
2) Augmenter la taille du cache côté Nginx s'il est en front à votre serveur Git (Gitea chez moi).
client_max_body_size 512m;
Et ne pas oublier de redémarrer Nginx via un systemctl restart nginx
.
Tout un tas de recommandations pour optimiser Docker. Cela va des temps de construction des conteneurs en passant par leur taille et allant jusqu'au runtime. Des choses sûrement déjà dites mais ce type de synthèse sert toujours.
Via Memiks.
Un article en trois parties vraiment très bon et qui tombe pile poil au moment où je suis en train de regarder pour réduire la taille des images Dockers de plusieurs projets.
Je sens que ça va intéresser @Philou :
- Lien vers la partie 1.
- Lien vers la partie 2.
- Lien vers la partie 3.
Via Liandri.
Comment installer, configurer, tuner et sécuriser son instance nginx.
Via Hedi
Je résume, si vos tests unitaires sont deux à deux distincts, s'ils n'engendre pas de conflits lors de leur exécution en parallèle (donc il faut dissocier les TU des TI qui quant à eux s'appuient sur l'injection de dépendances) alors vous pouvez dire à Surefire d'exécuter en parallèle autant de TEST que vous avez de Thread CPU de disponibles.
Après un benchmark succinct, j'ai divisé par 5 le temps d'exécution de mes tests sur un 4 cœurs physiques et 8 cœurs logiques via la technologique HT (Hyper-Threading chez Intel / Hyper-Transport chez AMD).
Voici comment faire :
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M3</version>
<configuration>
<forkCount>1C</forkCount> <!-- C'est la même commande que pour Maven, 1 thread / CPU -->
<reuseForks>true</reuseForks>
</plugin>
Depuis Android 4.4, Google a ajouté une IP virtuelle lorsque l'on partage notre connexion (thethering). Ceci permet aux FAI de savoir si l'on utiliser "normalement" le téléphone ou si l'on partage sa connexion.
En l'occurrence, ceux-ce ne se gênent pas pour brider la vitesse de téléchargement de la connexion partagé, voire de bloquer les échanges purement et simplement.
Voici une petite manipulation à exécuter depuis adb shell permettant de supprimer ce marquage et empêcher votre FAI de savoir que vous venez de partager la connexion internet de votre mobile via votre PC :
settings put global tether_dun_required 0
Via Cifiste.
Je regarderai cela plus tard !
From a river
Un tuning de conf de disque SSD pour PC portable (je viens d'apprendre à quoi servait le discard).
À ne jamais utiliser sur un serveur !
Un article expliquant le tuning sur Jetty. La partie touchang au tuning de la couche TCP/IP du Linux sous-jacent est très instructive
Comme vous avez pu le constater, je me documente un peu sur le tuning JVM / MySQL / Linux en ce moment.
L'idée étant de faire simplement des choses simples. Cette page est assez complète et couvre bien les spectres I/O, memory usages, garbage collector, OS.
Un PDF à lire lorsque l'on configure une base MySQL derrière un driver Java et HikariCP en pool de connexions.
Plein d'articles sur le sujet. Cool j'en avais besoin :D
Des éléments de Firefox à changer dans le about:config
.
Et ce lien vers le même site qui parle aussi des changements apportés par Java 8 au niveau de la JVM.