Analyse et résolution des erreurs OutOfMemoryError dans les applications Java en production

Épuisement des threads et allocation de tampons surdimensionnés dans Tomcat

Dans un système traitant environ 100 requêtes par seconde, des erreurs OutOfMemoryError: Java heap space surviennent de manière récurrente, entraînant l'arrêt complet du service. L'analyse des journaux révèle que les threads de travail de Tomcat (ex: http-nio-8080-exec-*) échouent lors de l'allocation d'objets dans le tas.

Analyse de la consommation mémoire

L'inspection du dump mémoire via MAT (Memory Analyzer Tool) montre que près de 8 Go de mémoire sont occupés par des tableaux byte[] de 10 Mo chacun. Ces tableaux sont alloués par les threads de Tomcat lors du traitement des requêtes HTTP.

En examinant la configuration du serveur, on découvre un paramètre critique :

server:
  tomcat:
    max-http-request-header-size: 10MB

Ce paramètre force Tomcat à allouer des tampons de 10 Mo pour chaque requête. Avec 400 threads de travail configurés, le traitement simultané de requêtes lentes peut rapidement saturer le tas.

Impact des timeouts RPC

Pourquoi 400 threads sont-ils actifs simultanément avec seulement 100 requêtes par seconde ? L'analyse des journaux d'application révèle de nombreux timeouts lors d'appels RPC vers des services en aval. Le timeout RPC était configuré à 4 secondes. Lorsqu'un service en aval tombe en panne, les threads de Tomcat restent bloqués pendant 4 secondes en attendant la réponse. Ainsi, en 4 secondes, 400 requêtes s'accumulent, saturant les 400 threads et allouant 8 Go de tampons, provoquant l'OOM.

Solution : Réduire le timeout RPC à 1 seconde et diminuer la taille maximale des en-têtes HTTP à une valeur raisonnable (ex: 8 KB) pour limiter l'empreinte mémoire par requête.

Fuite de mémoire hors-tas (Off-Heap) avec Jetty et NIO

Un service déployé sur Jetty devient inaccessible. Les jouranux indiquent une erreur java.lang.OutOfMemoryError: Direct buffer memory, pointant vers les composants NIO de Jetty.

Mécanisme d'allocation de la mémoire directe

La mémoire directe (Direct Memory) est allouée en dehors du tas JVM, gérée par le système d'exploitation. En Java NIO, l'allocation se fait via DirectByteBuffer. La libération de cette mémoire dépend de la récupération de l'objet DirectByteBuffer par le Garbage Collector (GC).

Si le GC ne récupère pas ces objets, la mémoire hors-tas n'est pas libérée, menant à une fuite.

Problème de promotion prématurée et GC explicite désactivé

L'analyse de la configuration JVM révèle deux problèmes majeurs :

  1. Espace Survivor sous-dimensionné : La jeune génération est trop petite, et l'espace Survivor ne fait que 10 Mo. Lors d'une Young GC, les objets DirectByteBuffer survivants ne peuvent pas tenir dans le Survivor et sont prématurément promus dans la vieille génération.
  2. GC explicite désactivé : Le paramètre -XX:+DisableExplicitGC est activé. Normalement, lorsque la mémoire directe est pleine, le code source de Java NIO appelle System.gc() pour forcer la récupération des DirectByteBuffer orphelins. Ce paramètre empêche cette exécution.

Solution : Agrandir la jeune génération pour éviter la promotion prématurée, et supprimer le paramètre -XX:+DisableExplicitGC pour permettre à NIO de nettoyer la mémoire hors-tas.

Désérialisation RPC et allocation de tableaux massifs

Lors du déploiement d'une nouvelle version du Service A, le Service B (consommateur) crash avec une erreur OutOfMemoryError: Java heap space. L'exception provient du framework RPC interne lors de la lecture des données.

Incompatibilité de schéma et fallback dangereux

Le framework RPC utilise un fichier de définition pour générer les classes de sérialisation. Par exemple :

syntax = "proto3";
message TransactionRequest {
    string tx_id = 1;
    int64 amount = 2;
    string currency = 3;
}

Le Service A a modifié ce schéma (ajout de champs) sans mettre à jour le Service B. Lors de la réception du flux d'octets, le Service B échoue à désérialiser l'objet. Le framework RPC contient un bug : en cas d'échec de désérialisation, il alloue un tableau byte[] par défaut de 4 Go pour stocker le flux brut.

public class TransactionRequest {
    // Logique de sérialisation générée automatiquement
    // En cas d'erreur, le fallback alloue un tableau de 4 Go
}

Solution : Corriger le bug du framework pour limiter le tableau de fallback à 4 Mo, et mettre en place des tests de contrat (Contract Testing) pour synchroniser les schémas entre les microservices.

Requêtes SQL non filtrées et méthodologie d'analyse MAT

Une application crash suite à une erreur d'espace de tas. L'analyse MAT est requise pour identifier la cause racine dans le code métier.

Flux de travail d'investigation avec MAT

  1. Histogramme : Identifier les objets les plus lourds. Ici, des milliers d'instances de HashMap ou d'entités métier occupent la majorité du tas.
  2. Arbre de domination (Dominator Tree) : Déterminer quel thread détient les références vers ces objets. On identifie un thread de travail Tomcat spécifique.
  3. Aperçu des threads (Thread Overview) : Examiner la pile d'appels du thread identifié. Cela permet de remonter exactement à la ligne de code et à la méthode qui a exécuté la requête SQL.

L'investigation révèle une requête MyBatis dépourvue de clause WHERE, renvoyant des millions de lignes en mémoire. Solution : Ajouter les filtres appropriés et implémenter la pagination ou le streaming de résultats.

Traitement de logs, récursivité et paramètres JVM inadaptés

Un système de nettoyage de logs consommant Kafka rencontre des OOM fréquents. Les journaux montrent une récursivité infinie ou profonde dans une méthode de traitement.

Impact de la récursivité et sous-allocation du tas

La méthode de nettoyage utilise la récursivité pour parser les logs contenant plusieurs utilisateurs. Chaque appel récursif génère de nombreux tableaux char[]. Bien que la profondeur de récursivité ne soit que de quelques dizaines, la configuration JVM est inadaptée :

-Xmx1024m -Xms1024m
-XX:+PrintGCDetails -Xloggc:/var/log/app/gc.log

Sur une machine de 8 Go, allouer seulement 1 Go au tas provoque des Full GC continus. Les journaux GC montrent des cycles complets chaque seconde, récupérant à peine quelques mégaoctets, jusqu'à l'OOM.

Solution : Augmenter le tas à 4-5 Go pour la machine, et refactoriser le code pour remplacer la récursivité par une approche itérative, réduisant ainsi la pression sur le GC de plus de 90%.

Surcharge du ClassLoader et gel intermittent du service

Un service web sous Tomcat subit des gels intermittents. Les interfaces ne répondent pas pendant plusieurs secondes, puis redeviennent accessibles. Aucune erreur OOM n'est présente dans les journaux applicatifs.

Analyse des ressources OS et fuites de ClassLoaders

La commande top montre une utilisation CPU très faible (1%) mais une consommation mémoire du processus JVM supérieure à 50% de la RAM totale, de manière constante. Cela indique que le GC ne parvient pas à libérer la mémoire, ou que le processus est tué et redémarré par l'OS (OOM Killerr).

L'extraction d'un dump mémoire révèle la présence de plusieurs milliers d'instances de ClassLoader personnalisés, chacun chargeant de lourds tableaux byte[] (fichiers de configuration ou ressources externes).

Solution : Le code applicatif instancei un nouveau ClassLoader à chaque requête au lieu de les mettre en cache. Il faut implémenter un pool ou un cache de ClassLoaders pour éviter la duplication du chargement des ressources.

Goulot d'étranglement des files d'attente mémoire dans les pipelines de données

Un système ETL consommant Kafka pour écrire en base de données subit des OOM réguliers qui s'aggravent avec le volume de données.

Accumulation dans les files d'attente non bornées

L'analyse MAT montre qu'une structure de file d'attente en mémoire retient des millions d'objets. Le code consommateur récupère les messages Kafka par lots (batchs de 500 messages), les place dans un List, puis insère cette List dans une file d'attente interne pour le traitement asynchrone.

Si le traitement en base de données est plus lent que la consommation Kafka, la file d'attente grossit indéfiniment. De plus, encapsuler les lots dans des List alourdit considérablement l'empreinte mémoire des objets en attente.

Solution : Remplacer la file d'attente non bornée par une BlockingQueue de capacité fixe (ex: 1024 éléments). Au lieu d'empiler des List, insérer les enregistrements individuellement. Lorsque la file est pleine, le consommateur Kafka sera naturellement bloqué (backpressure), empêchant toute saturation de la mémoire.

Étiquettes: JVM OutOfMemoryError MemoryAnalyzerTool DirectMemory NIO

Publié le 22 juin à 17h28