Aller au contenu

Optimiser les performances de la JVM : focus sur Docker & Kubernetes

3 octobre 2026

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
  
L’objectif principal est d’obtenir le meilleur rapport utilisation / mémoire allouée (used / committed) de la JVM. Dans certaines situations, l’usage de la mémoire hors heap peut être supérieur à celui du heap. Une surveillance adéquate de l’utilisation de l’application est nécessaire avant tout réglage : vous pouvez utiliser les tableaux de bord Datadog pour les conteneurs et la JVM afin d’aider les équipes à comprendre l’usage réel.

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 = 2040

Pour 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.

La JVM calcule par défaut certaines valeurs clés de réglage (appelées ergonomics) en fonction de son environnement d’exécution (cpu, architecture, mémoire). Toutes ces valeurs par défaut peuvent être remplacées à l’aide d’options en ligne de commande.

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.

G1 devient le GC par défaut à partir de Java 27 (JEP 523) ; les autres choix doivent être demandés explicitement avec une option de lancement.

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èreSerialParallelG1ZGCShenandoah
Cœurs et threads1 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ésMaîtrisésUltra-faibles (< 1 ms)Très faibles (< 10 ms)
SurcoûtMinimalMinimalModéréModéré+Modéré++
Effet sur la latence de queueÉlevéÉlevéÉlevéFaibleModéré
Version de JDKToutesToutesJDK 8+JDK 17+JDK 11+
Adapté àUn cœur, petits heapsMachines multicœurs, petits heaps et traitements batchHeaps moyens à grands, services web et bases de donnéesTrès faible latence, grands heapsApplications réactives et bases de données avec de grands heaps
Un GC ne se choisit donc pas sur son seul nom. Des pauses trop longues peuvent dégrader la latence ; un heap ou des allocations mal dimensionnés peuvent aussi mener à un 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_quota et/ou cpu_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.00M

Mesurer 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:InitialRAMPercentage et -XX:MaxRAMPercentage règlent les tailles initiale et maximale du heap en pourcentage de la mémoire disponible pour le conteneur. Elles offrent une alternative à -Xms et -Xmx.
  • -XX:MaxMetaspaceSize fixe une limite au metaspace.
  • -XX:MaxDirectMemorySize limite les buffers directs alloués hors heap.
  • -Xss règle la taille de pile de chaque thread.
  • -XX:ActiveProcessorCount force 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=2

Le 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