DEV Community

Cover image for Votre application Java est en détresse ? N'appelez pas le SAMU, activez simplement un profiler !
Patrice Eon for Onepoint

Posted on

Votre application Java est en détresse ? N'appelez pas le SAMU, activez simplement un profiler !

En 20 ans de développement Java, j'ai souvent joué les détectives pour résoudre des mystères de performance. Et croyez-moi, traquer une fuite mémoire demande plus de patience que dans les séries policières !
Et même si ce n'est pas aussi glamour que dans les séries policières, nous avons heureusement des outils puissants à notre disposition - notamment le profiling.

Dans cet article, je vous propose une plongée dans les coulisses de la JVM et un guide pratique du profiling. Pas besoin d'outils de monitoring sophistiqués, juste votre IDE et quelques connaissances bien ciblées !

Table des matières

  1. Que trouve-t-on sous le capot d'une JVM Java ?
  2. Paramètrage du ramasse-miettes Java le fameux "Garbage Collector"
  3. Paramétrage de la taille mémoire allouée à votre application Java
  4. Paramétrage des logs pour observer la mémoire allouée à votre application
  5. Détecter l'origine d'une fuite mémoire avec Intelij
  6. Conclusion

Que trouve-t-on sous le capot d'une JVM Java ?

Les applications Java brillent par deux qualités majeures :

  • La JVM (Java Virtual Machine) sert d'interprète universel et permet au code Java de s'exécuter partout – Windows, Linux, Unix.
    Le processus est simple : votre code source (.java) est compilé en bytecode (.class), que la JVM traduit ensuite en instructions machine spécifiques à chaque système.

  • En parallèle, la JVM apporte une gestion automatique de la mémoire. Et l'outil principal, le Garbage Collector, libère automatiquement la mémoire en supprimant les objets inutilisés

Cette combinaison gagnante a fait de Java un langage de programmation très populaire depuis les années 2000, offrant un avantage significatif par rapport à des langages comme C++ qui nécessitent une gestion manuelle de la mémoire.

Concrètement voici le schéma d'architecture d'une JVM :

Schéma d'architecture d'une JVM

Pour mémoire voici le rôle de chaque brique d'une JVM, au cours du reste de l'article nous nous concentrerons sur le fonctionnement du "Garbage Collector" qui se trouve être au coeur de la gestion des fuites mémoires possibles.

Class Loader Subsystem : Sous-système de chargement des classes

Le sous-système de chargement des classes est principalement responsable de trois activités :

  • Loading (Chargement) : Le chargeur de classes lit les fichiers .class, génère les données binaires correspondantes à l'OS et les enregistre dans la zone méthode (method area).

NOTE

Pour chaque fichier .class, la JVM stocke les informations suivantes :

  • Le nom complètement qualifié de la classe chargée et sa classe parente immédiate.
  • Si le fichier .class concerne une classe, une interface ou une énumération.
  • Les modificateurs, variables et informations sur les méthodes.

NOTE

Après avoir chargé le fichier .class, la JVM crée un objet de type Class pour représenter ce fichier en mémoire heap. Cet objet, défini dans le package java.lang, permet au programmeur d’obtenir des informations sur la classe (nom, méthodes, variables, etc.) via la méthode getClass() de la classe Object.

  • Linking (Lien), cette étape comprend trois sous-étapes :

    • S'assure que le fichier .class est correctement formaté et généré par un compilateur valide. En cas d'échec, une exception java.lang.VerifyError est levée.
    • Alloue de la mémoire pour les variables statiques de classe et initialise cette mémoire à des valeurs par défaut.
    • Remplace les références symboliques par des références directes en recherchant dans la zone méthode.
  • Initialization (Initialisation), durant cette phase :

    • Les variables statiques reçoivent leurs valeurs définies dans le code.
    • Les blocs statiques (le cas échéant) sont exécutés de haut en bas et selon la hiérarchie des classes (du parent à l'enfant).

Chargeurs de classes (Class Loaders)

Il existe trois types principaux de chargeurs de classes :

  • Chargeur de classes bootstrap : Charge les classes de base de l’API Java depuis le répertoire JAVA_HOME/lib. Implémenté en code natif (C, C++).

  • Chargeur de classes d'extension : Charge les classes présentes dans le répertoire JAVA_HOME/jre/lib/ext ou tout autre répertoire spécifié par la propriété système java.ext.dirs. Implémenté en Java.

  • Chargeur de classes système/application : Charge les classes à partir du classpath de l'application, défini par la variable java.class.path. Implémenté en Java.

Zones mémoire de la JVM

  • Method area (Zone méthode) : Stocke les informations des classes (nom, méthodes, variables statiques, etc.). Partagée entre tous les threads.

  • Heap area (Zone heap) : Contient les informations des objets. Partagée entre tous les threads.

  • Stack area (Zone pile) : Chaque thread a sa propre pile d'exécution, stockant les variables locales et les appels de méthodes.

  • PC Registers (Registres PC) : Contiennent l’adresse de l’instruction en cours d’exécution pour chaque thread.

  • Native method stacks (Piles de méthodes natives) : Contiennent les informations des méthodes natives pour chaque thread.

Moteur d’exécution (Execution Engine)

Le moteur d’exécution exécute le bytecode .class. Il comprend :

  • Interpréteur : Interprète et exécute le bytecode ligne par ligne. Moins performant pour des appels fréquents de méthodes.

  • Compilateur Just-In-Time (JIT) : Améliore l'efficacité en compilant le bytecode en code natif pour éviter des réinterprétations répétées.

  • Collecteur de déchets (Garbage Collector) : Détruit les objets non référencés.

Interface Java Native (JNI)

Permet à la JVM d’interagir avec des bibliothèques natives (écrites en C/C++) pour exécuter des méthodes natives.

Bibliothèques de méthodes natives

Collections de bibliothèques natives nécessaires pour exécuter des méthodes spécifiques au matériel.

Paramètrage du ramasse-miettes Java le fameux "Garbage Collector"

Dans ce chapitre, nous allons aborder sérieusement les concepts et notions liés au Garbage Collector de Java, ou GC pour les intimes. 😊

Le GC (Garbage Collector) est notre agent d'entretien virtuel : il libère la mémoire en supprimant les objets devenus inutiles. Fini les allocations et libérations manuelles à la C++ – en Java, le GC fait le ménage automatiquement.

⚠️ Attention
Cette automatisation a un coût : quand des fuites mémoire surviennent, le diagnostic est plus complexe car le GC travaille en coulisse.

Nous allons voir comment ce mécanisme fonctionne et comment le configurer pour prévenir les fuites mémoire.

Principes de fonctionnement du "Garbage Collector"

Les concepts de base du GC sont relativement simples, quatre principes sont à connaitre :

  • En Java, les objets sont alloués dans le tas (heap) lorsque le mot clé new est appelé.

  • La JVM surveille les objets en mémoire pour déterminer lesquels ne sont plus accessibles à partir de n'importe quel thread actif ou variable accessible.

Cette étape la plus importante est appelé le Balayage (Sweep) dans la litérature Java.

Les objets sont stockés sous la forme de grappes nommé "racine GC".

Les racines GC sont liées entre elles et contiennent :

- Les variables locales.
- Les références dans les piles des threads.
- Les références statiques des classes chargées.
- Les registres du processeur (si applicable).
Enter fullscreen mode Exit fullscreen mode
  • Les objets inaccessibles (autrement dit isolé) sont considérés comme inutilisables et peuvent être collectés pour libérer de la mémoire.

  • Lorsque la mémoire devient fragmentée à cause des suppressions, le compactage peut être effectué pour réorganiser les objets restants et maximiser l’espace libre contigu.

Attention

  • Le GC est exécuté dans un processus autre que votre application ➡️ N'étant pas instantané, il peut entraîner des pauses de votre application.
  • Les JVM modernes offrent plusieurs implémentations du GC ➡️ Il est important de les connaitres pour choisir le plus adapté.

Choisir le bon algorithme GC

Pour activer un collecteur spécifique, vous pouvez fournir l’option -XX:+Use a la JVM lors du démarrage de votre application.

Voici un exemple de ligne de commande :

java -XX:+Use<GarbageCollectorName> -jar myapp.jar
Enter fullscreen mode Exit fullscreen mode

Le choix des garbages Collector dépend de la version de java que vous utilisez. Voici les algorithmes disponibles en java 21 avec leur avantages/inconvénients.

Nom Description Caractéristiques Utilisation typique
Serial GC Un ramasse-miettes simple et efficace, adapté aux petites applications à thread unique • Collecte séquentielle (un seul thread)
• Pause unique et longue pour l'application
Idéal pour les applications simples, avec de faibles besoins en mémoire et un seul thread
Parallel GC Conçu pour maximiser le débit global de l'application • Collecte parallèle des générations jeune et ancienne
• Optimise le temps total de collecte vs temps d'exécution
Applications nécessitant un débit élevé et tolérant des pauses plus longues
G1 GC Un collecteur équilibré entre faible latence et bon débit • Divise le tas en régions de taille fixe
• Collecte prioritaire des régions les plus chargées
• Pauses définies par l'utilisateur
Applications interactives nécessitant des temps de pause prévisibles
ZGC Un collecteur à très faible latence • Pauses <10 ms même sur grands tas
• Collecte principalement en arrière-plan
Applications nécessitant une latence minimale (temps réel, systèmes critiques)
Shenandoah GC Collecteur orienté faible latence, similaire à ZGC • Pauses courtes et prévisibles (<10 ms)
• Balayage et compactage concurrent
Applications nécessitant des temps de réponse courts sur tas moyens à grands
Epsilon GC Un collecteur qui ne collecte pas • Aucune gestion mémoire post-allocation
• Usage principalement pour tests
Applications sans besoin de collecte ou pour benchmarking

Comparaison rapide des collecteurs

Collecteur Latence Débit Taille du tas idéale Caractéristique principale
Serial Élevée Modéré Petite Simple et efficace pour applications basiques.
Parallel Moyenne Élevé Petite à grande Optimisé pour le débit.
G1 Basse Équilibré Petite à grande Prévisibilité des temps de pause.
ZGC Très basse Élevé Très grande (To) Pauses ultra-courtes.
Shenandoah Très basse Équilibré Modérée à grande Latence minimale avec compactage.
Epsilon Aucune Aucun impact Variable Pas de gestion de mémoire.

Paramétrage de la taille mémoire alloué à votre application Java

Configuration du tas Java et gestion de la mémoire

Importance de la configuration

La configuration du tas Java est cruciale pour les performances applicatives. Elle implique l'ajustement des tailles minimale et maximale de mémoire JVM pour une gestion efficace des objets.

Impact sur les performances

Une configuration optimale permet d'éviter :

  • Des collectes mémoire trop fréquentes impactant les performances
  • Des erreurs OutOfMemoryError causant des interruptions de service

Types d'erreurs OutOfMemoryError

Type d'erreur Description Message d'erreur
Heap space Mémoire tas insuffisante pour les nouveaux objets java.lang.OutOfMemoryError: Java heap space
GC Overhead limit JVM consacre >98% du temps au Garbage Collector java.lang.OutOfMemoryError: GC overhead limit exceeded
Metaspace Espace saturé pour les métadonnées de classes (Java 8+) java.lang.OutOfMemoryError: Metaspace

Liste des options du tas disponibles

Afin d'éviter les OutOfMemoryError les options du tas disponibles sont les suivantes :

  • -Xms : Définit la taille initiale du tas.
  • -Xmx : Définit la taille maximale du tas.
  • -XX:NewSize et -XX:MaxNewSize : Contrôlent la taille du jeune espace (Young Generation) dans le tas.
  • -XX:PermSize et -XX:MaxPermSize (pour Java 7 et versions antérieures) : Définissent la taille de l'espace permanent.
  • -XX:MetaspaceSize et -XX:MaxMetaspaceSize (pour Java 8 et versions ultérieures) : Contrôlent la taille du Metaspace.

Liste des options du tas disponibles


Transmettre les options nécessaire a votre JVM

Pour configurer le tas Java, ajoutez a nouveau les options directement dans la ligne de commande utilisée pour lancer votre application Java.

java -Xms256m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m -jar MonApplication.jar
Enter fullscreen mode Exit fullscreen mode

Paramétrage des logs pour observer la mémoire allouée à votre application

Les problèmes de mémoire peuvent mettre un certain temps à se manifester : plusieurs heures, jours, voire même semaines après le lancement de votre application.

Il est donc essentiel d’activer et d’analyser régulièrement les journaux de collecte des déchets (logs GC) pour repérer des schémas récurrents, diagnostiquer des anomalies et ajuster les paramètres de collecte en conséquence.

Les versions récentes de Java utilisent le système Unified Logging, qui permet de configurer les logs liés à la mémoire et au garbage collector via l'option -Xlog.

Liste des options JVM pour obtenir les Logs Mémoire et Garbage Collector

Liste des options JVM pour obtenir les Logs Mémoire et Garbage Collector :

  • gc* : Log des événements du garbage collector.
  • heap*=debug : Log des allocations et libérations de mémoire.
  • debug : Niveau de détail des logs.
java -Xlog:gc*,heap*=debug -jar votre-application.jar
Enter fullscreen mode Exit fullscreen mode

Liste des options JVM pour écrire les Logs dans un Fichier :

  • file=gc_logs.txt : Redirige les logs vers le fichier spécifié.
  • time,uptime,level : Ajoute des informations comme le temps et le niveau de log.
java -Xlog:gc*,heap*=debug:file=gc_logs.txt:time,uptime,level -jar votre-application.jar
Enter fullscreen mode Exit fullscreen mode

Si vous avez une application Spring Boot, vous pouvez simplement utiliser Actuator pour récupérer les heap dumps. Il faut rajouter ces deux dépendances à votre projet :

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Détecter l'origine d'une fuite mémoire avec Intelij

Fuites mémoire et Profiler IntelliJ

Définition :

Une fuite mémoire est une consommation progressive et incontrôlée de mémoire causée par des objets devenus inutiles mais qui ne sont pas libérés correctement par le système.

Solution

IntelliJ fournit un profiler intégré qui permet de :

  • Détecter l'origine des fuites
  • Analyser la consommation mémoire
  • Valider les corrections apportées

Le profiler offre une visualisation en temps réel de l'allocation mémoire, facilitant l'identification des objets problématiques.
Il est sous-utilisé à mon avis surtout par manque de connaissance et de formation.

Développement d'une application générant une fuite mémoire

Pour illustrer l'utilisation du profiler IntelliJ, je vous propose l'application Spring boot minimaliste suivante.

package com.onepoint.profiler;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import javax.crypto.NoSuchPaddingException;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;

@SpringBootApplication
@EnableScheduling
public class ProfilerApplication {

    public static void main(String[] args) {

        SpringApplication.run(ProfilerApplication.class, args);

    }

}

@Service
class MemoryLeakService {

    // Liste statique pour maintenir des références inutilisées
    private static final List<byte[]> MEMORY_LEAK_LIST = new ArrayList<>();

    // Tâche planifiée pour simuler une fuite mémoire en continu
    @Scheduled(fixedRate = 2000) // Exécution toutes les 5 secondes
    public void simulateMemoryLeak() throws NoSuchPaddingException, NoSuchAlgorithmException {
        System.out.println("Ajout de blocs mémoire à la liste...");

        // FUITE MEMOIRE : Ajouter un bloc de 10 MB à chaque exécution
        byte[] memoryBlock = new byte[10 * 1024 * 1024];
        MEMORY_LEAK_LIST.add(memoryBlock);

        // Imprimer la taille actuelle de la liste
        System.out.println("Taille actuelle de MEMORY_LEAK_LIST : " + MEMORY_LEAK_LIST.size());
    }
}
Enter fullscreen mode Exit fullscreen mode

Utilisation du profiler

Démarrer votre application.

La fenètre Run doit alors apparaitre.

Utilisation du profiler InteliJ

Cliquer sur l'icone profiler qui ressemble a une gauge.

Vous devez voir apparaitre la liste des processus en cours.
Dans mon cas, mon application apparait en deuxième position.

Utilisation du profiler InteliJ

Faire un clic droit sur la méthode. Puis choisir l'option "CPU and memory Live Charts".

Utilisation du profiler InteliJ

Un graphe similaire au suivant doit apparaitre. La courbe "Heap Memory" augmente continuellement : on peut visualiser clairement la fuite mémoire.

Utilisation du profiler InteliJ

Maintenant, nous savons que l'application génère une fuite mémoire sans savoir quelle ligne de code en est la cause.

Pour la rechercher, nous allons lancer le profiler en phase d'analyse :
Revenir sur l'onglet home du profiler.
Puis faire un clic droit sur le processus de l'application.
Puis choisir l'option "Attach Intellij Profiler".

Utilisation du profiler InteliJ

La page suivante doit alors apparaitre. Patienter alors quelques minutes. Puis stopper le profiling.

Utilisation du profiler InteliJ

Un onglet résultat doit apparaitre avec notamment un "Flame graph".

Utilisation du profiler InteliJ

Basculer sur la vue show "Memory Allocations".

Utilisation du profiler InteliJ

Sélectionner l'onglet "Call tree". La méthode posant problème apparait alors clairement car elle occupe 3,49 Go de mémoire.

Utilisation du profiler InteliJ

Aller voir le code de la méthode : une flame rouge est apposée sur la ligne 38 placée volontairement pour générer une fuite mémoire.
! CQFD !

Utilisation du profiler InteliJ

Cet exercice nous a permit de découvrir comment identifier une fuite mémoire dans une application Java.
Il peut également s'appliquer dans d'autres cas, comme en cas de surconsommation de CPU.

Conclusion

La JVM est le moteur central de vos applications Java, gérant automatiquement la mémoire et l'exécution des instructions. Tel un mécanisme de précision, chaque composant joue un rôle essentiel dans son fonctionnement optimal.

Bien que sophistiquée, ce moteur nécessite parfois des ajustements manuels. La configuration de la mémoire et l'optimisation du Garbage Collector s'apparentent à un travail de mécanique fine, où chaque réglage impacte les performances globales.

Les options de configuration et les logs sont vos outils de diagnostic, permettant d'identifier et résoudre les problèmes avant qu'ils n'affectent la production. Le profiler, quant à lui, agit comme la valise de diagnostic avancé, capable de détecter les anomalies les plus subtiles.

Maîtriser la JVM s'apparente ainsi au pilotage d'une voiture de course : au-delà de la vitesse, il faut comprendre ses réglages, anticiper sa maintenance et savoir utiliser les outils d'optimisation pour éviter les pannes. À vous de prendre les commandes !

Rappel pour choisir le bon collecteur

Pense-bête : les Garbage Collectors par cas d'usage

Objectif Garbage Collector recommandé
Faible latence ZGC ou Shenandoah
Débit élevé Parallel GC
Équilibre latence/débit G1 GC
Simplicité Serial GC
Tests et benchmarks Epsilon GC

Cet article fait partie du "Advent of Tech 2024 Onepoint", une série d'articles tech publiés par Onepoint pour patienter jusqu'à Noël.
Voir tous les articles du Advent of Tech 2024.

Merci de votre lecture.

Top comments (0)