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

Les zones de la mémoire d'un processus Java et l'OutOfMemoryError de chacune : unable to create native thread pour les stacks des threads, Java heap space et GC overhead limit exceeded pour le heap, Metaspace pour le Metaspace, Cannot reserve … bytes of direct buffer memory pour la mémoire directeProcessus Javachaque message dit quelle zone est pleineStacks des threadsun thread du système parnew Thread · -Xsslimite : le systèmeunable to createnative threadHeappartagé · géré par le GC-XmxJava heap spaceGC overhead limitexceededParallel ; G1 depuis 25.0.3Metaspacemémoire native-XX:MaxMetaspaceSizeMetaspaceDirect memoryByteBuffer.allocateDirect()·-XX:MaxDirectMemorySizeCannot reserve … bytes of direct buffer memory (allocated: …, limit: …)en Java 17, 21 et 25 · hors du heap · Java 8 et 11 : Direct buffer memory
MessageZone pleinePremière vérification
Java heap spacele heaple heap après chaque GC (-Xlog:gc*, jcmd <pid> GC.heap_info)
GC overhead limit exceededle heap, presque plein en continupareil : le GC tourne sans arrêt
Metaspacela description des classesjcmd <pid> VM.metaspace
Cannot reserve … bytes of direct buffer memoryla mémoire directe-XX:MaxDirectMemorySize, jcmd <pid> VM.native_memory
unable to create native threadles threads du systèmejcmd <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 :

-XmxJava 8Java 11Java 17Java 21Java 25
16mGC overheadJava heap spaceJava heap spaceJava heap spaceJava heap space
32mpas d’erreur après 60 sGC overheadGC overheadGC overheadpas d’erreur après 60 s
128mpas d’erreur après 60 sGC overheadGC overheadGC overheadJava 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

  1. Lire le message : il dit quelle zone est pleine (tableau du début).
  2. Avoir un heap dump : avec -XX:+HeapDumpOnOutOfMemoryError, la JVM écrit un fichier .hprof. Dans mes tests, il est écrit pour Java heap space, GC overhead limit exceeded et Metaspace, mais pas pour les buffers directs. Prévoyez la place : le fichier peut faire plusieurs Go.
  3. Regarder le heap après chaque GC : stable, le heap est trop petit ; en hausse, c’est une fuite (voir ci-dessous).
  4. 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 encoreCe qui est vrai aujourd’huiDepuis
OutOfMemoryError: Direct buffer memoryCannot reserve … bytes of direct buffer memory (allocated: …, limit: …)Java 17 dans mes tests (11 avait encore l’ancien)
unable to create new native threadunable to create native thread: possibly out of memory or process/resource limits reachedJava 11 dans mes tests (8 avait l’ancien)
GC overhead limit exceeded vient seulement de ParallelG1 peut aussi la leverJava 26, et Java 25.0.3 par backport
PermGen spaceMetaspaceJava 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).

Pour aller plus loin