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 la heap semble encore loin de sa limite. Où est passée la mémoire ?

La 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 à la heap

La mémoire RSS correspond à la mémoire occupée par le processus sur le système d’exploitation. Il ne se confond pas avec la taille de la 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 de la heap.

Dans la 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).

En dehors de la 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
"RSS Memory"
    "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 de la heap.

Une surveillance adéquate de l’utilisation de l’application est nécessaire avant tout réglage.

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. Ce mécanisme d’auto-configuration est appelé Ergonomics. Il ajuste notamment le garbage collector, la taille de la heap et celle des stacks de threads.

Taille de la pile

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:27-jre \
    java -XX:+PrintFlagsFinal -version | grep -Ei "ThreadStackSize"
     intx ThreadStackSize = 1024
# Sur une architecture ARM (arm64)
➜ docker run --platform=linux/arm64 eclipse-temurin:27-jre \
    java -XX:+PrintFlagsFinal -version | grep -Ei "ThreadStackSize"
     intx ThreadStackSize = 2040

Mémoire allouée à la heap

Pour la mémoire, la valeur par défaut de MaxRAMPercentage limite la 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.

# Avec 2 Go de RAM pour le container -> 25% donc 512 Mo pour la Heap
➜ docker run --memory=2g eclipse-temurin:27-jre \
    java -XX:+PrintFlagsFinal -version | grep -E "(MaxRAMPercentage|MaxHeapSize)"
   size_t MaxHeapSize = 536870912 {product} {ergonomic}
   double MaxRAMPercentage = 25.0 {product} {default}
# Avec 512 Mo de RAM pour le container -> 25% donc 128 Mo pour la Heap
➜ docker run --memory=512m eclipse-temurin:27-jre \
    java -XX:+PrintFlagsFinal -version | grep -E "(MaxRAMPercentage|MaxHeapSize)"
   size_t MaxHeapSize = 134217728 {product} {ergonomic}
   double MaxRAMPercentage = 25.0 {product} {default}
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 de la heap et ressources disponibles. Par défaut, la JVM choisit un GC à partir des ressources détectées. Historiquement, sur une machine de « classe serveur » - au moins deux processeurs actifs et plus de 1791 Mo de RAM - la JVM sélectionnait le G1 GC ; sinon, la JVM utilisait le Serial GC.

G1 devient le GC par défaut à partir de Java 27 (JEP 523) peu importe le contexte ; 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 de la 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.

Et dans un contexte Kubernetes, ca change quoi ?

La limite mémoire du conteneur s’applique au processus entier, pas seulement à la 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:27-jre \
    java -XshowSettings:system -version
Operating System Metrics:  
    Provider: cgroupv2  
    Effective CPU Count: 2  
    Memory Limit: 512.00M

Stratégie : 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 de la 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 la 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 la 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 la heap et le GC à partir des résultats - pas seulement d’une valeur par défaut.

Références & bibliographie