Les 6 OutOfMemoryError de la JVM et comment lire chacun

java.lang.OutOfMemoryError n’est pas un problème, c’est une famille de problèmes. Ce qui compte, c’est le texte après les deux-points. Java heap space et unable to create native thread n’ont pas la même cause, ne se voient pas avec le même outil, et ne se corrigent pas au même endroit. Augmenter -Xmx règle le premier et n’a aucun effet sur le second.

Cet article passe en revue les six messages que HotSpot produit en pratique. Pour chacun : ce que la JVM essayait de faire, pourquoi elle n’a pas pu, et par quoi commencer. Tous les extraits viennent d’un Temurin 25.0.4 sous Linux, dans un conteneur, avec des programmes de quelques lignes qui provoquent l’erreur exprès.

Le point de départ, c’est un rappel simple. La JVM contient plusieurs zones mémoire, avec chacune sa limite. La heap pour les objets, le Metaspace pour les classes, la mémoire directe pour les buffers NIO, une stack par thread. Chaque message d’OutOfMemoryError désigne l’une de ces zones. Une fois qu’on sait laquelle, le diagnostic est déjà à moitié fait. Si ce qui vit hors de la heap n’est pas clair pour vous, La mémoire hors-heap : où part la RAM de la JVM le détaille.

1. Java heap space

Le plus connu. La JVM a voulu allouer un objet dans la heap, elle a lancé une collecte complète pour faire de la place, et il n’y avait toujours pas assez de place :

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at HeapSpace.main(HeapSpace.java:8)

Le programme derrière tient en quelques lignes, lancé avec -Xmx64m :

import java.util.ArrayList;
import java.util.List;

public class HeapSpace {
    public static void main(String[] args) {
        List<byte[]> retained = new ArrayList<>();
        while (true) {
            retained.add(new byte[1024 * 1024]);
        }
    }
}

Ce qui rend l’erreur lisible, ce n’est pas la stack trace, c’est le log GC juste avant. Avec -Xlog:gc, on voit la JVM se battre :

[0.038s][info][gc] GC(7) Pause Young (Normal) (G1 Humongous Allocation) 63M->63M(64M) 0.461ms
[0.040s][info][gc] GC(8) Pause Full (G1 Compaction Pause) 63M->63M(64M) 1.693ms
[0.042s][info][gc] GC(9) Pause Full (G1 Compaction Pause) 63M->63M(64M) 1.975ms
java.lang.OutOfMemoryError: Java heap space

63M->63M sur un Full GC, c’est la signature. La collecte a tourné et n’a rien libéré : tout ce qui est dans la heap est encore référencé. À partir de là, deux cas seulement. Soit la heap est trop petite pour la charge réelle, et on le voit parce que l’erreur arrive au pic de trafic puis disparaît. Soit un objet retient tout le reste, la heap monte semaine après semaine, et c’est une fuite.

Le réflexe à avoir avant même de chercher, c’est de poser deux flags en production, une fois pour toutes :

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps

La JVM écrit alors un heap dump au moment exact de l’erreur, quand l’objet coupable est encore là :

java.lang.OutOfMemoryError: Java heap space
Dumping heap to /tmp/heap.hprof ...
Heap dump file created [36297923 bytes in 0.051 secs]

L’analyse du dump est un sujet en soi : Diagnostiquer une fuite mémoire avec un heap dump. Et pour distinguer un pic d’une fuite en lisant les logs sans outil, Lire les logs GC sans outil externe.

2. GC overhead limit exceeded

Celui-ci surprend, parce que la heap n’est pas tout à fait pleine. La JVM a décidé d’abandonner avant : elle passe plus de 98 % de son temps en collecte et récupère moins de 2 % de la heap à chaque fois. Continuer n’aurait aucun sens, alors elle lève l’erreur :

[0.589s][info][gc] GC(111) Pause Full (Allocation Failure) 59M->59M(62M) 7.321ms
[0.597s][info][gc] GC(112) Pause Full (Allocation Failure) 59M->59M(62M) 7.980ms
[0.608s][info][gc] GC(113) Pause Full (Allocation Failure) 59M->59M(62M) 10.785ms
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
	at GcOverhead.main(GcOverhead.java:16)

Le programme, lancé avec -XX:+UseParallelGC -Xmx64m et 4096 en argument :

import java.util.HashMap;
import java.util.Map;

public class GcOverhead {
    public static void main(String[] args) {
        // Taille du déchet lue sur la ligne de commande : avec une constante,
        // le JIT supprime l'allocation morte et on finit en Java heap space.
        int garbage = Integer.parseInt(args[0]);
        Map<Integer, byte[]> cache = new HashMap<>();
        int i = 0;
        while (true) {
            // Un petit objet gardé pour toujours, à côté d'un plus gros jeté
            // aussitôt : la heap se remplit lentement et chaque collecte ne
            // libère que le déchet du dernier tour.
            cache.put(i++, new byte[256]);
            byte[] tmp = new byte[garbage];
            tmp[0] = 1;
        }
    }
}

Il garde un petit objet par tour dans une HashMap, et alloue à côté un tableau de 4 Ko jeté aussitôt. La heap se remplit lentement, et chaque Full GC ne récupère que le déchet du dernier tour. Ici, plus de cent Full GC de 7 à 11 ms, enchaînés à quelques millisecondes d’écart, sans jamais faire baisser la heap dans le log. La JVM finit par dire stop.

Un détail que peu de gens connaissent : ce message n’existe qu’avec le Parallel GC. Le même programme, avec les mêmes 64 Mo, sous G1 comme sous Serial donne un Java heap space classique. J’ai vérifié les trois côte à côte. G1 étant le collecteur par défaut depuis Java 9, on voit donc de moins en moins ce message, sauf sur les applis batch qui ont gardé -XX:+UseParallelGC.

Sur le fond, c’est la même chose que le cas 1, en plus lent. Même diagnostic, mêmes outils, même heap dump. Le flag -XX:-UseGCOverheadLimit fait taire le message, mais ne gagne rien. Avec le flag, le même programme a enchaîné 173 Full GC au lieu de 113 avant de finir en Java heap space. Ne l’utilisez pas : ça retarde la même erreur, avec une appli figée entre-temps.

3. Metaspace

Ici les objets vont bien. Ce sont les classes qui ne rentrent plus. Le Metaspace stocke les métadonnées de chaque classe chargée, et il grandit quand des classes se chargent sans jamais se décharger :

[0.118s][info][gc] GC(8) Pause Full (Metadata GC Clear Soft References) 6M->6M(20M) 5.106ms
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
	at java.base/java.lang.ClassLoader.defineClass1(Native Method)
	at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:962)
	at Meta$Loader.define(Meta.java:9)
	at Meta.main(Meta.java:22)

La ligne de log dit tout : la JVM a tenté une collecte spéciale, Metadata GC Clear Soft References, pour décharger des classes, et elle n’a rien pu décharger. Le programme, lancé avec -XX:MaxMetaspaceSize=32m :

import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;

public class Meta {
    static class Loader extends ClassLoader {
        private final byte[] bytes;
        Loader(byte[] bytes) { super(null); this.bytes = bytes; }
        Class<?> define() { return defineClass("Meta$Payload", bytes, 0, bytes.length); }
    }
    static class Payload { int a; int b; int c; }

    public static void main(String[] args) throws Exception {
        byte[] bytes;
        try (InputStream in = Meta.class.getResourceAsStream("Meta$Payload.class")) {
            bytes = in.readAllBytes();
        }
        List<Class<?>> classes = new ArrayList<>();
        while (true) {
            // Un loader neuf à chaque tour : la classe est redéfinie encore et
            // encore, et la liste garde tous les loaders vivants.
            classes.add(new Loader(bytes).define());
        }
    }
}

Il crée un ClassLoader neuf à chaque tour, redéfinit la même classe avec, et garde tous les loaders dans une liste. Tant qu’un loader est référencé, ses classes le sont aussi.

En pratique, la cause est presque toujours l’une de ces trois :

Un détail change tout en conteneur. Le Metaspace n’a pas de limite par défaut. Sans -XX:MaxMetaspaceSize, il ne lève jamais cette erreur : il pousse le RSS jusqu’à la limite du conteneur, et le pod meurt en OOMKilled sans un mot. Poser une limite raisonnable ne corrige rien, mais transforme une mort silencieuse en erreur avec une stack trace :

-XX:MaxMetaspaceSize=256m

Pour le diagnostic, un heap dump marche aussi : les ClassLoader sont des objets, et leur nombre dans le dump trahit la fuite. jcmd <pid> VM.classloader_stats donne la même information en direct, sans dump.

Il existe un cousin : OutOfMemoryError: Compressed class space. C’est la zone, réservée à côté du Metaspace, qui stocke la partie des métadonnées adressée par des pointeurs compressés. Sa taille est fixée au démarrage, 1 Go par défaut, réglable avec -XX:CompressedClassSpaceSize. Même cause, même remède que le Metaspace.

4. Cannot reserve N bytes of direct buffer memory

Les buffers directs de NIO vivent hors de la heap, dans une zone avec sa propre limite. Quand elle est pleine, le message a l’avantage de donner les chiffres :

Exception in thread "main" java.lang.OutOfMemoryError: Cannot reserve 1048576 bytes of direct buffer memory (allocated: 67108864, limit: 67108864)
	at java.base/java.nio.Bits.reserveMemory(Bits.java:178)
	at java.base/java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:108)
	at java.base/java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:367)
	at Direct.main(Direct.java:9)

Le programme, lancé avec -XX:MaxDirectMemorySize=64m :

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;

public class Direct {
    public static void main(String[] args) {
        List<ByteBuffer> buffers = new ArrayList<>();
        while (true) {
            buffers.add(ByteBuffer.allocateDirect(1024 * 1024));
        }
    }
}

Sur Java 11, le même message dit juste Direct buffer memory, sans les chiffres. Depuis Java 17, on lit directement combien est alloué et quelle est la limite. Cette limite se règle avec -XX:MaxDirectMemorySize, et par défaut elle vaut la taille max de la heap. Sur une appli avec -Xmx4g, il y a donc potentiellement 4 Go de buffers directs à côté des 4 Go de heap. En conteneur, ça mérite d’être posé explicitement.

Le piège de ce message, c’est qu’il arrive souvent alors que la mémoire directe n’est pas vraiment utilisée. Un DirectByteBuffer rend sa mémoire native quand l’objet Java est ramassé par le GC. Avec une grosse heap et des collectes rares, des buffers morts s’entassent en attendant une collecte qui ne vient pas. La JVM tente bien un System.gc() avant de lever l’erreur, mais si vous avez posé -XX:+DisableExplicitGC, cet appel ne fait rien, et l’erreur tombe avec une heap à moitié vide. Je l’ai vérifié : un programme qui alloue 2 000 buffers d’1 Mo sans les garder, avec une limite à 64 Mo, passe sans le flag et échoue avec.

Deux pistes pour le diagnostic. Le BufferPoolMXBean nommé direct, exposé par JMX et par la plupart des agents de métriques, donne la courbe d’utilisation. Et si Netty est dans l’application, son propre allocateur a ses métriques et ses propres fuites, en général un ByteBuf jamais relâché. Le détail des zones hors-heap est dans La mémoire hors-heap.

Dernier point, et il est important pour la suite : cette erreur est levée par du code Java, dans Bits.reserveMemory, pas par la machine virtuelle elle-même. On verra plus bas pourquoi ça change le comportement de -XX:+ExitOnOutOfMemoryError.

5. Unable to create native thread

La JVM a demandé un thread au système, et le système a refusé. Le message complet est prudent, parce que la JVM ne sait pas pourquoi :

[0.059s][warning][os,thread] Failed to start thread "Unknown thread" - pthread_create failed (EAGAIN) for attributes: stacksize: 512k, guardsize: 0k, detached.
[0.060s][warning][os,thread] Failed to start the native thread for java.lang.Thread "Thread-279"
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
	at java.base/java.lang.Thread.start0(Native Method)
	at java.base/java.lang.Thread.start(Thread.java:1417)
	at Threads.main(Threads.java:8)

Ce n’est pas un manque de mémoire, malgré le nom de la classe. Le programme crée des threads qui dorment :

public class Threads {
    public static void main(String[] args) {
        int count = 0;
        while (true) {
            Thread t = new Thread(() -> {
                try { Thread.sleep(Long.MAX_VALUE); } catch (InterruptedException e) { }
            });
            t.setDaemon(true); t.start();
            count++;
            if (count % 100 == 0) System.out.println(count + " threads");
        }
    }
}

Il tourne avec -Xss512k, dans un conteneur lancé avec --pids-limit=300. Le thread refusé s’appelle Thread-279, c’est le 280e de l’application. Avec les threads internes de la JVM, on touche la limite de 300, et pthread_create renvoie EAGAIN. La ligne de warning juste au-dessus est plus utile que l’exception : elle donne le code d’erreur et la taille de stack demandée.

Les causes, par ordre de fréquence :

Dans tous les cas, la question est la même : pourquoi autant de threads ? Un thread dump donne la réponse, avec le nom et la stack de chacun. Un pool créé par requête et jamais fermé se repère en dix secondes : Lire un thread dump avec jstack. Et si l’application a vraiment besoin de milliers de threads qui attendent, les virtual threads n’ont pas chacun leur thread natif : Les virtual threads en production.

6. Requested array size exceeds VM limit

Le seul des six qui ne dépend pas de la mémoire disponible :

Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit
	at ArraySize.main(ArraySize.java:3)

Le programme :

public class ArraySize {
    public static void main(String[] args) {
        int[] big = new int[Integer.MAX_VALUE];
        System.out.println(big.length);
    }
}

La JVM refuse avant même de regarder la heap : je l’ai lancé avec -Xmx64m et avec -Xmx8g, même erreur. Et la limite est très étroite. Seules les deux dernières longueurs, Integer.MAX_VALUE et Integer.MAX_VALUE - 1, sont refusées, pour un int[] comme pour un byte[]. Un tableau de Integer.MAX_VALUE - 2 passe la vérification, et c’est ensuite la heap qui décide, avec un Java heap space si elle est trop petite.

En production, on y arrive avec Integer.MAX_VALUE utilisé comme synonyme d’« illimité » : new StringBuilder(Integer.MAX_VALUE), ByteBuffer.allocate(Integer.MAX_VALUE), ou une taille calculée qui tombe pile dessus. Les deux premiers produisent exactement ce message. Sur Java 8, ArrayList demandait aussi un tableau de Integer.MAX_VALUE éléments en dernier recours quand il grossissait, et tombait dessus.

Les collections des JDK récents s’arrêtent avant. ArrayList, StringBuilder ou ByteArrayOutputStream plafonnent leur croissance à Integer.MAX_VALUE - 8, et quand même ça ne suffit pas, l’erreur vient du code Java, avec un texte différent :

Exception in thread "main" java.lang.OutOfMemoryError: Required array length 2147483631 + 100 is too large
	at java.base/jdk.internal.util.ArraysSupport.hugeLength(ArraysSupport.java:914)
	at java.base/jdk.internal.util.ArraysSupport.newLength(ArraysSupport.java:907)
	at java.base/java.lang.AbstractStringBuilder.newCapacity(AbstractStringBuilder.java:344)

Le code qui produit ça, avec -Xmx3g :

StringBuilder sb = new StringBuilder(Integer.MAX_VALUE - 16);
sb.setLength(Integer.MAX_VALUE - 16);
sb.append("x".repeat(100));

Un StringBuilder rempli à 2 147 483 631 caractères en reçoit 100 de plus. Le cas réel, c’est une réponse HTTP accumulée sans fin, un fichier de plusieurs gigaoctets lu en entier dans un ByteArrayOutputStream, une requête SQL sans LIMIT. En pratique, avec une heap de taille normale, ces collections lèvent un Java heap space bien avant d’atteindre 2 milliards d’éléments. Les trois messages désignent donc le même bug. Si la stack trace montre Arrays.copyOf, grow ou newLength, cherchez la collection qui grossit sans borne, pas la fuite.

Ce qui ressemble à un OutOfMemoryError et n’en est pas un

Deux façons de mourir par manque de mémoire ne passent jamais par une exception Java. Elles sont plus fréquentes que la moitié des cas ci-dessus, et plus difficiles à lire parce qu’il n’y a aucune stack trace.

Le conteneur est tué. Le processus dépasse la limite mémoire du cgroup, le kernel le tue avec un SIGKILL. La JVM n’a pas le temps de dire quoi que ce soit. Tout ce qui reste, c’est le code de sortie :

$ docker inspect app --format 'ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}'
ExitCode=137 OOMKilled=true

Ici la JVM tournait avec -Xmx512m dans un conteneur limité à 128 Mo. Sous Kubernetes, kubectl describe pod montre Reason: OOMKilled sur le dernier état du conteneur. La cause est presque toujours la même : la heap plus le reste de la JVM dépasse la limite, parce que quelqu’un a dimensionné la heap sans compter le reste. Régler la JVM en conteneur explique comment laisser la marge.

La JVM s’arrête avec un fichier hs_err. Quand c’est la JVM elle-même qui n’obtient pas de mémoire du système, pour agrandir la heap par exemple, elle ne peut pas lever une exception. Elle écrit un rapport et s’arrête :

OpenJDK 64-Bit Server VM warning: INFO: os::commit_memory(0x0000000740000000, 3221225472, 0) failed; error='Not enough space' (errno=12)
#
# There is insufficient memory for the Java Runtime Environment to continue.
# Native memory allocation (mmap) failed to map 3221225472 bytes. Error detail: committing reserved memory.
# An error report file with more information is saved as:
# /tmp/hs_err_pid269.log

Ce cas a été reproduit sur une VM avec vm.overcommit_memory=2, où le kernel refuse d’engager plus de mémoire qu’il n’en a. Le fichier hs_err_pid<pid>.log liste les causes possibles, dont les threads et leurs stacks. Il contient aussi l’état de la mémoire, la liste des threads et les flags de la JVM. Lisez-le, il est écrit pour ça.

Les flags qui réagissent, et ceux qui ne réagissent pas

Trois flags existent pour ce moment-là. -XX:+HeapDumpOnOutOfMemoryError écrit un dump. -XX:+ExitOnOutOfMemoryError arrête la JVM, pour que l’orchestrateur redémarre un processus sain au lieu de laisser vivre un zombie. -XX:+CrashOnOutOfMemoryError fait pareil en produisant un hs_err, et un core dump si le système l’autorise.

Ce que la documentation ne dit pas clairement, c’est que ces flags ne réagissent pas à tous les messages. Ils sont branchés dans la machine virtuelle, sur le chemin qui lève l’erreur pour la heap et le Metaspace. Une erreur levée par du code Java passe à côté. J’ai testé chaque cas avec -XX:+ExitOnOutOfMemoryError et un catch (OutOfMemoryError) :

MessageLa JVM s’arrête ?
Java heap spaceoui, Terminating due to java.lang.OutOfMemoryError: Java heap space
GC overhead limit exceededoui
Metaspaceoui
Requested array size exceeds VM limitoui
Required array length N + M is too largenon, l’appli continue
Cannot reserve N bytes of direct buffer memorynon, l’appli continue
Unable to create native threadnon, l’appli continue

Pour les erreurs levées par du code Java, la sortie du test était sans ambiguïté :

caught: java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
still running after the error

C’est le pire des scénarios en production. Un pool de threads attrape l’erreur, la logue, et continue avec un thread de moins. L’appli reste vivante, répond de plus en plus mal, et le healthcheck reste vert. Pour ces messages, il faut une alerte sur le log ou une métrique, pas un flag JVM.

Même sans les flags, rappelez-vous qu’un OutOfMemoryError attrapé n’a rien nettoyé. Le programme de test l’attrape et affiche still running after the error dans les six cas. Ce qui a survécu, c’est une JVM dont on ne sait plus quelles allocations ont échoué à mi-chemin. Le bon réflexe reste de sortir et de laisser redémarrer.

En résumé

Lisez le texte après OutOfMemoryError:, il nomme la zone. Java heap space et GC overhead limit exceeded parlent de la heap : heap dump, et distinguer le pic de trafic de la fuite. Metaspace parle des classes : cherchez les ClassLoader et posez une limite pour que l’erreur devienne lisible. Direct buffer memory parle des buffers NIO, souvent morts en attente d’un GC. Unable to create native thread parle d’une limite de processus, pas de mémoire : comptez les threads. Requested array size parle d’une collection qui a grossi sans borne. Et si le processus meurt sans exception, regardez le code de sortie 137 ou le fichier hs_err. Enfin, posez -XX:+HeapDumpOnOutOfMemoryError et -XX:+ExitOnOutOfMemoryError partout, en sachant qu’ils ne couvrent que quatre messages sur six.