Modern Java Garbage Collection: Deep Dive into G1, Generational ZGC, and Shenandoah
In high-throughput distributed microservices, tail latency (P99/P99.9) is king. A backend service can maintain an average response time of 5ms, but if a periodic 200ms Stop-The-World (STW) garbage collection pause strikes, downstream API gateways timeout, Kafka consumers risk heartbeat failures triggering partition rebalances, and customer-facing SLAs fail.
Java’s garbage collection has seen radical innovations across LTS releases. The days of accepting hundreds of milliseconds of application freezes are over. Today, JVM performance engineers can choose between high-throughput regional engines and fully concurrent, sub-millisecond low-latency collectors.
This deep dive deconstructs the inner mechanics, memory barriers, throughput costs, and trade-offs of G1 GC, Generational ZGC, and Shenandoah GC.
1. G1 GC: The Balanced Generational Workhorse
Garbage-First (G1) has been the default HotSpot collector since Java 9. Designed to replace CMS (Concurrent Mark Sweep), G1 strikes an engineering balance between raw CPU throughput and bounded pause times.
Internal Architecture & Region Grid
- Region-Based Memory: Instead of allocating contiguous physical space for Young and Old generations, G1 subdivides the heap into 2,048 logical, non-contiguous regions ranging from 1MB to 32MB (always a power of two, determined dynamically by
-Xmx). - Dynamic Generation Roles: Each region dynamically assumes a role: Eden, Survivor, Old, or Humongous (objects larger than 50% of a region size).
- Collection Set (CSet): G1 continuously tracks live object density and reclaimable garbage per region. During collection, it prioritizes regions with the highest proportion of reclaimable space ("Garbage-First") to maximize freed memory within the target pause window.
+-----------------------------------------------------------+
[Eden] [Old] [Eden] [Humongous] [Survivor] [Old] [Survivor][Eden] [Old] [Old] [Eden] [Old] [Survivor] [Free] [Eden]
+-----------------------------------------------------------+
Logical 1MB–32MB Independent Region Grid
The Evacuation Phase & STW Mechanics
G1 marks objects concurrently during normal application execution using Snapshot-At-The-Beginning (SATB) write barriers. However, the actual compaction and evacuation phase is strictly Stop-The-World (STW):
- Application threads halt at an aligned JVM safepoint.
- Live objects from the chosen CSet regions copy into fresh, empty survivor regions.
- Survivor objects age and promote to Old regions as needed.
- Old regions in the CSet are reclaimed in their entirety.
- Target Pause Control: Configured via
-XX:MaxGCPauseMillis=200(default). G1 dynamically resizes Young generation regions to satisfy this target based on historical decay metrics. - Limitation: Under sudden high-throughput allocation bursts, G1 cannot evacuate fast enough within the target window, resulting in extended STW pauses or cascading into an expensive single-threaded Full GC fallback.
2. Generational ZGC: Sub-Millisecond Pauses at Terabyte Scale
Integrated as production-ready in Java 15 and enhanced with generational capabilities via JEP 439, ZGC is an ultra-low latency collector engineered to scale predictably from tens of megabytes to 16 Terabytes.
Core Mechanics: Colored Pointers & Load Barriers
ZGC executes marking, evacuation, and reference remapping concurrently alongside running application threads.
1. Colored Pointers
ZGC utilizes reference coloring by embedding metadata directly into the unused upper bits of 64-bit virtual memory pointers:
+-------------------+-------------+-----------------------------------------------+
16 bits (unused) 4 metadata 44 bits (Object Virtual Memory Address) bits Supports up to 16 Terabytes of address space
+-------------------+-------------+-----------------------------------------------+
|
+-------------+-------------+
| Marked0 | Marked1 | Remapped | Finalizable |
Because metadata resides directly in the pointer reference, the runtime determines an object's GC state without dereferencing the pointer or reading object header cache lines.
2. JIT Load Barriers (Self-Healing Pointers)
When an application thread dereferences an object field (e.g., obj.field), a JIT-compiled Load Barrier validates the colored bits of the reference:
- If the pointer is marked
Remapped, execution proceeds immediately at native CPU speed (cost: ~1–2 instructions). - If the pointer points to an object in an active evacuation page, the load barrier intercepts the call:
Remapped.
Subsequent reads directly hit the updated address—this is the self-healing mechanism.
Application Thread -> Read reference obj.field
|
[Load Barrier Test]
/ \
Pointer is 'Remapped'? Pointer 'Not Remapped' (In Evacuation Set)
| |
Fast Path Slow Path (Self-Heal):
(Execute instantly) 1. Relocate object / read forward table
2. Update pointer in place
3. Mark pointer 'Remapped'
Generational ZGC Performance Profile
Generational ZGC segregates memory into Young and Old generations while preserving concurrent relocation. Because most objects die young (the weak generational hypothesis), collecting the young generation frequently reclaims memory rapidly with negligible CPU overhead, preventing allocation stalls under intense traffic.
- Typical Pauses: Less than 1ms (often sub-250 microseconds), independent of whether the heap is 16GB or 16TB.
3. Shenandoah GC: Concurrent Compaction via Forwarding Pointers
Developed by Red Hat and integrated into OpenJDK, Shenandoah is an ultra-low latency garbage collector designed to eliminate compaction-induced pause spikes without requiring multi-terabyte infrastructure.
Core Mechanics: Forwarding Pointers & Load-Reference Barriers
Like G1, Shenandoah structures memory into discrete regions. Unlike G1, it performs the expensive object evacuation phase concurrently.
1. Forwarding Pointers (Brooks Pointers)
To allow concurrent relocation while application threads actively mutate memory:
- Every object maintains an internal forwarding pointer in its header.
- In steady-state, the forwarding pointer points directly to the object itself.
- When Shenandoah evacuates an object:
Old Object Memory: New Object Memory:
+------------------------+ +------------------------+
| Forwarding Pointer ----|--------------> | Forwarding Pointer (self)
+------------------------+ +------------------------+
| Object Fields / Payload| | Object Fields / Payload|
+------------------------+ +------------------------+
2. Load-Reference Barriers (LRB)
Modern Shenandoah uses Load-Reference Barriers to intercept heap loads. Whenever an application thread dereferences an object, the barrier checks the forwarding pointer, ensuring the application always mutates the canonical, up-to-date copy during active evacuation.
- Typical Pauses: Sub-10ms, strictly bounded by minor root-scanning safepoints (Init Mark, Final Mark, Init Evac).
Direct Architecture Comparison
| Feature / Metric | G1 GC | Generational ZGC | Shenandoah GC |
|---|---|---|---|
| Primary Design Goal | Balanced throughput & bounded pauses | Ultra-low latency across massive heaps | Low latency via concurrent compaction |
| P99 Pause Target | 10ms – 200ms (Soft limit) | < 1ms (Deterministic) | < 10ms (Deterministic) |
| Marking Phase | Concurrent | Concurrent | Concurrent |
| Evacuation / Compaction | Stop-The-World (STW) | Concurrent | Concurrent |
| Core Mechanism | Card tables & SATB write barriers | Colored pointers & Load barriers | Forwarding pointers & LRBs |
| Generational Support | Fully Generational | Fully Generational | Evolving / Generation-aware modes |
| Throughput Overhead | Lowest (~1–3% CPU) | Low (~3–5% CPU) | Moderate (~5–8% CPU) |
| Optimal Heap Size | 4GB – 32GB | 16GB – 16TB+ | 4GB – 64GB+ |
Production Recommendations & JVM Flags
Standard Enterprise Microservice (G1 GC)
Best for raw batch throughput where 50ms–100ms pause spikes will not violate SLAs:java -XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-XX:InitiatingHeapOccupancyPercent=45 \
-Xms16g -Xmx16g \
-XX:+AlwaysPreTouch \
-jar service.jar
Ultra-Low Latency & Financial APIs (Generational ZGC)
Best for strict sub-millisecond P99/P99.9 latency SLAs on modern Java builds:java -XX:+UseZGC \
-XX:+ZGenerational \
-Xms32g -Xmx32g \
-XX:+AlwaysPreTouch \
-jar service.jar
Containerized Medium Heaps (Shenandoah GC)
Best for sub-10ms response constraints on medium heaps (4GB–32GB) where low pause jitter is required:java -XX:+UseShenandoahGC \
-XX:ShenandoahGCMode=satb \
-Xms16g -Xmx16g \
-XX:+AlwaysPreTouch \
-jar service.jar
The Kubernetes CPU Limit Trap: CFS Throttling & Allocation Stalls
Deploying concurrent garbage collectors (Generational ZGC or Shenandoah) inside containerized cloud environments introduces an operational trap: Linux Completely Fair Scheduler (CFS) throttling.
How STW Collectors Behave Under CFS
Under G1, when an evacuation pause begins, mutator threads stop executing. The JVM consumes available CPU quotas exclusively to evacuate objects as fast as possible before resuming application traffic.
How Concurrent Collectors Trigger Throttling
Under ZGC or Shenandoah, GC worker threads run concurrently on background cores while HTTP request-handling threads process inbound payloads.
Consider this common Kubernetes resource specification:
resources:
requests:
cpu: "4"
memory: "16Gi"
limits:
cpu: "4" # <-- Latency Risk
memory: "16Gi"
During heavy ingress traffic, your application mutators and JVM GC background threads burn compute cycles simultaneously. The Linux CFS tracks accumulated CPU runtime across the container. Once the aggregate execution hits the millisecond quota within the default period (cpu.cfs_period_us, typically 100ms), the kernel throttles the entire container.
[HTTP Worker Threads (4 cores)] + [Concurrent GC Threads (2 cores)]
|
Surpasses 4 CPU limit
|
CFS Quota Exhausted!
|
Kernel Throttles Entire Container
|
Mutators continue allocating memory while paused
|
ALLOCATION STALL OCCURS
(Latency spikes to 2–5 seconds)
The Failure Cascade (Allocation Stalls)
When threads are throttled, application threads continue allocating memory faster than the throttled GC workers can reclaim pages. Once free regions are exhausted, the JVM enters a synchronous allocation stall:
- Application threads attempting to allocate objects are placed on wait queues.
- Latency jumps from less than 1ms directly to multiple seconds.
- Health check endpoints fail, prompting Kubernetes liveness probes to kill the pod.
Production Mitigation Rules
- Over-provision CPU headroom: Allocate concurrent collectors at least 20–25% more CPU capacity than a baseline G1 deployment to absorb concurrent background sweep cycles.
- Avoid tight CPU limits: Match CPU requests to actual usage and consider omitting CPU limits or leveraging the Kubernetes
GuaranteedQoS tier with static CPU core pinning for latency-critical workloads. - Pre-touch memory pages: Always pair
-Xmsequal to-Xmxwith-XX:+AlwaysPreTouchto force page-table population during JVM startup, preventing runtime page fault latency.