La mémoire hors-heap : où part la RAM de la JVM

Le scénario est classique. La heap est stable, les logs GC sont propres, et pourtant le RSS du processus grimpe, jour après jour. Au bout d’une semaine, le pod se fait tuer avec un OOMKilled. On prend un heap dump, on cherche la fuite, on ne trouve rien. Normal : la mémoire qui déborde n’est pas dans la heap.

Une JVM consomme bien plus que sa heap. Metaspace, stacks de threads, buffers directs, code compilé par le JIT, structures internes du GC : tout ça vit à côté, et rien de tout ça n’apparaît dans un heap dump. Cet article fait le tour de cette mémoire hors-heap. Ce qui la compose, comment la mesurer, et où chercher quand elle gonfle.

Avant de continuer, vérifiez que le problème est bien là. Si la heap se remplit et que les collectes ne libèrent plus rien, ce n’est pas un sujet hors-heap, c’est une fuite classique : Diagnostiquer une fuite mémoire avec un heap dump. Et si vous soupçonnez plutôt les limites du conteneur, Régler la JVM en conteneur pose les bases.

Ce qui vit à côté de la heap

Le processus JVM contient plusieurs zones de mémoire, et la heap n’est que la plus grosse. Autour d’elle :

Additionnez tout ça et vous comprenez pourquoi un conteneur de 1 Go avec une heap de 768 Mo finit par se faire tuer. La heap n’a rien fait de mal. C’est le reste qui n’avait plus de place.

Mesurer avec Native Memory Tracking

La JVM sait détailler sa propre consommation. Ça s’appelle le Native Memory Tracking, et ça s’active au démarrage :

-XX:NativeMemoryTracking=summary

La doc Oracle annonce 5 à 10 % de surcoût, et le mode summary coûte moins que ça en pratique. Par contre, le flag demande un redémarrage. Une fois le processus en marche, on interroge avec jcmd :

jcmd <pid> VM.native_memory summary scale=MB

La sortie liste chaque poste, ici raccourcie à l’essentiel :

Total: reserved=6318MB, committed=1704MB
-       Java Heap (reserved=4096MB, committed=1024MB)
-           Class (reserved=1024MB, committed=12MB)
-          Thread (reserved=250MB, committed=84MB)
-            Code (reserved=245MB, committed=52MB)
-              GC (reserved=200MB, committed=98MB)
-           Other (reserved=310MB, committed=310MB)
-       Metaspace (reserved=128MB, committed=96MB)

Deux chiffres par ligne, et la différence compte. Le reserved est de l’espace d’adressage demandé au système, il ne coûte presque rien. Le committed est la mémoire que la JVM s’est engagée à utiliser, et c’est lui qui se rapproche du RSS. Quand vous cherchez où part la RAM, lisez la colonne committed, ignorez l’autre.

Les postes parlent d’eux-mêmes. Metaspace contient les métadonnées de classes. Thread compte les stacks, et son committed ne compte que les pages vraiment touchées. Other regroupe entre autres les buffers directs. Ici, 250 threads et 310 Mo de buffers directs à côté d’une heap de 1 Go : voilà déjà une bonne partie du conteneur expliquée.

Le vrai réflexe pour une fuite, c’est le mode différentiel. On pose un point de repère, on laisse tourner, et on compare :

jcmd <pid> VM.native_memory baseline
# quelques heures plus tard
jcmd <pid> VM.native_memory summary.diff scale=MB

La sortie affiche l’écart de chaque poste depuis le baseline. Le poste qui grimpe en continu est votre coupable, et vous savez tout de suite où creuser.

Les suspects habituels

Le Metaspace qui gonfle. Il grandit quand des classes se chargent sans jamais se décharger. Les causes classiques : des redéploiements à chaud, et surtout la génération de classes à la volée (proxies, reflection, moteurs de scripts). Comme il n’a pas de limite par défaut, il peut pousser le conteneur jusqu’à l’OOMKilled sans un bruit. Poser -XX:MaxMetaspaceSize=256m ne corrige rien, mais transforme la mort silencieuse en OutOfMemoryError: Metaspace avec une stack trace, ce qui est beaucoup plus facile à diagnostiquer.

Trop de threads. Chaque thread platform coûte sa stack. Un pool mal borné, ou des pools créés à chaque requête et jamais fermés, se voient directement sur la ligne Thread du NMT. Un thread dump montre combien il y en a et qui les a créés, c’est le sujet de Lire un thread dump avec jstack. Les virtual threads ne posent pas ce problème, leur pile vit dans la heap : Les virtual threads en production.

Les buffers directs qui s’accumulent. Le piège le plus vicieux. Un DirectByteBuffer alloue sa mémoire hors de la heap, mais elle n’est rendue au système que quand l’objet Java, lui, est ramassé par le GC. Avec une grosse heap et des collectes rares, les buffers morts s’entassent en attendant une collecte qui ne vient pas, et la mémoire directe sature alors que la heap est à moitié vide. La limite se règle avec -XX:MaxDirectMemorySize, qui vaut par défaut la taille max de la heap. Le pool se surveille par JMX, via le BufferPoolMXBean nommé direct, exposé par la plupart des agents de métriques.

Le code cache et le GC. Rarement coupables. Leur taille se stabilise après la montée en charge. S’ils apparaissent dans un diff NMT après plusieurs jours, c’est inhabituel et ça mérite un regard, mais commencez toujours par les trois suspects du dessus.

Ce que NMT ne voit pas

Le NMT ne trace que les allocations faites par la JVM elle-même. Une bibliothèque native qui alloue avec son propre malloc via JNI passe complètement sous le radar. Le grand classique est zlib : un Inflater ou un Deflater jamais fermé garde son buffer natif, et des milliers d’entre eux font une vraie fuite, invisible dans le NMT comme dans le heap dump.

L’allocateur lui-même peut aussi jouer contre vous. La glibc découpe la mémoire en arenas, à raison de 8 par cœur, et a du mal à rendre au système la mémoire libérée. Sur une appli qui alloue beaucoup en natif, le RSS dépasse largement la somme des postes NMT, par simple fragmentation. Le remède connu en conteneur tient en une variable d’environnement :

MALLOC_ARENA_MAX=2

Si l’écart persiste, l’étape suivante est de remplacer l’allocateur par jemalloc et d’activer son profiling. Il enregistre les stacks d’allocation natives et pointe la bibliothèque responsable. C’est plus lourd à mettre en place, mais c’est l’outil qui trouve les fuites que rien d’autre ne voit.

En résumé

Un RSS qui monte avec une heap saine, c’est de la mémoire hors-heap. Activez -XX:NativeMemoryTracking=summary, lisez la colonne committed, et servez-vous du mode summary.diff pour voir quel poste grimpe. Metaspace, threads et buffers directs expliquent la plupart des cas. Si le NMT ne voit rien, pensez aux allocations natives des bibliothèques et à la fragmentation de la glibc. Et en conteneur, gardez toujours de la marge entre la heap et la limite mémoire : ce n’est pas du gaspillage, c’est la place du reste de la JVM.