Garbage collector en Java : G1, ZGC et comment choisir

1. Aperçu

Le Garbage collector (GC) détermine combien de temps votre application Java s’arrête et combien de CPU elle consomme pour nettoyer la mémoire. Si vous le choisissez mal, vous pouvez voir la latence exploser même si le code n’a pas changé.

Cet article explique comment G1 et ZGC fonctionnent en Java 21, comment les activer et lire les logs GC, comment vérifier le GC en cours via du code et des tests. À la fin, nous verrons les critères pour choisir un GC pour une application réelle.

2. Les GC disponibles en Java 21

HotSpot JDK 21 propose ces principaux GC :

GC Flag Objectif
Serial -XX:+UseSerialGC Heap petit, 1 CPU, empreinte mémoire faible
Parallel -XX:+UseParallelGC Débit maximal, accepte les pauses longues
G1 -XX:+UseG1GC Équilibre entre débit et pause
ZGC -XX:+UseZGC Pauses extrêmement courtes, heap volumineux
Shenandoah -XX:+UseShenandoahGC Pauses courtes (disponible uniquement dans certaines distributions)

Si vous ne passez aucun flag, la JVM choisit d’elle-même. Sur une machine « server-class » (au moins 2 CPU et environ 1792 MB de RAM), la JVM utilise G1. Sinon, elle utilise Serial.

Attention : Dans un container, la JVM lit les limites CPU et mémoire du cgroup. Un Pod limité à 1 CPU sera considéré par la JVM comme non-server-class et silencieusement Serial GC sera utilisé. C’est une cause courante de pauses anormalement longues dans les services Kubernetes.

3. Comment fonctionne G1

G1 (Garbage-First) divise le heap en plusieurs regions de taille égale, généralement entre 1 MB et 32 MB. Chaque region a un rôle spécifique : Eden, Survivor, Old ou Humongous (pour les grands objets).

Le cycle de base comprend :

  • Young GC : arrête tous les threads de l’application (stop-the-world), copie les objets vivants d’Eden vers Survivor ou Old.
  • Concurrent marking : quand Old remplit jusqu’à un seuil, G1 marque les objets vivants en parallèle avec l’application.
  • Mixed GC : nettoie à la fois le young et les regions Old qui contiennent le plus de déchets en premier. C’est le sens de « Garbage-First ».

La force de G1 est que vous pouvez définir un objectif de pause :

java -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xms2g -Xmx2g -jar order-service.jar

MaxGCPauseMillis=100 demande à G1 de maintenir chaque pause sous 100 ms (par défaut 200 ms). G1 ajuste la taille de la young generation pour atteindre cet objectif. C’est un objectif logiciel, pas une garantie. Les pauses de G1 augmentent toujours selon le nombre d’objets vivants à copier.

4. Comment fonctionne ZGC

ZGC effectue presque tout le travail lourd (marking, relocation, mise à jour des références) en parallèle avec l’application. Il utilise des colored pointers et des load barriers : chaque fois que le code lit une référence, le barrier vérifie si l’objet a été déplacé et corrige la référence si nécessaire.

Le résultat est que les pauses sont généralement sous 1 ms et ne croissent pas avec la taille du heap. Un heap de 2 GB ou 2 TB donne des pauses équivalentes.

4.1. Generational ZGC en Java 21

Java 21 introduit Generational ZGC (JEP 439). Cette version sépare les générations young et old, ce qui permet de nettoyer les objets de courte durée beaucoup plus efficacement. En Java 21, vous devez activer un flag supplémentaire :

java -XX:+UseZGC -XX:+ZGenerational -Xmx4g -jar order-service.jar

Si vous omettez -XX:+ZGenerational, Java 21 exécute ZGC à l’ancienne (non-générationnel). À partir de JDK 23, la version générationelle est par défaut. À partir de JDK 24, le mode non-générationnel a été supprimé. Par conséquent, si vous utilisez ZGC sur Java 21, vous devriez activer le mode générationnel immédiatement.

4.2. Le prix à payer

Une pause courte a un coût :

  • Les load barriers ralentissent chaque lecture de référence, donc le débit est généralement inférieur de quelques pour cent à celui de G1.
  • ZGC a besoin de headroom, c’est-à-dire d’espace libre dans le heap. L’application continue d’allouer pendant que le GC s’exécute en parallèle. Si le heap se remplit avant que le GC ne soit terminé, les threads sont bloqués (« Allocation Stall »). La latence explose alors, ce qui va à l’encontre de l’objectif initial.
  • Les threads GC s’exécutent en parallèle, donc ils rivalisent pour le CPU avec l’application.

5. Expérimentation avec un petit programme

Le programme ci-dessous crée beaucoup d’objets Order de courte durée et en garde une partie pour simuler un cache :

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

public class OrderLoadSimulator {

    record Order(String id, String customer, byte[] payload) {}

    public static void main(String[] args) {
        List<Order> cache = new ArrayList<>();
        long start = System.currentTimeMillis();

        for (int i = 0; i < 5_000_000; i++) {
            Order order = new Order(UUID.randomUUID().toString(), "customer-" + (i % 1000), new byte[512]);
            if (i % 100 == 0) {
                cache.add(order);           // garder 1% -> passer en old generation
            }
            if (cache.size() > 20_000) {
                cache.subList(0, 10_000).clear(); // vider partiellement, créer des déchets en old
            }
        }

        System.out.printf("Done in %d ms, cache=%d%n",
                System.currentTimeMillis() - start, cache.size());
    }
}

Chaque itération crée un objet Order avec une charge utile de 512 bytes. La plupart des objets meurent immédiatement, conformément à l’hypothèse selon laquelle « la plupart des objets meurent jeunes ». Environ 1 % sont conservés dans le cache assez longtemps pour être promis en old generation. La commande subList(...).clear() les libère périodiquement.

Exécutons avec G1 et activons le logging GC via la journalisation unifiée (-Xlog) :

java -XX:+UseG1GC -Xmx512m -Xlog:gc OrderLoadSimulator.java

La sortie ressemblera à :

[0.008s][info][gc] Using G1
[0.312s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 25M->3M(512M) 2.814ms
[0.401s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 28M->4M(512M) 1.957ms
...
Done in 3120 ms, cache=15000

Chaque ligne « Pause Young » indique le heap avant et après le GC (25M->3M), le heap total commité (512M) et le temps de pause (2.814ms).

Exécutons maintenant avec Generational ZGC :

java -XX:+UseZGC -XX:+ZGenerational -Xmx512m -Xlog:gc OrderLoadSimulator.java
[0.010s][info][gc] Using The Z Garbage Collector
[0.420s][info][gc] GC(0) Minor Collection (Allocation Rate) 60M(12%)->14M(3%) 0.021s
[0.610s][info][gc] GC(1) Minor Collection (Allocation Rate) 72M(14%)->16M(3%) 0.018s
...
Done in 3350 ms, cache=15000

Le temps à la fin de chaque ligne ZGC (0.021s) est la durée du cycle GC entier, dont la majeure partie s’exécute en parallèle avec l’application. Ce n’est pas le temps d’arrêt de l’application. Pour voir les vraies pauses, utilisez -Xlog:gc,gc+phases=debug et cherchez les lignes Pause Mark Start, Pause Mark End, Pause Relocate Start. Ces pauses ne durent généralement que quelques dizaines de microsecondes.

Les chiffres exacts varieront sur votre machine. La tendance généralement observée est que ZGC prend un peu plus de temps globalement mais sans pauses longues.

6. Vérifier le GC en cours via du code

En environnement de production, vous devez confirmer le GC réel au lieu de faire confiance à la configuration. GarbageCollectorMXBean vous en informe :

import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.List;

public class GcInspector {

    public static List<String> activeCollectors() {
        return ManagementFactory.getGarbageCollectorMXBeans().stream()
                .map(GarbageCollectorMXBean::getName)
                .toList();
    }

    public static void main(String[] args) {
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            System.out.printf("%s: count=%d, time=%d ms%n",
                    gc.getName(), gc.getCollectionCount(), gc.getCollectionTime());
        }
    }
}

Chaque GC enregistre un ou plusieurs MXBeans avec des noms caractéristiques. Par exemple avec G1 :

G1 Young Generation: count=0, time=0 ms
G1 Concurrent GC: count=0, time=0 ms
G1 Old Generation: count=0, time=0 ms

Avec Generational ZGC, les noms seront ZGC Minor Cycles, ZGC Minor Pauses, ZGC Major Cycles et ZGC Major Pauses.

6.1. Écrire un test pour confirmer la configuration du GC

Vous pouvez verrouiller la configuration du GC par des tests, pour que personne ne change accidentellement les options JVM. Commencez par configurer Surefire pour exécuter les tests avec ZGC :

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.2.5</version>
    <configuration>
        <argLine>-XX:+UseZGC -XX:+ZGenerational</argLine>
    </configuration>
</plugin>

Puis écrivez un test JUnit 5 combiné avec AssertJ :

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class GcInspectorTest {

    @Test
    void whenRunningWithZgcFlag_thenZgcCollectorsAreActive() {
        var collectors = GcInspector.activeCollectors();

        assertThat(collectors)
                .isNotEmpty()
                .allMatch(name -> name.startsWith("ZGC"));
    }

    @Test
    void whenGenerationalZgc_thenMinorCyclesBeanExists() {
        assertThat(GcInspector.activeCollectors())
                .contains("ZGC Minor Cycles");
    }
}

Le premier test vérifie que tous les collecteurs sont des ZGC. Le deuxième test s’assure que le mode générationnel est activé. Si quelqu’un supprime -XX:+ZGenerational, le nom du bean devient ZGC Cycles et le test échoue.

Remarque : Ce test dépend des options JVM du processus de test. Si votre IDE ne lit pas argLine à partir de Surefire, le test échouera lors de l’exécution directe dans l’IDE.

7. Configuration du GC pour une application Spring Boot

Spring Boot ne choisit pas automatiquement le GC. Vous passez les flags à la JVM selon votre mode de déploiement. Lors de l’exécution locale via le plugin Maven :

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <jvmArguments>-XX:+UseZGC -XX:+ZGenerational -Xmx2g</jvmArguments>
    </configuration>
</plugin>

Dans un container, le moyen le plus concis est d’utiliser la variable d’environnement JAVA_TOOL_OPTIONS. La JVM lit automatiquement cette variable au démarrage :

docker run -e JAVA_TOOL_OPTIONS="-XX:+UseG1GC -XX:MaxRAMPercentage=75 -Xlog:gc*:stdout:time" \
  --cpus=2 --memory=2g payment-service:1.0

MaxRAMPercentage=75 définit le heap maximal à 75 % de la mémoire du container. Le reste est réservé à la metaspace, à la pile des threads et aux buffers directs. Toujours spécifier clairement -XX:+UseG1GC ou -XX:+UseZGC au lieu de laisser la JVM choisir, pour les raisons mentionnées à la section 2.

Si vous avez Spring Boot Actuator et Micrometer, la métrique jvm.gc.pause vous donne la distribution du temps de pause par type de GC. Suivez le p99 et le max de cette métrique avant et après tout changement de GC.

8. Comment choisir un GC

Commencez par les exigences de votre application, pas par le GC « le plus récent » :

Choisissez G1 quand :

  • Vous n’avez pas de raison spécifique de changer. G1 est un choix par défaut raisonnable pour la plupart des API REST et services.
  • Le heap va de quelques centaines de MB à environ plusieurs dizaines de GB.
  • Les pauses de quelques dizaines à 200 ms sont acceptables.
  • Le CPU est strictement limité et vous avez besoin d’un bon débit.

Choisissez ZGC (generational) quand :

  • Vos exigences de latence p99 ou p999 sont très strictes, par exemple le trading, les serveurs de jeux, les API avec un SLA inférieur à 50 ms.
  • Le heap est volumineux (des dizaines de GB ou plus), quand les pauses de G1 commencent à être longues en raison du nombre d’objets vivants.
  • Vous avez du CPU et de la mémoire supplémentaires pour faire du headroom.

Choisissez Parallel quand : vous exécutez des jobs batch, des ETL, du traitement de données offline. Le temps total est plus important que la pause.

Choisissez Serial quand : vous exécutez une petite CLI, une fonction serverless avec un heap d’environ 100 MB ou moins et 1 CPU.

Remarque : N’utilisez pas ZGC sur un petit container (1 CPU, heap 512 MB). Les threads GC rivaliseront pour le CPU avec l’application, et le peu de headroom du heap risque de causer une Allocation Stall. Le résultat peut être pire que G1.

Quelques erreurs courantes lors du tuning :

  • Copier en masse les anciens flags comme -XX:NewRatio ou -XX:SurvivorRatio dans G1. Ces flags interfèrent avec le mécanisme d’auto-ajustement pour atteindre MaxGCPauseMillis. Vous devriez commencer avec juste -Xmx et l’objectif de pause.
  • Définir MaxGCPauseMillis trop bas, par exemple 10 ms, pour G1. G1 réduit la young generation, le GC s’exécute fréquemment et le débit chute drastiquement. Si vous avez besoin de pauses de cette taille, passez à ZGC.
  • Changer le GC pour corriger une fuite mémoire. Aucun GC ne peut libérer les objets qui sont toujours référencés. Analysez d’abord un heap dump.
  • Évaluer avec un benchmark de quelques secondes. Mesurez plutôt avec une charge similaire à la production pendant au moins plusieurs dizaines de minutes, et comparez la latence p99, le CPU et la mémoire.

9. Conclusion

G1 est le choix par défaut équilibré, approprié pour la plupart des applications. ZGC, particulièrement la version générationelle en Java 21, offre des pauses infra-milliseconde avec un heap très volumineux, en échange d’une consommation CPU supplémentaire et d’un plus grand headroom requis.

Déclarez toujours le GC clairement, activez -Xlog:gc et confirmez le GC en cours via GarbageCollectorMXBean ou des tests. Ne changez le GC que si les données de latence p99 montrent que les pauses sont vraiment un problème.

javajvmgarbage-collectorg1zgc