Java Ergonomics : Faut-il encore tuner la JVM ?

La JVM est, dit-on, de plus en plus intelligente (en tout cas c’est ce que les éditeurs prétendent), les questions que l’on peut légitimement se poser sont donc :

  • Est-il toujours nécessaire de fournir des paramètres de tuning au lancement de la machine virtuelle ?
  • Ne sont-ils pas plus néfastes que bénéfiques ?
  • Ne vaut-il pas mieux laisser la JVM se débrouiller seule ?

C’est à ces questions que nous allons tenter de répondre et on en profitera pour approfondir les grandes différences entre les collecteurs.

Depuis Java 5, l’accent a été mis sur la capacité de la JVM à trouver dynamiquement les meilleurs paramètres possibles par rapport au contexte de l’application.
Même avec une version antérieure de Java, c’est quelque chose que vous avez pu observer lorsque vous avez constaté que la taille de la Heap augmentait en fonction de la charge de l’application (avec des valeurs différentes pour Xms et Xmx évidemment).

Présentation

On désigne par le terme Ergonomics les capacités de la JVM à s’auto-tuner, à s’ajuster selon des critères de performance et de latence.
On connaît bien évidemment les valeurs par défaut des paramètres de la JVM qui s’appliquent lorsqu’une valeur n’est pas renseignée au lancement de la JVM ; mais ici on va plus loin puisque cette fonctionnalité va agir pendant l’exécution du programme pour aboutir à des valeurs différentes de celles initialement valorisées.
En particulier la JVM va agir dynamiquement sur les paramètres suivants :

  • Taille de la heap
  • Ratio new/old (young/tenured) génération
  • Choix du temps maximal de pause pendant la collecte
  • Choix du ratio entre le temps alloué à la collecte et le temps alloué à l’application
  • Fréquence d’utilisation du compilateur Just In Time

Mais encore Java Ergonomics peut augmenter ou diminuer le nombre de threads alloués aux Collecteurs.
Java Ergonomics peut aussi activer/désactiver l’option CompressedOops sur les plates-formes 64 bits.

Activation Ergonomics

Le paramètre “UseAdaptiveSizePolicy” permet d’activer les Ergonomics mais il est actif par défaut pour la plupart des JVM à l’exception des cas suivants.
Certains paramètres sont incompatibles avec les Ergonomics :

  • UseConcMarkSweepGC et UseParNewGC (OpenJDK)
  • Définition du SurvivorRatio

L’algorithme CMS n’est donc pas compatible avec les Ergonomics pour toutes les JVM :

  • Compatibilité incomplète : OpenJDK (problème d’instabilité)
  • Compatibilité : Apple, …

SurvivorRatio est un des leviers de l’autoréglage de la JVM, le fait de l’imposer désactive donc la fonctionnalité.
Sous linux, Java Ergonomics n’est pas compatible avec les options UseNuma et UseLargePages utilisées conjointement (car il est impossible de désallouer une page).

Pour désactiver java Ergonomics : -XX:-UseAdaptiveSizePolicy

L’auto-réglage de la JVM va répondre à trois objectifs :

  1. Pause time goal : Un temps de pause maximal pour le passage du Garbage Collector (MaxGCPauseMillis)
  2. Throughput : Le temps accordé à l’application par rapport au temps de passage du collecteur (GCTimeRatio)
  3. Footprint : Réduire l’empreinte mémoire (Taille de la Heap)

Règles de fonctionnement des Ergonomics :

  1. Si le temps de pause du GC est supérieur au temps maximum défini alors réduire la taille des générations
  2. Si le temps de pause du GC est correct (sous entendu : inférieur à la valeur maximale définie) alors tenter d’améliorer le throughput (débit) de l’application en augmentant la taille des générations (moins de GC mais éventuellement plus long)
  3. Si les objectifs de temps de pause du GC et de throughput sont atteints alors réduire la taille des générations.

On peut donc constater que pour respecter un temps de pause du Garbage faible, la JVM va restreindre la taille de la Heap ce qui peut avoir un impact important sur le comportement de l’application.

Parmi les différences imposées par l’auto-réglage de la JVM, on peut citer l’interdiction du phénomène de “survivor space overflow” lorsque les Ergonomics sont activés.
Ce phénomène se produit lorsque l’espace overflow est saturée et qu’un objet passe directement de la “young génération” à la “tenured generation” sans transiter par les générations de type survivor.

Liste des paramètres liés aux Ergonomics :

UseAdaptiveSizePolicyActivation de l’auto-réglage de la taille des générations
UsePSAdaptiveSurvivorSizePolicyActivation de l’auto-réglage de la taille des générations de type survivor
UseAdaptiveGenerationSizePolicyAtMinorCollectionActivation de l’auto-réglage de la taille des générations lors des “collectes mineures”
UseAdaptiveGenerationSizePolicyAtMajorCollectionActivation de l’auto-réglage de la taille des générations lors des “collectes majeures”
UseAdaptiveSizePolicyWithSystemGCUtilisation des statistiques du garbage collector pour l’auto-réglage de la taille des générations.
UseAdaptiveGCBoundaryAutorise les frontières new/old (ratio) à évoluer
AdaptiveSizeThroughPutPolicyDéfinition des temps accordés à l’application comme objectif premier
AdaptiveSizePausePolicyDéfinition des temps de pause minima comme objectif premier
AdaptiveSizePolicyInitializingStepsTemps d’observation des statistiques avant leur utilisation
AdaptiveSizePolicyOutputIntervalAffichage des données d’auto-réglage en fonction des passages du GC (0 : jamais, 1 à chaque GC, etc.)
UseAdaptiveSizePolicyFootprintGoalDéfinition de l’empreinte mémoire minimale comme objectif premier
AdaptiveSizePolicyWeightRatio utilisé lors du redimensionnement des générations (de 0 à 100)
AdaptiveTimeWeightPoids attribué au paramètre temps de pause (de 0 à 100)
PrintAdaptiveSizePolicyAffichage des directives d’auto-réglage de la JVM (au moment du passage du GC)

Liste des paramètres et valeur par défaut

Analysons les différents paramètres de réglage de la JVM :
La commande suivante va permettre de lister les paramètres supportés par votre JVM et leur valeur initiale.

java -XX:+UnlockDiagnosticVMOptions -XX:+UnlockExperimentalVMOptions -XX:+PrintFlagsFinal -version

Exemple : OpenJDK7 64 bits sur Mac OS

FlagValeur
GCTimeRatio99 (1 /100 soit 1 % pour le GC)
InitialHeapSize67108864
InitialSurvivorRatio8
MaxGCPauseMillis18446744073709551615
MaxHeapSize1073741824
PermSize21757952
MaxTenuringThreshold15
MinSurvivorRatio3
PrintAdaptiveSizePolicyfalse
SurvivorRatio8
TargetSurvivorRatio50
UseAdaptiveGCBoundaryfalse
UseAdaptiveGenerationSizePolicyAtMajorCollectiontrue
UseAdaptiveGenerationSizePolicyAtMinorCollectiontrue
UseAdaptiveSizePolicytrue
UseAdaptiveSizePolicyFootprintGoaltrue
UseAdaptiveSizePolicyWithSystemGCfalse
UseConcMarkSweepGCfalse
UseG1GCfalse
UseParallelGCtrue
UseParallelOldGCtrue
On voit que :
  1. ParallelGC est le collecteur par défaut
  2. Ergonomics activé par défaut
  3. Objectif pause GC très élevé : (18446744073709551615 ms)
  4. Heap à 1 Go
  5. PermSize 20,75 Mo

Avec un temps de pause maximal aussi peu réaliste, la JVM va tenter de satisfaire le second objectif soit le temps maximal alloué à l’application par rapport au temps de GC.
De même on peut mesurer l’impact du collecteur sur les valeurs initiales

Collecteur G1

java -XX:+UnlockDiagnosticVMOptions -XX:+UnlockExperimentalVMOptions -XX:+PrintFlagsFinal -XX:+UseG1GC -version

FlagValeur
GCTimeRatio9 (1 /10 soit 10 % pour le GC)
InitialHeapSize67108864
InitialSurvivorRatio8
MaxGCPauseMillis200
MaxHeapSize1073741824
PermSize20971520
MaxTenuringThreshold15
MinSurvivorRatio3
PrintAdaptiveSizePolicyfalse
SurvivorRatio8
TargetSurvivorRatio50
UseAdaptiveGCBoundaryfalse
UseAdaptiveGenerationSizePolicyAtMajorCollectiontrue
UseAdaptiveGenerationSizePolicyAtMinorCollectiontrue
UseAdaptiveSizePolicytrue
UseAdaptiveSizePolicyFootprintGoaltrue
UseAdaptiveSizePolicyWithSystemGCfalse
UseConcMarkSweepGCfalse
UseG1GCtrue
UseParallelGCfalse
UseParallelOldGCfalse
On voit que :
  1. Ergonomics activé par défaut
  2. Objectif pause GC : 200 ms
  3. Heap à 1 Go
  4. PermSize 20Mo

La plus grande différence reste la définition d’un temps de pause maximal, on peut donc supposer que la JVM va tenter de satisfaire cet objectif en premier contrairement au collecteur « ParallelGC ». Par contre le temps autorisé pour le GC est 10 fois plus important (GCTimeRatio).

Collecteur CMS
Test sur JVM Apple version 1.6

/System/Library/Java/JavaVirtualMachines/1.6.0.jdk/Contents/Home/bin/java -XX:+UseConcMarkSweepGC -XX:+UnlockDiagnosticVMOptions -XX:+UnlockExperimentalVMOptions -XX:+PrintFlagsFinal -version

FlagJVM Apple version 1.6
GCTimeRatio99 (1/100 soit 1 % pour le GC)
InitialHeapSize0
InitialSurvivorRatio8
MaxGCPauseMillis18446744073709551615
MaxHeapSize132120576
PermSize21757952
MinSurvivorRatio3
PermSize21757952
PrintAdaptiveSizePolicyfalse
SurvivorRatio8
TargetSurvivorRatio50
UseAdaptiveGCBoundaryfalse
UseAdaptiveGenerationSizePolicyAtMajorCollectiontrue
UseAdaptiveGenerationSizePolicyAtMinorCollectiontrue
UseAdaptiveSizePolicyfalse
UseAdaptiveSizePolicyFootprintGoaltrue
UseAdaptiveSizePolicyWithSystemGCfalse
UseConcMarkSweepGCtrue
UseParallelGCfalse
UseParallelOldGCfalse
UseParNewGCtrue
MaxTenuringThreshold4
On voit que :
  1. Ergonomics non activé par défaut
  2. Objectif pause GC très élevé : (18446744073709551615 ms) et donc inactif
  3. Heap initiale à 0
  4. Heap à 128 Mo
  5. PermSize 20,75 Mo

Conclusion : Il y a donc des différences entre les JVM et les collecteurs.

Impact du choix de la classe (client/server)

Il existe deux types prédéfinis d’applications :

  1. Les applications de type client (applications dont la performance d’exécution n’est pas la priorité)
  2. Les applications de type server (application dont le démarrage peut être plus lent mais qui doivent être rapides lors de l’exécution)

La JVM est capable de définir le type de classe lors du démarrage, pour cela elle s’appuie sur la mémoire disponible, l’OS et le nombre de processeurs :

  • server : pour une machine avec au moins 2Go de mémoire et plusieurs processeurs (à l’exception des machines 32 bits windows)
  • server : pour une machine 64 bits
  • client : Pour toute les autres configurations

NB : Ce choix n’est pas uniquement fait lorsqu’il n’y a pas de paramètres définis au lancement, la classe est contrainte par des règles : il est par exemple normalement impossible d’exécuter un programme de classe client sur une machine 64 bits.
Le choix de la classe est primordiale car il va décider des paramètres suivants :

  • Choix du collecteur (serialGC ou parallelGC)
  • Taille de l’espace Permanent (uniquement pour JVM Sun et Open JDK car stockée dans la Heap)
  • Taille initiale de la Heap
  • Taille maximale de la Heap
  • Type de compiler JIT (client ou server)
  • Etc

Ainsi avec server, les réglages suivants sont définis :

  • Parallel GC
  • InitialHeapSize: 1/64 de la mémoire physique limité à 1Go
  • MaxHeapSize: 1/4 de la mémoire physique limité à 1Go

Choix du Collecteur

Voici la liste des principaux algorithmes disponibles avec Java 7 :

OptionAlgorithme
+UseSerialGCSerial collector : un seul thread pour le Garbage Collector et l’application est stoppée pendant le passage du GC.
+UseParallelGCParallel collector : Les traitements du GC sont parallélisés pour la young/new generation (toujours sérialisé pour la old/tenured)
+UseParallelOldGCParallel compacting collector : Cette fois ci les traitements du GC sont parallélisés pour la young/new et la old/tenured
+UseConcMarkSweepGCConcurrent mark-sweep collector (CMS collector) : Hormis quelques phases incompatibles, ie. lorsque le collecteur marque les objets à collecter (initial mark pause, remark pause), les traitements du GC sur la old/tenured generation se fait de façon concurrente avec l’application. Le but étant de réduire les temps de pause de l’application dus au GC.
+UseG1GCGarbage First : Les traitements du GC sont à la fois parallèles et concurrents. Compacting : Contrairement à CMS cet algorithme inclut une phase d’ordonnancement de la mémoire pendant la collecte et ce afin d’éviter la fragmentation de la mémoire.

Depuis la version 5, l’algorithme par défaut n’est plus « serial collector » (-XX:+UseSerialGC) mais « parallel collector » pour la classe server (-XX:+UseParallelGC) avec nombre de threads = nombre de processeurs de la machine.

Encore plus de liberté ?

On a souvent aucune idée de l’utilisation mémoire de son application avant les tests de charge et on aimerait parfois que la JVM utilise autant de mémoire que possible (disponible).
Pour cela il y a l’option -XX:+AggressiveHeap

Avec cette option, la JVM inspecte les ressources mémoire et CPU de la machine afin d’allouer le maximum de ressource possible.
Hormis le cas où l’on souhaite estimer la mémoire nécessaire à l’application, c’est souvent utilisé afin d’optimiser une application qui utilise énormément de mémoire puisqu’avec ces réglages (énormément de mémoire au démarrage) le passage du collecteur est retardé au maximum (par contre les temps de pause peuvent être conséquents).

Attention : La JVM suppose que c’est la seule source d’activité (autre que système) sur la machine.
Cette option n’est donc viable que si une seule application Java tourne à un instant t.
C’est donc uniquement préconisé si la machine est dédiée (physique ou virtuelle).

Analyse concrète du fonctionnement des ergonomics ?

On va utiliser un benchmark Java : Da Capo http://www.dacapobench.org/
Ce benchmark n’est qu’un prétexte mais présente les avantages suivants :

  • il est public
  • il est réaliste

Téléchargement : http://sourceforge.net/projects/dacapobench/files/

Les tests qui vont nous intéresser :

tradebeansTradebeans runs the Apache daytrader workload “directly” (via EJB) within a Geronimo application server. Daytrader is derived from the IBM Trade6 benchmark.
tradesoapTradesoap is identical to the tradebeans workload, except that client/server communications is via soap protocols (and the workloads are reduced in size to compensate the substantially higher overhead).

Lancement du 1er test :

java -XX:AdaptiveSizePolicyOutputInterval=1 -XX:+UseAdaptiveSizePolicy -XX:+PrintAdaptiveSizePolicy -XX:+PrintGCDetails -jar dacapo-9.12-bach.jar tradesoap -t 1

t précise le nombre de threads à utiliser, il y a parfois des soucis d’accès concurrent si t > 1 on va donc utiliser cette valeur puisque la performance du test en soi ne nous intéresse pas.

Premières traces

[PSYoungGen: 18898K->2661K(19136K)] 18906K->3440K(62848K), 0,0086070 secs] [Times: user=0,02 sys=0,00, real=0,01 secs]<br></br>
UseAdaptiveSizePolicy actions to meet throughput goal
GC overhead (%)
Young generation:1,13 (attempted to grow)
Tenured generation:0,00 (no change)
[GCAdaptiveSizePolicy::compute_survivor_space_size_and_thresh:survived: 2726576promoted: 686152overflow: trueAdaptiveSizeStart: 4,120 collection: 3
PSAdaptiveSizePolicy::compute_generation_free_space: costs minor_time: 0,013684 major_cost: 0,000000 mutator_cost: 0,986316 throughput_goal: 0,990000 live_space: 278908992 free_space: 50528256 old_promo_size: 16842752 old_eden_size: 33685504 desired_promo_size: 16842752 desired_eden_size: 33685504
AdaptiveSizePolicy::survivor space sizes: collection: 3 (2752512, 2752512) -> (2752512, 2752512)
AdaptiveSizeStop: collection: 3

Explications :
La ligne « UseAdaptiveSizePolicy actions to meet throughput goal » indique que la JVM va tenter de satisfaire l’objectif « throughput ».

Young generation:1,13 (attempted to grow)<br></br>
Tenured generation:0,00 (no change)

La JVM utilise 1,13 % du temps total pour collecter la young génération
La tenured generation n’a pas encore subi de collecte.
La JVM tente d’augmenter la taille de la Heap pour réduire ce temps.

Un peu après

UseAdaptiveSizePolicy actions to meet throughput goal
GC overhead (%)
Young generation:2,35 (attempted to grow)
Tenured generation:0,00 (no change)
Tenuring threshold:(attempted to decrease to balance GC costs) = 5

La ligne “Tenuring threshold: (attempted to decrease to balance GC costs) = 5″ indique que la JVM tente de modifier les temps entre collecte des générations young et tenured en diminuant le paramètre tenuring threshold (initialement fixé à 15). Donc au bout de 5 collectes un objet créé dans la young sera passé en tenured.

A la fin

Heap
PSYoungGentotal 274752K, used 224172K
eden space 203392K, 88% used
from space 71360K, 62% used
to space 70528K, 0% used
ParOldGen total 179392K, used 99675K
object space 179392K, 55% used
PSPermGen total 83968K, used 60136K
object space 83968K, 71% used
UseAdaptiveSizePolicy actions to meet throughput goal
GC overhead (%)
Young generation:4,05 (attempted to grow)
Tenured generation: 5,79 (no change)
Tenuring threshold:(attempted to increase to balance GC costs) = 14

On peut conclure que l’objectif n’est pas atteint puisque la JVM tente encore d’ajuster les paramètres.
Cela dit les valeurs ont beaucoup évolué depuis le lancement vers un réglage optimal en ce qui concerne le but à atteindre : allouer un maximum de temps à l’application.

Test avec le collecteur G1
Chaque collecteur implémente ses propres traces, ne soyez donc pas surpris de trouver des traces au format différent en changeant de collecteur.

Exemple :

[G1Ergonomics (Heap Sizing) expand the heap, requested expansion amount: 67108864 bytes, attempted expansion amount: 67108864 bytes]
[GC pause (young) 3,634: [G1Ergonomics (CSet Construction) start choosing CSet, _pending_cards: 1518, predicted base time: 19,11 ms, remaining time: 180,89 ms, target pause time: 200,00 ms]
3,652: [G1Ergonomics (CSet Construction) add young regions to CSet, eden: 14 regions, survivors: 0 regions, predicted young region time: 356,52 ms]
3,652: [G1Ergonomics (CSet Construction) finish choosing CSet, eden: 14 regions, survivors: 0 regions, old: 0 regions, predicted pause time: 375,63 ms, target pause time: 200,00 ms]
…
[G1Ergonomics (Mixed GCs) continue mixed GCs, reason: candidate old regions available, candidate old regions: 48 regions, reclaimable: 46390112 bytes (18,91 %), threshold: 10,00 %]
…
[G1Ergonomics (Mixed GCs) do not continue mixed GCs, reason: reclaimable percentage not over threshold, candidate old regions: 26 regions, reclaimable: 23792032 bytes (9,70 %), threshold: 10,00 %]

Le formalisme est différent :

  • on voit que l’objectif est de contrôler le temps de pause défini à 200 ms
  • on voit aussi que la JVM peut choisir ou non de collecter old et young en même temps (mixed GC) en fonction du pourcentage de la génération collectable (reclaimable)

Conclusion

Dans le passé le tuning de JVM était un passage obligé et réellement maîtrisé par de rares spécialistes.
Avec les JVM modernes (Java 5 et +), il faut admettre que :

  • La JVM peut s’ajuster d’elle même.
  • Chaque paramètre ajusté spécialise le comportement de votre application et elle sera par conséquent plus sensible aux variations de charge, aux évolutions de l’utilisation de l’application,…

Définissez votre but :

  • Temps de pause (plutôt pour les applications de type web ou temps réel).
  • Temps alloué à l’application (plutôt pour les batchs).
  • Empreinte mémoire faible.

A partir de là vous pourrez choisir votre collecteur, définir l’objectif (temps de pause ou ratio) et définir la taille maximale de la Heap (si > à 1 Go) puis laisser faire la JVM.

Quid des préconisations éditeur ?
Il faut les prendre pour ce qu’elle sont : des recommandations pour les cas d’utilisation courants, qui émanent des tests de charge et des différents retours des utilisateurs au support.
Comme nous venons de le voir il est difficile de faire des préconisations quelque soit l’OS et la JVM (si pas de distinction : méfiance).
De même, un serveur d’application ne peut avoir un même réglage de la JVM, à cause des variations suivantes :

  • L’architecture applicative
  • Sollicitation par les clients
  • Durée de vie des objets métier longue ou courte
  • Utilisation d’un cache (et donc d’objet de vie longue) dans la même JVM que le serveur

On attend souvent des miracles du tuning de la JVM mais la plus grande source d’optimisation reste votre application.
Ce qui ne veut pas dire qu’un paramétrage de la JVM inadapté n’est pas une catastrophe pour l’application, ou que vous obtiendrez le meilleur de la JVM sans tuning conséquent (Exemple de tuning approfondi http://java-is-the-new-c.blogspot.fr/) mais c’est loin d’être la majorité des cas.

En bref, sauf application nécessitant de hautes performances, faite vôtre cette maxime en ce qui concerne les paramètres de lancement :

«La perfection est atteinte, non pas lorsqu’il n’y a plus rien à  ajouter, mais lorsqu’il n’y a plus rien  à retirer» : Antoine de Saint-Exupéry

Références :
Explications sur le fonctionnement des Garbage Collector : http://www.infoq.com/articles/Java_Garbage_Collection_Distilled
Présentation des fonctionnalités Ergonomics en Java 7 : http://docs.oracle.com/javase/7/docs/technotes/guides/vm/gc-ergonomics.html