Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 017 / 100

G1 vs ZGC: choosing a garbage collector, explained simply

The default is good. Know when to switch.

Expona Admin7 min read

Introduction

Most Java teams run their Spring Boot applications with the default garbage collector and never think about it again. That works fine—until your application hits production at scale, your heap grows to 16GB, and your users start complaining about occasional 200ms freezes. Understanding G1 vs ZGC: choosing a garbage collector, explained simply means knowing when staying with the default is the right call and when switching unlocks real performance gains.

In this article, we'll walk through the four garbage collectors you're likely to encounter, why G1 became the default in Java 9, when ZGC shines for low-latency systems, and how to measure pause times so you make data-driven decisions rather than guesses.

What is a Garbage Collector, and Why Should You Care?

The garbage collector (GC) runs automatically in the JVM and reclaims memory from objects your application no longer uses. Every time it runs, your application threads pause while the GC does its work. On a 2GB heap that pause might be 5ms. On a 128GB heap with the wrong collector, it could be 2 seconds. In a Spring Boot microservice handling 10,000 requests per second, a 2-second pause means 20,000 requests queued, timeouts triggered, and angry on-call engineers.

The garbage collector's job is to balance three competing goals: keeping pause times short, maintaining good throughput (not wasting CPU on GC), and not consuming too much memory overhead. No collector excels at all three; they make different trade-offs.

G1: The Default, Balanced Choice

The Garbage First (G1) collector became the default in Java 9 for good reason. It works well across a wide range of heap sizes and application patterns, from 2GB microservices to 64GB batch jobs.

How G1 Works

G1 divides the heap into regions (typically 2MB each) and collects them incrementally rather than stopping the world to collect the entire heap at once. It prioritizes collecting regions with the most garbage first—hence the name. This means:

  • Pause times are predictable and relatively short (typically 50–200ms, tunable)
  • It handles large heaps reasonably well
  • Throughput is solid for most workloads
  • It requires minimal tuning for typical applications

When to Use G1

You're already using G1 if you run Java 9 or later without specifying a GC flag. Keep using it unless you have concrete evidence—from metrics—that pause times are a problem.

G1 is the right choice for:

  • Spring Boot APIs and microservices (heaps from 4GB to 64GB)
  • Web applications with general-purpose workloads
  • Teams without dedicated performance engineers
  • Applications where a few 100ms pauses per minute are acceptable

Example: Configuring G1

java
java -Xmx16g \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=200 \
  -XX:+ParallelRefProcEnabled \
  -jar myapp.jar

The MaxGCPauseMillis flag tells G1 to target pause times of 200ms. G1 will adjust its collection strategy to hit this target (though it can't guarantee it). Lower values mean more frequent, smaller collections; higher values allow longer but fewer pauses.

ZGC: Sub-Millisecond Pauses at Scale

The Z Garbage Collector (ZGC) is a low-latency GC designed for heaps from 8GB to multiple terabytes. Its defining feature: pause times are almost always under 1ms, regardless of heap size. For some teams, that's game-changing. For others, it's unnecessary overhead.

How ZGC Works

ZGC uses concurrent marking and concurrent copying: most of the work happens while your application threads keep running. It marks objects while the app runs, then moves them concurrently without stopping the world. Only a few operations require a brief pause, and those pauses are decoupled from heap size.

When to Use ZGC

ZGC is worth considering if:

  • You're building a high-frequency trading system, real-time ad auction platform, or other sub-100ms SLA service
  • Your heap is larger than 16GB and pause times regularly exceed 500ms with G1
  • You've measured GC pauses and they're directly causing request timeouts
  • Your team has the expertise to tune and debug concurrent collectors

ZGC is not worth it if:

  • You're running a typical CRUD API or batch job
  • Pause times under 200ms are already acceptable
  • You haven't actually measured GC pause times yet

Example: Configuring ZGC

java
java -Xmx64g \
  -XX:+UseZGC \
  -XX:+ZGenerational \
  -jar myapp.jar

The ZGenerational flag (available in Java 21+) improves throughput on generational workloads. ZGC has few tuning knobs by design; it largely takes care of itself.

Real-World Scenario: Trading Platform

Imagine a market data ingestion service that buffers order updates in memory before writing to Kafka. With G1 on a 32GB heap, GC pauses occasionally spike to 800ms. During those pauses, inbound orders are not processed, and the system falls behind. Switching to ZGC reduces pauses to under 2ms, keeping the pipeline stable even under load.

Parallel GC: Throughput Over Latency

The Parallel GC (also called Throughput GC) is the oldest production-ready collector. It's simple: it pauses the entire application, mobilizes multiple threads, and compacts the heap aggressively. The pause can be a second or more on large heaps, but the time between pauses is long, and overall throughput is excellent.

When to Use Parallel GC

Use Parallel GC only if:

  • You're running a batch job that processes data overnight with no interactive users (e.g., a daily report generator)
  • Throughput (total computation speed) matters far more than pause times
  • Your heap is under 8GB

For almost every modern Spring Boot service, G1 is a better default than Parallel GC.

Example: Configuring Parallel GC

java
java -Xmx8g \
  -XX:+UseParallelGC \
  -XX:ParallelGCThreads=8 \
  -jar batch-job.jar

How to Measure GC Pause Times

Before you change the garbage collector, measure. Guessing is the enemy of good performance.

Using JVM Flags to Log GC Activity

java
java -Xmx16g \
  -Xlog:gc*:file=gc.log:time,uptime,level,tags \
  -jar myapp.jar

This writes detailed GC logs to gc.log. Parse it with tools like GCLogAnalyzer or GCeasy.io to see pause time distributions, frequency, and trends.

Monitoring in Production

For ongoing visibility, expose GC metrics via Micrometer and Spring Boot Actuator:

java
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer;
import org.springframework.context.annotation.Bean;

@Bean
public MeterRegistryCustomizer<MeterRegistry> gcMetrics() {
    return registry -> {
        // GC pause time and count are automatically registered
        // by MicrometerMeterRegistry via JVM metrics
    };
}

Then query /actuator/metrics/jvm.gc.pause and graph pause times over time. Look for the 95th and 99th percentiles, not just averages.

Load Testing Your GC Strategy

Generate realistic traffic and measure pause times under load, not on an idle system. Use tools like JMeter or Gatling to simulate your peak workload, then examine GC logs side-by-side with request latencies. If GC pauses correlate with application p99 latencies, you've found your bottleneck.

Common Mistakes

Mistake 1: Switching GC Without Baseline Measurements

Teams often disable G1 and enable ZGC because they read it was "faster" online, without ever measuring pause times. Result: no improvement, added complexity, and harder debugging when something breaks.

Mistake 2: Chasing Sub-Millisecond Pauses on a CRUD API

If your Spring Boot REST API has p99 latencies of 50ms due to database queries, shaving 50µs off GC pauses buys you nothing. Focus on the real bottleneck.

Mistake 3: Not Accounting for Full GC (Old Generation Collection)

Young generation collections happen frequently and pause for microseconds to milliseconds. Full GC (old generation collection) happens rarely but pauses for much longer. Measure both. If Full GC pauses dominate, your heap is either too small or your application is leaking memory.

Mistake 4: Forgetting to Account for CPU Overhead

ZGC runs more garbage collection work concurrently, which uses more CPU. On a system already at 80% CPU, switching to ZGC might actually increase pause times because the concurrent collector competes for cores. Measure CPU utilization alongside pause times.

Summary Table: Comparing the Collectors

Collector Default? Typical Pause Time Best Heap Size Use Case
G1 Yes (Java 9+) 50–200ms 4GB–64GB General-purpose microservices
ZGC No (opt-in) <1ms 8GB+ Low-latency, high-throughput systems
Parallel GC No (legacy) 500ms–2s <8GB Batch jobs, throughput only
Serial GC No (obsolete) 100ms–1s <2GB Single-threaded legacy apps

Key Takeaways

  • G1 is the safe default. It balances pause times, throughput, and simplicity well enough for most Spring Boot applications. Stay with it until you have evidence otherwise.
  • Measure pause times before changing anything. Use JVM logging, GCeasy.io, or Spring Boot Actuator metrics to see the true distribution of pauses under production-like load.
  • ZGC is for specific, latency-critical workloads. If you're building a trading platform, ad auction system, or real-time API where sub-millisecond pauses matter and you've proved GC is your bottleneck, ZGC is your answer.
  • Parallel GC is for batch jobs only. For interactive applications, it's rarely the right choice anymore.
  • Garbage collector tuning is a last resort. Before switching collectors, fix database query times, reduce memory allocations, and optimize your code. A leak or hot loop beats any GC improvement.
  • Know your SLAs. A 200ms pause in a batch system is fine. A 200ms pause in a trading system is a disaster. Your latency requirements drive your GC choice.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles