Suivi de la mémoire native dans JVM

1. Vue d'ensemble

Vous êtes-vous déjà demandé pourquoi les applications Java consomment beaucoup plus de mémoire que la quantité spécifiée via les indicateurs de réglage bien connus -Xms et -Xmx ? Pour diverses raisons et optimisations possibles, la JVM peut allouer de la mémoire native supplémentaire. Ces allocations supplémentaires peuvent éventuellement augmenter la mémoire consommée au-delà de la limitation -Xmx .

Dans ce didacticiel, nous allons énumérer quelques sources communes d'allocations de mémoire native dans la JVM, ainsi que leurs indicateurs de réglage de dimensionnement, puis apprendre à utiliser le suivi de la mémoire native pour les surveiller.

2. Allocations natives

Le tas est généralement le plus gros consommateur de mémoire dans les applications Java, mais il en existe d'autres. Outre le tas, la JVM alloue une assez grande partie de la mémoire native pour maintenir ses métadonnées de classe, le code d'application, le code généré par JIT, les structures de données internes, etc. Dans les sections suivantes, nous explorerons certaines de ces allocations.

2.1. Metaspace

Afin de conserver certaines métadonnées sur les classes chargées, la machine virtuelle Java utilise une zone non-tas dédiée appelée Metaspace . Avant Java 8, l'équivalent s'appelait PermGen ou Permanent Generation . Metaspace ou PermGen contient les métadonnées sur les classes chargées plutôt que leurs instances, qui sont conservées dans le tas.

L'important ici est que les configurations de dimensionnement du tas n'affecteront pas la taille de Metaspace puisque le Metaspace est une zone de données hors tas. Afin de limiter la taille de Metaspace, nous utilisons d'autres indicateurs de réglage:

  • -XX: MetaspaceSize et -XX: MaxMetaspaceSize pour définir la taille minimale et maximale de Metaspace
  • Avant Java 8, -XX: PermSize et -XX: MaxPermSize pour définir la taille PermGen minimale et maximale

2.2. Fils

L'une des zones de données les plus consommatrices de mémoire de la JVM est la pile, créée en même temps que chaque thread. La pile stocke des variables locales et des résultats partiels, jouant un rôle important dans les appels de méthode.

La taille de la pile de threads par défaut dépend de la plate-forme, mais dans la plupart des systèmes d'exploitation 64 bits modernes, elle est d'environ 1 Mo. Cette taille est configurable via l' indicateur de réglage -Xss .

Contrairement aux autres zones de données, la mémoire totale allouée aux piles est pratiquement illimitée lorsqu'il n'y a pas de limitation du nombre de threads. Il convient également de mentionner que la JVM elle-même a besoin de quelques threads pour effectuer ses opérations internes telles que GC ou des compilations juste à temps.

2.3. Cache de code

Afin d'exécuter le bytecode JVM sur différentes plates-formes, il doit être converti en instructions machine. Le compilateur JIT est responsable de cette compilation lorsque le programme est exécuté.

Lorsque la JVM compile le bytecode en instructions d'assemblage, elle stocke ces instructions dans une zone de données spéciale non-tas appelée Code Cache. Le cache de code peut être géré comme les autres zones de données de la JVM. Les indicateurs de réglage -XX: InitialCodeCacheSize et -XX: ReservedCodeCacheSize déterminent la taille initiale et maximale possible pour le cache de code.

2.4. Collecte des ordures

La JVM est livrée avec une poignée d'algorithmes GC, chacun adapté à différents cas d'utilisation. Tous ces algorithmes GC partagent un trait commun: ils doivent utiliser des structures de données hors tas pour effectuer leurs tâches. Ces structures de données internes consomment plus de mémoire native.

2.5. Symboles

Commençons par Strings, l' un des types de données les plus couramment utilisés dans le code d'application et de bibliothèque. En raison de leur ubiquité, ils occupent généralement une grande partie du tas. Si un grand nombre de ces chaînes contiennent le même contenu, une partie importante du tas sera gaspillée.

Afin d'économiser de l'espace sur le tas, nous pouvons stocker une version de chaque chaîne et faire en sorte que d'autres se réfèrent à la version stockée. Ce processus s'appelle String Interning. Étant donné que la JVM ne peut interner que des constantes de chaîne de temps de compilation, nous pouvons appeler manuellement la méthode intern () sur les chaînes que nous avons l'intention d'interner.

La JVM stocke les chaînes internes dans une table de hachage native spéciale de taille fixe appelée String Table, également appelée String Pool . Nous pouvons configurer la taille de la table (c'est-à-dire le nombre de buckets) via l' indicateur de réglage -XX: StringTableSize .

En plus de la table de chaînes, il existe une autre zone de données native appelée Runtime Constant Pool. JVM utilise ce pool pour stocker des constantes telles que des littéraux numériques au moment de la compilation ou des références de méthode et de champ qui doivent être résolues lors de l'exécution.

2.6. Tampons d'octets natifs

La JVM est le suspect habituel pour un nombre important d'allocations natives, mais parfois les développeurs peuvent également allouer directement de la mémoire native. Les approches les plus courantes sont l' appel malloc par JNI et les ByteBuffers directs de NIO.

2.7. Drapeaux de réglage supplémentaires

Dans cette section, nous avons utilisé une poignée d'indicateurs de réglage JVM pour différents scénarios d'optimisation. En utilisant le conseil suivant, nous pouvons trouver presque tous les indicateurs de réglage liés à un concept particulier:

$ java -XX:+PrintFlagsFinal -version | grep 

Les PrintFlagsFinal imprime tous les - XX choix dans JVM. Par exemple, pour trouver tous les indicateurs liés à Metaspace:

$ java -XX:+PrintFlagsFinal -version | grep Metaspace // truncated uintx MaxMetaspaceSize = 18446744073709547520 {product} uintx MetaspaceSize = 21807104 {pd product} // truncated

3. Suivi de la mémoire native (NMT)

Maintenant que nous connaissons les sources communes d'allocations de mémoire native dans la JVM, il est temps de découvrir comment les surveiller. Tout d'abord, nous devons activer le suivi de la mémoire native en utilisant un autre indicateur de réglage JVM: -XX: NativeMemoryTracking = off | sumary | detail. Par défaut, le NMT est désactivé mais nous pouvons lui permettre de voir un résumé ou une vue détaillée de ses observations.

Supposons que nous souhaitons suivre les allocations natives pour une application Spring Boot typique:

$ java -XX:NativeMemoryTracking=summary -Xms300m -Xmx300m -XX:+UseG1GC -jar app.jar

Ici, nous activons le NMT tout en allouant 300 Mo d'espace de tas, avec G1 comme algorithme GC.

3.1. Instantanés instantanés

Lorsque NMT est activé, nous pouvons obtenir les informations de la mémoire native à tout moment en utilisant la commande jcmd :

$ jcmd  VM.native_memory

Afin de trouver le PID d'une application JVM, nous pouvons utiliser le jpscommander:

$ jps -l 7858 app.jar // This is our app 7899 sun.tools.jps.Jps

Maintenant, si nous utilisons jcmd avec le pid approprié , VM.native_memory oblige la JVM à imprimer les informations sur les allocations natives:

$ jcmd 7858 VM.native_memory

Analysons la sortie NMT section par section.

3.2. Allocations totales

NMT rapporte le total de la mémoire réservée et engagée comme suit:

Native Memory Tracking: Total: reserved=1731124KB, committed=448152KB

La mémoire réservée représente la quantité totale de mémoire que notre application peut potentiellement utiliser. Inversement, la mémoire engagée est égale à la quantité de mémoire utilisée actuellement par notre application.

Malgré l'allocation de 300 Mo de tas, la mémoire totale réservée pour notre application est de près de 1,7 Go, bien plus que cela. De même, la mémoire engagée est d'environ 440 Mo, ce qui est, encore une fois, bien plus que 300 Mo.

Après la section totale, NMT signale les allocations de mémoire par source d'allocation. Alors, explorons chaque source en profondeur.

3.3. Tas

NMT reports our heap allocations as we expected:

Java Heap (reserved=307200KB, committed=307200KB) (mmap: reserved=307200KB, committed=307200KB)

300 MB of both reserved and committed memory, which matches our heap size settings.

3.4. Metaspace

Here's what the NMT says about the class metadata for loaded classes:

Class (reserved=1091407KB, committed=45815KB) (classes #6566) (malloc=10063KB #8519) (mmap: reserved=1081344KB, committed=35752KB)

Almost 1 GB reserved and 45 MB committed to loading 6566 classes.

3.5. Thread

And here's the NMT report on thread allocations:

Thread (reserved=37018KB, committed=37018KB) (thread #37) (stack: reserved=36864KB, committed=36864KB) (malloc=112KB #190) (arena=42KB #72)

In total, 36 MB of memory is allocated to stacks for 37 threads – almost 1 MB per stack. JVM allocates the memory to threads at the time of creation, so the reserved and committed allocations are equal.

3.6. Code Cache

Let's see what NMT says about the generated and cached assembly instructions by JIT:

Code (reserved=251549KB, committed=14169KB) (malloc=1949KB #3424) (mmap: reserved=249600KB, committed=12220KB)

Currently, almost 13 MB of code is being cached, and this amount can potentially go up to approximately 245 MB.

3.7. GC

Here's the NMT report about G1 GC's memory usage:

GC (reserved=61771KB, committed=61771KB) (malloc=17603KB #4501) (mmap: reserved=44168KB, committed=44168KB)

As we can see, almost 60 MB is reserved and committed to helping G1.

Let's see how the memory usage looks like for a much simpler GC, say Serial GC:

$ java -XX:NativeMemoryTracking=summary -Xms300m -Xmx300m -XX:+UseSerialGC -jar app.jar

The Serial GC barely uses 1 MB:

GC (reserved=1034KB, committed=1034KB) (malloc=26KB #158) (mmap: reserved=1008KB, committed=1008KB)

Obviously, we shouldn't pick a GC algorithm just because of its memory usage, as the stop-the-world nature of the Serial GC may cause performance degradations. There are, however, several GCs to choose from, and they each balance memory and performance differently.

3.8. Symbol

Here is the NMT report about the symbol allocations, such as the string table and constant pool:

Symbol (reserved=10148KB, committed=10148KB) (malloc=7295KB #66194) (arena=2853KB #1)

Almost 10 MB is allocated to symbols.

3.9. NMT Over Time

The NMT allows us to track how memory allocations change over time. First, we should mark the current state of our application as a baseline:

$ jcmd  VM.native_memory baseline Baseline succeeded

Then, after a while, we can compare the current memory usage with that baseline:

$ jcmd  VM.native_memory summary.diff

NMT, en utilisant les signes + et -, nous indiquerait comment l'utilisation de la mémoire a changé au cours de cette période:

Total: reserved=1771487KB +3373KB, committed=491491KB +6873KB - Java Heap (reserved=307200KB, committed=307200KB) (mmap: reserved=307200KB, committed=307200KB) - Class (reserved=1084300KB +2103KB, committed=39356KB +2871KB) // Truncated

Le total de la mémoire réservée et engagée a augmenté de 3 Mo et 6 Mo, respectivement. D'autres fluctuations dans les allocations de mémoire peuvent être repérées aussi facilement.

3.10. NMT détaillé

NMT peut fournir des informations très détaillées sur une carte de tout l'espace mémoire. Pour activer ce rapport détaillé, nous devons utiliser l' indicateur de réglage -XX: NativeMemoryTracking = detail .

4. Conclusion

Dans cet article, nous avons énuméré différents contributeurs aux allocations de mémoire natives dans la JVM. Ensuite, nous avons appris à inspecter une application en cours d'exécution pour surveiller ses allocations natives. Grâce à ces informations, nous pouvons régler plus efficacement nos applications et dimensionner nos environnements d'exécution.