Optimiser les performances de la JVM : focus sur Docker & Kubernetes
Un pod Java est limité à 1 Go, mais le processus finit en OOMKilled alors que le
heap
semble encore loin de sa limite. Où est passée la mémoire ?
Le heap n’est qu’une partie de l’empreinte mémoire d’une
JVM
. Pour comprendre ce que voit Kubernetes - et éviter de régler -Xmx à l’aveugle - il faut aussi compter les stacks des threads, le metaspace, les buffers natifs et la mémoire utilisée par le processus lui-même.
La mémoire du processus ne se limite pas au heap
Le Resident Set Size ( RSS ) correspond à la mémoire résidente du processus. Il ne se confond pas avec la taille du heap : la JVM et l’application utilisent aussi de la mémoire native, que le garbage collector ( GC ) ne gère pas comme les objets du heap.
Dans le heap, le garbage collector organise les objets selon leur durée de vie. La jeune génération comprend Eden, où les objets sont alloués, et les espaces Survivor, où passent ceux qui survivent aux premiers cycles de nettoyage. Les objets qui restent longtemps sont ensuite conservés dans l’ancienne génération (Old Gen).
Hors heap, plusieurs zones contribuent à la mémoire du processus :
- Le metaspace contient les métadonnées des classes chargées. Son utilisation tend à se stabiliser après le démarrage de l’application.
- Le code cache contient le code compilé en natif par le compilateur Just-In-Time ( JIT ).
- Chaque thread possède sa propre pile d’exécution. Avec une pile d’environ 1 Mo par thread, plusieurs dizaines de threads représentent déjà plusieurs dizaines de mégaoctets.
- Les buffers directs de
ByteBuffer(NIO) sont alloués hors heap. Les entrées-sorties réseau peuvent en consommer une quantité importante. - Le processus utilise également de la mémoire système.
---
config:
treemap:
valueFormat: '.0%'
---
treemap-beta
"System Memory": 0.1
"Non-Heap"
"Meta Space": 0.04
"Code Cache": 0.10
"Threads Stacks": 0.08
"Byte Buffers": 0.08
"JVM Heap"
"Old Gen": 0.35
"New Gen": 0.25
Ce que la JVM configure à votre place
Au démarrage, la JVM observe les ressources qu’elle peut utiliser - processeurs, mémoire et architecture - puis choisit plusieurs valeurs par défaut. C’est son mécanisme d’ergonomics. Il ajuste notamment le garbage collector, la taille du heap et celle des stacks de threads.
Ces valeurs peuvent varier selon l’environnement. Par exemple, -XX:+PrintFlagsFinal permet de comparer la taille de pile configurée par défaut sur deux architectures :
# Sur une architecture Intel (x86_64)
➜ docker run --platform=linux/amd64 eclipse-temurin:25-jre-alpine java -XX:+PrintFlagsFinal -version | grep -Ei "ThreadStackSize"
intx ThreadStackSize = 1024
# Sur une architecture ARM (arm64)
➜ docker run --platform=linux/arm64 eclipse-temurin:25-jre-alpine java -XX:+PrintFlagsFinal -version | grep -Ei "ThreadStackSize"
intx ThreadStackSize = 2040Pour la mémoire, la valeur par défaut de MaxRAMPercentage limite le heap à 25 % de la mémoire disponible pour le conteneur. Cette valeur peut convenir à certains workloads, mais elle mérite d’être vérifiée : un heap trop petit peut contraindre l’application, tandis qu’un heap trop grand laisse trop peu de marge aux autres allocations.
Choisir un garbage collector
Le choix du garbage collector ( GC ) dépend du profil de l’application : débit, latence, taille du heap et ressources disponibles. Par défaut, la JVM choisit un GC à partir des ressources détectées. Sur une machine de « classe serveur » - au moins deux processeurs actifs et plus de 1791 Mo de RAM - elle sélectionne le G1 GC ; sinon, elle utilise le Serial GC.
Les principaux GC ont des profils différents :
- Serial GC est monothread et utilise peu de ressources. Il convient aux machines à un cœur et aux petits heaps de moins de 4 Go.
- Parallel GC privilégie le débit en utilisant plusieurs threads. Il peut être plus performant que G1 sur les heaps jusqu’à 2 Go, au prix de pauses Stop the World susceptibles d’affecter la latence p99.
- G1 GC (Garbage-First) cherche un compromis entre débit et pauses maîtrisées sur les heaps de taille moyenne ou importante.
- ZGC, production-ready depuis JDK 15 (JEP 377), vise de faibles latences sur de grands heaps.
- Shenandoah, production-ready depuis JDK 15 (JEP 379), vise lui aussi des temps de pause réduits.
| Critère | Serial | Parallel | G1 | ZGC | Shenandoah |
|---|---|---|---|---|---|
| Cœurs et threads | 1 cœur (pas de multithreading) | 2 cœurs ou plus (multithread) | 2 cœurs ou plus (multithread) | 2 cœurs ou plus (multithread) | 2 cœurs ou plus (multithread) |
| Taille du heap | < 4 Go | < 4 Go | > 4 Go | > 4 Go | > 4 Go |
| Temps de pause | Élevés | Élevés | Maîtrisés | Ultra-faibles (< 1 ms) | Très faibles (< 10 ms) |
| Surcoût | Minimal | Minimal | Modéré | Modéré+ | Modéré++ |
| Effet sur la latence de queue | Élevé | Élevé | Élevé | Faible | Modéré |
| Version de JDK | Toutes | Toutes | JDK 8+ | JDK 17+ | JDK 11+ |
| Adapté à | Un cœur, petits heaps | Machines multicœurs, petits heaps et traitements batch | Heaps moyens à grands, services web et bases de données | Très faible latence, grands heaps | Applications réactives et bases de données avec de grands heaps |
OutOfMemoryError. Mesurez le comportement de l’application avant de changer de GC.Ce que Kubernetes change
La limite mémoire du conteneur s’applique au processus entier, pas seulement au heap. Un nœud doté de 32 Go ne permet donc pas nécessairement d’y placer 32 pods Java limités à 1 Go : chaque JVM a aussi besoin de mémoire hors heap et de mémoire système.
La JVM tient compte des limites des conteneurs Linux et peut lire les métriques exposées par cgroups v2. Elle adapte ainsi ses calculs aux ressources du conteneur plutôt qu’à celles du nœud (voir le ticket Java 17: What’s new in OpenJDK’s container awareness).
La JVM détecte ainsi les quotas CPU (cpu_quota et/ou cpu_period) et la limite mémoire disponible (memory.limit).
À titre d’exemple, jusqu’à 1000 millicores correspondent à un processeur utilisable ; entre 1001 et 2000, elle en compte deux. Cette valeur influe notamment sur le nombre de threads utilisés par les GC parallèles et les pools de threads.
Sur la question des
cpu_quotaet/oucpu_period, voir l’article Containerize your Java applications for Kubernetes: Understand JVM available processors
# Vérifier les ressources détectées dans le conteneur
➜ docker run -m 512m --cpus="2" eclipse-temurin:25-jre-alpine java -XshowSettings:system -version
Operating System Metrics:
Provider: cgroupv2
Effective CPU Count: 2
Memory Limit: 512.00MMesurer puis ajuster
Les valeurs par défaut sont un point de départ, pas une configuration garantie pour votre charge. Avant de les modifier, regardez l’utilisation réelle en production : mémoire du processus, heap, threads et latence des pauses GC. Un outil de monitoring aide à suivre ces signaux.
Quelques options utiles pour cadrer les ressources :
-XX:InitialRAMPercentageet-XX:MaxRAMPercentagerèglent les tailles initiale et maximale du heap en pourcentage de la mémoire disponible pour le conteneur. Elles offrent une alternative à-Xmset-Xmx.-XX:MaxMetaspaceSizefixe une limite au metaspace.-XX:MaxDirectMemorySizelimite les buffers directs alloués hors heap.-Xssrègle la taille de pile de chaque thread.-XX:ActiveProcessorCountforce le nombre de processeurs que la JVM prend en compte, ce qui peut être utile dans un environnement Kubernetes.
Des valeurs de départ pour vos conteneurs
Pour rendre le dimensionnement plus prévisible, on peut fixer la même valeur initiale et maximale pour le heap. Il faut toutefois garder une marge pour le metaspace, les stacks, les buffers directs et le système. Les pourcentages ci-dessous sont des points de départ à confronter aux mesures de votre application, pas des valeurs universelles.
Au-delà de 2 Go de mémoire disponible, avec G1 GC : commencez autour de 60 % pour le heap. Selon les besoins de l’application et les mesures, cette part peut monter vers 75 %.
-XX:+UseG1GC
-XX:InitialRAMPercentage=75.0
-XX:MaxRAMPercentage=75.0
-XX:ActiveProcessorCount=2À 2 Go ou moins, avec Parallel GC : une valeur de départ autour de 60 % laisse de la place au système et aux allocations hors heap.
-XX:+UseParallelGC
-XX:InitialRAMPercentage=60.0
-XX:MaxRAMPercentage=60.0
-XX:ActiveProcessorCount=2Le bon réglage dépend finalement du profil de l’application et de ses limites de conteneur. Commencez par mesurer l’ensemble du processus, choisissez une marge adaptée, puis ajustez le heap et le GC à partir des résultats - pas seulement d’une valeur par défaut.
Références & bibliographie
- Java Platform, Standard Edition HotSpot Virtual Machine Garbage Collection Tuning Guide
- Containerize your Java applications for Kubernetes
- Kubernetes and Java
- How to optimize memory consumption for WildFly running in Kubernetes
- Optimizing Java Applications on Kubernetes: beyond the Basics - Bruno Borges