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 :

UseAdaptiveSizePolicy Activation de l’auto-réglage de la taille des générations
UsePSAdaptiveSurvivorSizePolicy Activation de l’auto-réglage de la taille des générations de type survivor
UseAdaptiveGenerationSizePolicyAtMinorCollection Activation de l’auto-réglage de la taille des générations lors des “collectes mineures”
UseAdaptiveGenerationSizePolicyAtMajorCollection Activation de l’auto-réglage de la taille des générations lors des “collectes majeures”
UseAdaptiveSizePolicyWithSystemGC Utilisation des statistiques du garbage collector pour l’auto-réglage de la taille des générations.
UseAdaptiveGCBoundary Autorise les frontières new/old (ratio) à évoluer
AdaptiveSizeThroughPutPolicy Définition des temps accordés à l’application comme objectif premier
AdaptiveSizePausePolicy Définition des temps de pause minima comme objectif premier
AdaptiveSizePolicyInitializingSteps Temps d’observation des statistiques avant leur utilisation
AdaptiveSizePolicyOutputInterval Affichage des données d’auto-réglage en fonction des passages du GC (0 : jamais, 1 à chaque GC, etc.)
UseAdaptiveSizePolicyFootprintGoal Définition de l’empreinte mémoire minimale comme objectif premier
AdaptiveSizePolicyWeight Ratio utilisé lors du redimensionnement des générations (de 0 à 100)
AdaptiveTimeWeight Poids attribué au paramètre temps de pause (de 0 à 100)
PrintAdaptiveSizePolicy Affichage 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

Flag Valeur
GCTimeRatio 99 (1 /100 soit 1 % pour le GC)
InitialHeapSize 67108864
InitialSurvivorRatio 8
MaxGCPauseMillis 18446744073709551615
MaxHeapSize 1073741824
PermSize 21757952
MaxTenuringThreshold 15
MinSurvivorRatio 3
PrintAdaptiveSizePolicy false
SurvivorRatio 8
TargetSurvivorRatio 50
UseAdaptiveGCBoundary false
UseAdaptiveGenerationSizePolicyAtMajorCollection true
UseAdaptiveGenerationSizePolicyAtMinorCollection true
UseAdaptiveSizePolicy true
UseAdaptiveSizePolicyFootprintGoal true
UseAdaptiveSizePolicyWithSystemGC false
UseConcMarkSweepGC false
UseG1GC false
UseParallelGC true
UseParallelOldGC true

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

Flag Valeur
GCTimeRatio 9 (1 /10 soit 10 % pour le GC)
InitialHeapSize 67108864
InitialSurvivorRatio 8
MaxGCPauseMillis 200
MaxHeapSize 1073741824
PermSize 20971520
MaxTenuringThreshold 15
MinSurvivorRatio 3
PrintAdaptiveSizePolicy false
SurvivorRatio 8
TargetSurvivorRatio 50
UseAdaptiveGCBoundary false
UseAdaptiveGenerationSizePolicyAtMajorCollection true
UseAdaptiveGenerationSizePolicyAtMinorCollection true
UseAdaptiveSizePolicy true
UseAdaptiveSizePolicyFootprintGoal true
UseAdaptiveSizePolicyWithSystemGC false
UseConcMarkSweepGC false
UseG1GC true
UseParallelGC false
UseParallelOldGC false

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

Flag JVM Apple version 1.6
GCTimeRatio 99 (1/100 soit 1 % pour le GC)
InitialHeapSize 0
InitialSurvivorRatio 8
MaxGCPauseMillis 18446744073709551615
MaxHeapSize 132120576
PermSize 21757952
MinSurvivorRatio 3
PermSize 21757952
PrintAdaptiveSizePolicy false
SurvivorRatio 8
TargetSurvivorRatio 50
UseAdaptiveGCBoundary false
UseAdaptiveGenerationSizePolicyAtMajorCollection true
UseAdaptiveGenerationSizePolicyAtMinorCollection true
UseAdaptiveSizePolicy false
UseAdaptiveSizePolicyFootprintGoal true
UseAdaptiveSizePolicyWithSystemGC false
UseConcMarkSweepGC true
UseParallelGC false
UseParallelOldGC false
UseParNewGC true
MaxTenuringThreshold 4

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 :

Option Algorithme
+UseSerialGC Serial collector : un seul thread pour le Garbage Collector et l’application est stoppée pendant le passage du GC.
+UseParallelGC Parallel collector : Les traitements du GC sont parallélisés pour la young/new generation (toujours sérialisé pour la old/tenured)
+UseParallelOldGC Parallel compacting collector : Cette fois ci les traitements du GC sont parallélisés pour la young/new et la old/tenured
+UseConcMarkSweepGC Concurrent 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.
+UseG1GC Garbage 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 :

tradebeans Tradebeans runs the Apache daytrader workload “directly” (via EJB) within a Geronimo application server. Daytrader is derived from the IBM Trade6 benchmark.
tradesoap Tradesoap 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]
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)
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

Tweet about this on TwitterShare on FacebookGoogle+Share on LinkedIn
Blabla

2 réflexions au sujet de « Java Ergonomics : Faut-il encore tuner la JVM ? »

  1. Bonjour,

    Excellent article. Néanmoins j’aimerai revenir sur le point où vous dites “Mais encore Java Ergonomics peut augmenter ou diminuer le nombre de threads alloués aux Collecteurs.” Je me base sur OpenJDK 7u. Il faut faire attention à cette partie car l’activation ne se fait pas de la même manière. Pour le Parallel Scavenge, il faut spécifier UseDynamicNumberOfGCThreads et/ou ForceDynamicNumberOfGCThreads. Alors que pour le G1, ces paramètre sont “inutiles” puisqu’ils ne vérifient que si ParallelGCThreads est > 0 (et après si UseDynamicNumberOfGCThreads est défini pour le calcul dynamique). Cela est fait grâce à la méthode use_parallel_gc_threads() (défini dans collectedHeap.hpp:702) et qui regarde si ParallelGCThreads est > 0.

    De plus, pour cette partie la, le calcul des GC Threads à chaque collection est bornée. Tout d’abord en fonction du type de processeur (Niagara – M-Series, …) et ensuite selon la formule suivante :

    switch_pt + ((ncpus – switch_pt) * num) / den où ncpus est le nombre de coeurs, den et switch_pt valent 8 et num 5. (Ces valeurs diffèrent en fonction de l’architecture, mais j’ai prise celle par défaut). Si le nombre de coeurs est inférieur à swtich_pt, on prend le nombre de coeurs sinon la formule précédente.

    Ce qui fait que sur un 72 coeurs, on a 8 + (72 – 8) * (5/8) = 48 worker threads. (défini dans vm_version.cpp, dans la méthode nof_parallel_worker_threads, ligne 280).

    Enfin, le calcul dynamique se base sur :

    – la mémoire (1 thread par tranche de 64m de heap, défini par le paramètre HeapSizePerGCThread). – – le nombre de threads non démons * 2.

    On prend le maximum de ces deux valeurs, puis le minimum avec la borne dont j’ai parlé au-dessus. Pour terminer, si la nouvelle valeur calculée est strictement inférieure à la précédente valeur de GC Threads, la nouvelle valeur devient (new_value + prev_value) / 2. Si on avait 18 GC Threads et qu’on en calcule 6, on aura (18 + 6) / 2 = 12 GC Threads au lieu de 6. Cela est effectué dans AdaptiveSizePolicy.cpp, dans la méthode calc_default_active_workers, ligne 100.

    Au final, vous avez rédigé un excellent article, cela fait plaisir de trouver des posts où l’on rentre dans le coeur de la JVM, il y en a trop peu à mon goût.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *


*