Une OutOfMemoryError, ce n’est pas un seul problème. Le texte après
les deux-points dit quelle zone de la mémoire est pleine : le heap, le
Metaspace, la mémoire directe ou les threads. Et chaque zone se
vérifie et se corrige autrement.
Pour chaque message, j’ai écrit un petit programme qui le provoque. Ils
sont dans le dépôt public
java-gc-lab,
un fichier par message, à lancer avec java sans compiler (Java 11 ou
plus).
Le message dit quelle zone est pleine
| Message | Zone pleine | Première vérification |
|---|---|---|
Java heap space | le heap | le heap après chaque GC (-Xlog:gc*, jcmd <pid> GC.heap_info) |
GC overhead limit exceeded | le heap, presque plein en continu | pareil : le GC tourne sans arrêt |
Metaspace | la description des classes | jcmd <pid> VM.metaspace |
Cannot reserve … bytes of direct buffer memory | la mémoire directe | -XX:MaxDirectMemorySize, jcmd <pid> VM.native_memory |
unable to create native thread | les threads du système | jcmd <pid> Thread.print, les limites du système |
Java heap space
java -Xmx64m oom/HeapSpace.java
java.lang.OutOfMemoryError: Java heap space
Le programme garde dans une liste des tableaux de 1 Mo : le GC ne peut rien libérer, et le heap de 64 Mo est plein en moins d’une seconde.
Le guide d’Oracle est clair : ce message ne veut pas forcément dire qu’il y a une fuite. Le heap peut simplement être trop petit pour l’application. Mais dans une application qui tourne longtemps, c’est souvent le signe qu’elle garde des références à des objets sans le vouloir.
Pour savoir lequel des deux, regardez le heap après chaque GC :
avec -Xlog:gc*, ou avec jcmd <pid> GC.heap_info sur une application
qui tourne. S’il redescend toujours au même niveau, le heap est juste
trop petit. S’il monte d’un GC à l’autre, quelque chose garde les
objets.
GC overhead limit exceeded
java -Xmx32m -XX:+UseParallelGC oom/GcOverhead.java
java.lang.OutOfMemoryError: GC overhead limit exceeded
Ici, le heap se remplit lentement, avec de petits objets. Le GC tourne sans arrêt et ne libère presque rien. La JVM préfère alors s’arrêter plutôt que de continuer sans avancer : d’après la documentation, elle lève cette erreur si plus de 98 % du temps passe dans le GC et que moins de 2 % du heap est récupéré, sur cinq GC de suite.
Ce message n’est pas garanti. Avec le même programme et le GC Parallel, voici ce que j’obtiens :
-Xmx | Java 8 | Java 11 | Java 17 | Java 21 | Java 25 |
|---|---|---|---|---|---|
| 16m | GC overhead | Java heap space | Java heap space | Java heap space | Java heap space |
| 32m | pas d’erreur après 60 s | GC overhead | GC overhead | GC overhead | pas d’erreur après 60 s |
| 128m | pas d’erreur après 60 s | GC overhead | GC overhead | GC overhead | Java heap space |
« Pas d’erreur après 60 s », c’est le pire cas : la JVM passe son temps dans le GC et l’application ne fait plus rien, sans erreur.
Longtemps, seul Parallel levait cette erreur. G1, le GC par défaut,
l’a aussi depuis Java 26, avec un backport dans Java 25.0.3
(JDK-8212084). Mais dans
mes tests avec G1 sur Java 25.0.4, de -Xmx16m à -Xmx256m, j’obtiens
toujours Java heap space. D’après le guide, la cause habituelle est
la même : des données vivantes qui tiennent à peine dans le heap.
Metaspace
java -XX:MaxMetaspaceSize=32m oom/MetaspaceFull.java
java.lang.OutOfMemoryError: Metaspace
Le Metaspace contient la description des classes, en mémoire native. Le programme crée en boucle un nouveau class loader et une nouvelle classe dedans ; comme il les garde, rien ne peut être déchargé.
Par défaut, le Metaspace n’a pas de limite. Si une limite est fixée
avec -XX:MaxMetaspaceSize, le guide conseille de l’augmenter. Si le
Metaspace monte sans arrêt (jcmd <pid> VM.metaspace), le problème
vient plutôt de classes qui ne peuvent pas être déchargées, comme dans
ce programme.
Direct buffer memory
java -XX:MaxDirectMemorySize=32m oom/DirectBuffer.java
java.lang.OutOfMemoryError: Cannot reserve 1048576 bytes of direct buffer memory (allocated: 32514048, limit: 33554432)
Les buffers directs (ByteBuffer.allocateDirect) vivent hors du heap.
Leur limite est
-XX:MaxDirectMemorySize ; sans cette option, la JVM la choisit
elle-même.
Avant Java 17 : en Java 8 et 11, le message était simplement
Direct buffer memory. Depuis Java 17, dans mes tests, il donne la taille demandée, la mémoire déjà prise et la limite, ce qui aide beaucoup.
Pour voir cette mémoire sur une application qui tourne :
-XX:NativeMemoryTracking=summary au démarrage, puis
jcmd <pid> VM.native_memory.
Unable to create native thread
Ce programme démarre des threads jusqu’à ce que le système refuse. Ne le lancez pas sur votre machine sans limite : je l’ai lancé dans un conteneur limité à 200 processus.
docker run --rm --pids-limit 200 -v "$PWD/oom":/oom eclipse-temurin:25 java /oom/NativeThreads.java
java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
Avant Java 11 : en Java 8, le message était
unable to create new native thread.
Chaque thread démarré avec new Thread est un thread du système, avec
sa propre pile hors du heap. La cause est donc une limite du système
(nombre de processus ou de threads, mémoire), ou une application qui
crée des threads sans fin, par exemple un pool sans taille maximale.
jcmd <pid> Thread.print montre tous les threads, avec leur pile
d’appels. Augmenter -Xmx ne change rien à une limite du système.
La méthode, quel que soit le message
- Lire le message : il dit quelle zone est pleine (tableau du début).
- Avoir un heap dump : avec
-XX:+HeapDumpOnOutOfMemoryError, la JVM écrit un fichier.hprof. Dans mes tests, il est écrit pourJava heap space,GC overhead limit exceededetMetaspace, mais pas pour les buffers directs. Prévoyez la place : le fichier peut faire plusieurs Go. - Regarder le heap après chaque GC : stable, le heap est trop petit ; en hausse, c’est une fuite (voir ci-dessous).
- Corriger : agrandir la bonne zone, ou trouver ce qui garde les objets.
Heap trop petit, ou fuite ?
Avec -Xlog:gc, chaque GC écrit une ligne. Le chiffre qui compte est
celui après la flèche : le heap après le GC, donc à peu près les
données encore vivantes.
Avec SteadyLoad.java, les données vivantes ne changent pas. Après chaque GC, le heap revient au même niveau :
java -Xmx64m -Xlog:gc oom/SteadyLoad.java
GC(2) Pause Young (Normal) (G1 Evacuation Pause) 41M->24M(64M) 0.379ms
GC(5) Pause Young (Normal) (G1 Evacuation Pause) 47M->24M(64M) 0.538ms
GC(9) Pause Young (Normal) (G1 Evacuation Pause) 54M->24M(64M) 0.624ms
Avec SlowLeak.java,
le programme garde 1 Mo de plus à chaque tour. Après chaque GC, le heap
monte, jusqu’à Java heap space :
java -Xmx64m -Xlog:gc oom/SlowLeak.java
GC(1) Pause Young (Normal) (G1 Evacuation Pause) 30M->5M(64M) 2.292ms
GC(4) Pause Young (Normal) (G1 Evacuation Pause) 42M->11M(64M) 0.916ms
GC(8) Pause Young (Normal) (G1 Evacuation Pause) 50M->19M(64M) 0.938ms
GC(12) Pause Young (Normal) (G1 Evacuation Pause) 54M->29M(64M) 1.283ms
Sur une application déjà lancée, sans la redémarrer,
jstat -gcutil <pid> 1s affiche chaque seconde l’occupation de chaque
zone : dans le deuxième cas, la colonne O (la Old, en %) monte de la
même façon. Sa documentation précise qu’il est expérimental et non
supporté.
Augmenter -Xmx n’est pas une faute en soi : c’est même la première
action que propose le guide d’Oracle pour Java heap space. L’erreur,
c’est de l’augmenter encore et encore quand le heap remonte après
chaque GC : la fuite est toujours là, elle prend juste plus de temps à
remplir le heap.
Ce qui a changé
| Ce qu’on lit encore | Ce qui est vrai aujourd’hui | Depuis |
|---|---|---|
OutOfMemoryError: Direct buffer memory | Cannot reserve … bytes of direct buffer memory (allocated: …, limit: …) | Java 17 dans mes tests (11 avait encore l’ancien) |
unable to create new native thread | unable to create native thread: possibly out of memory or process/resource limits reached | Java 11 dans mes tests (8 avait l’ancien) |
GC overhead limit exceeded vient seulement de Parallel | G1 peut aussi la lever | Java 26, et Java 25.0.3 par backport |
PermGen space | Metaspace | Java 8 |
Testé avec Java 8 (Corretto 8u504), 11, 17, 21 et 25 (Temurin 11.0.32,
17.0.20, 21.0.9, 25.0.4), sur macOS ; le cas des threads dans Docker
(eclipse-temurin).
Sources : le
guide de dépannage d’Oracle sur les fuites mémoire,
la documentation des commandes
java,
jcmd
et jstat
(JDK 25), JDK-8212084
(G1 et la limite d’overhead).