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 -Xmx16g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+ParallelRefProcEnabled \
-jar myapp.jarThe 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 -Xmx64g \
-XX:+UseZGC \
-XX:+ZGenerational \
-jar myapp.jarThe 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 -Xmx8g \
-XX:+UseParallelGC \
-XX:ParallelGCThreads=8 \
-jar batch-job.jarHow 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 -Xmx16g \
-Xlog:gc*:file=gc.log:time,uptime,level,tags \
-jar myapp.jarThis 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:
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.






Comments
Loading comments…