Garbage Collection Mechanics: G1GC, ZGC & Concurrent Relocation
Every tail-latency spike you've chased in a Java service eventually traces back to a GC pause. The question is why those pauses happen - and whether they have to.
Every collector runs the same four steps:
- Mark live objects on the heap.
- Identify regions holding mostly dead garbage.
- Relocate live objects to clean pages.
- Reclaim the now-empty pages.
Marking is cheap. The hard part is step 3: relocation. Moving an object changes its address, and every pointer to it across thread stacks, registers, and fields must be updated. That's exactly where G1 and ZGC take fundamentally different paths.
Why Objects Move (And Why It Causes Chaos)
As objects die, memory fragments - live objects end up scattered between gaps too small for new allocations. To reclaim whole pages into clean, contiguous memory, the GC must relocate surviving objects. But moving an object changes its address (say, 0x1000 becomes 0x9000). If any application thread reads the old address before it's updated, it hits stale data or crashes. Remapping those pointers safely without stalling the application is where all GC complexity begins.
flowchart TD
subgraph PageA["Page A (Fragmented)"]
OA1["Object A (Live)"]
G1["Garbage (Dead)"]
OB1["Object B (Live)"]
G2["Garbage (Dead)"]
end
subgraph PageD["Page D (Compacted Free Space)"]
OA2["Object A (Relocated)"]
OB2["Object B (Relocated)"]
FREE["Remaining Free Space"]
end
OA1 -->|"Relocate Live Object"| OA2
OB1 -->|"Relocate Live Object"| OB2
Don't confuse the two. Promotion is when an object survives enough GC cycles to move from Young Gen to Old Gen. Relocation is just moving an object to a different page to kill fragmentation - it can happen ten times in the same generation without any promotion.
What is a Stop-The-World (STW) Event and Why Is It Needed?
A Stop-The-World (STW) pause suspends every Java thread at a Safepoint. HTTP requests queue in socket buffers, DB queries freeze, loops halt - nothing runs except GC threads.
Why freeze at all? Because if a mutator thread writes a reference while GC is copying the object, you get half-updated data. Freezing creates a stable heap snapshot: GC can safely copy objects and bulk-update all pointers without a race condition. That's the tradeoff - correctness for latency.
How G1 GC Handles Relocation (And Why Pause Targets Fail)
Why does G1 stop all Java threads during evacuation? Because if application threads kept running while objects were being shifted from one memory address to another, threads could read or write half-copied data.
G1 simplifies this concurrency dilemma by enforcing a Stop-The-World (STW) Evacuation Pause. All mutator threads are frozen, live objects are copied to fresh regions, all references are eagerly updated, and then threads resume.
flowchart TD
TARGET["-XX:MaxGCPauseMillis\n(e.g., Target: 10ms)"] --> COST["G1 Historical Cost Model\n(How fast regions evacuate)"]
COST --> CALC["Estimate CSet Size\n(e.g., 10ms ≈ 20 regions)"]
CALC --> CSET["Select Collection Set (CSet)"]
CSET --> STW["Execute STW Evacuation Pause"]
When Allocation Outpaces Reclamation
If your application threads allocate memory faster than G1 worker threads can evacuate regions, free heap space evaporates:
flowchart LR
ALLOC["Application Allocating\n(100 GB/s)"] --> HEAP["Free Heap Space\n(Rapidly Exhausting)"]
HEAP --> RECLAIM["G1 Reclaiming\n(60 GB/s)"]
HEAP -.->|"Out of Free Regions"| FULL["Pause Target Overridden:\nAllocation Stall / Full GC"]
When free regions run dry, physical memory limits override all pause-time goals. G1 is forced into an emergency Full GC pause or Allocation Stall.
G1 Evacuation Pause Sequence
sequenceDiagram
autonumber
participant App as Java Application Threads
participant G1 as G1 GC Worker Threads
participant Heap as Heap Regions
App->>App: Running business logic
Note over App,G1: ── STW EVACUATION PAUSE BEGINS ──
G1->>App: Stop all application threads at safepoint
activate G1
Note over G1: Application completely frozen
G1->>Heap: Scan roots & identify live objects in CSet
G1->>Heap: Copy live objects to fresh regions
G1->>Heap: Update every in-memory reference to new addresses
G1->>Heap: Free old regions
G1-->>App: Resume application threads
deactivate G1
Note over App,G1: ── STW EVACUATION PAUSE ENDS ──
App->>App: Continue running with updated pointers
ZGC: Moving Objects Without Stopping Your App
ZGC flips the traditional model on its head by relocating objects concurrently while application threads keep running. It isn't completely pause-free - short stop-the-world pauses (typically under 1ms) still occur for root scanning and phase transitions. But while G1 pauses everything to copy gigabytes of live data, ZGC copies objects in the background so application threads continue serving requests uninterrupted.
Under the Hood: Colored Pointers & Load Barriers
To safely access objects while background GC threads move them, ZGC pairs Colored Pointers with Load Barriers. Colored pointers store metadata bits (like Marked or Remapped) directly in the upper bits of each 64-bit reference to show whether an address is fresh or stale without table lookups. When an application thread reads a reference (e.g., customer.latestOrder), a JIT-injected load barrier tests these bits: if valid (fast path), it returns immediately; if the object was relocated (slow path), the barrier checks the forwarding table, updates the stale reference in memory (self-healing), and returns the new address so all subsequent reads take the fast path.
sequenceDiagram
autonumber
participant App as Application Thread
participant LB as Load Barrier
participant FWD as Forwarding Table
participant Heap as Heap Memory
App->>LB: Read object reference
alt Color is Valid (Fast Path)
LB-->>App: Return object address immediately
else Color is Stale / Relocated (Slow Path)
LB->>FWD: Lookup new address (e.g., 0x1000 → 0x9000)
FWD-->>LB: Return 0x9000
LB->>Heap: Update reference to 0x9000 (Self-Healing)
LB-->>App: Return 0x9000
end
Architectural Comparison: G1 vs. ZGC
Thread Execution Timelines
The visual difference in execution models between G1 and ZGC can be understood directly by observing application mutator threads versus garbage collection worker threads over time:
Architectural Decision Flow
G1 makes STW evacuation smaller; ZGC makes relocation concurrent.
- G1: Controls how much evacuation work fits inside each STW pause using empirical pause-time budgeting.
- ZGC: Moves relocation outside STW pauses altogether using load barriers and forwarding tables.
Production Trade-Offs: Latency vs. Throughput
ZGC was engineered for one overarching purpose: predictable, ultra-low latency. It is not designed to beat Parallel GC or G1 in raw execution throughput.
Running load barriers on pointer reads and executing GC threads concurrently burns a modest percentage of CPU cycles (typically 2% to 5% overhead). If your workload is batch data processing where a 200ms pause does not matter, Parallel GC or G1 will deliver higher raw throughput. But if you run microservices, APIs, or trading platforms where 99.9th percentile tail latency is critical, ZGC is a massive upgrade.
| Feature / Dimension | G1 GC (Garbage-First) | ZGC (Z Garbage Collector) |
|---|---|---|
| Heap Organization | Region-based (1MB - 32MB regions) | Page-based (Small, Medium, Large pages) |
| Evacuation / Relocation | Stop-The-World (STW) pause | Concurrent with application threads |
| Pause Latency | 10ms - 200ms (configured target) | < 1ms (independent of heap size) |
| Primary Strength | High throughput for general enterprise workloads | Predictable sub-millisecond tail latency |
| Pointer Mechanism | Standard 64-bit object pointers | Colored Pointers (Metadata bits embedded in reference) |
| Barrier Technique | Write Barriers (Card tables / SATB buffers) | Load Barriers (Pointer color check on reference read) |
| Reference Fix-Up | Eagerly updated during the STW pause | Lazy, on-demand remapping via load barriers |
| Generational Support | Generational by default | Generational mode available (-XX:+ZGenerational in JDK 21+) |
| Ideal Use Case | Standard enterprise applications with heaps ≤ 32GB | Ultra-low latency microservices, financial engines (heaps up to 16TB) |
The Real Cost of ZGC
Sub-millisecond pauses sound like magic, but you pay for them in three ways: CPU overhead, lower raw throughput, and extra RAM.
1. CPU Tax from Load Barriers
Because ZGC moves objects while your code runs, every single heap reference read triggers a JIT-injected load barrier check. While a single check takes barely a couple of CPU cycles, tight loops doing millions of reads will burn measurable CPU. For standard I/O-bound web services, you will never notice it. For CPU-bound workloads like data processing or analytics, that constant checking eats into your computing budget.
2. Lower Raw Throughput
Parallel GC is ruthless about throughput: it stops everything, uses 100% of all CPU cores to clean up as fast as possible, and gets out of the way. Between pauses, your app gets the entire machine.
ZGC does the opposite. Its GC threads run continuously alongside your application, competing for CPU cores, L3 cache, and memory bus bandwidth. For the exact same workload, ZGC typically burns 10% to 30% more total CPU cycles than Parallel GC. The pauses disappear, but a heavy batch job will simply take longer to finish.
3. You Need 20-30% More Heap Headroom
ZGC is a race: background threads must reclaim memory faster than your application allocates it. If your app allocates too quickly and the heap fills up, ZGC triggers an Allocation Stall - mutator threads freeze until pages are freed, which feels just like an old-school STW pause.
To stay ahead of the race, ZGC needs breathing room. A service that runs comfortably on an 8GB heap with G1 usually needs 10GB to 11GB on ZGC to maintain a safe buffer.
ZAllocationStall in your GC logs, your threads are getting paused because the collector fell behind. The fix is almost always adding heap space or reducing allocation rate, not increasing GC thread counts.
Comparing the Three Core GC Algorithms
With the mechanisms and trade-offs established, here is the side-by-side picture across all three primary collectors for production JVM workloads:
| Feature | Parallel GC | G1 GC | ZGC |
|---|---|---|---|
| Primary Goal | Maximum Throughput | Balanced Latency & Throughput | Ultra-Low Latency |
| Typical Pause Time | 200ms - 5s (multi-threaded STW) | 10ms - 200ms (configurable target) | < 1ms (independent of heap size) |
| Application Overhead | Lowest - no load barriers | Medium - write barriers (SATB / card tables) | Higher - load barriers on every object reference read |
| CPU Efficiency | Highest - 100% of cores go to GC during pause, zero overhead between pauses | Medium - some concurrent background work steals cycles | Lower - concurrent GC threads compete for CPU continuously |
| Memory Headroom | Very Low - runs close to live data set size | Medium - ~10-15% extra headroom recommended | High - 20-30% extra heap to avoid allocation stalls |
| Ideal Workload | Batch jobs, data pipelines, offline ETL, Spark workers, ML training | General web services, REST APIs, standard enterprise microservices | High-frequency trading, real-time gaming, strict sub-10ms SLA services |
Why Not Just Use ZGC Everywhere?
- Batch Jobs (Spark, ETL, nightly reports): A 90-minute G1 job often runs 95-100 minutes under ZGC. Nobody cares about two 800ms pauses - they care the job finishes before business hours. Use
-XX:+UseParallelGC. - Small Kubernetes Pods (1 vCPU, 1-2GB RAM): ZGC's background threads need idle cores to work. With 1 vCPU and 1GB heap, the 20-30% headroom requirement leaves almost nothing for live objects. Serial GC or conservative G1 wins here.
How to Choose the Right GC for Your Application
Choosing the right garbage collector comes down to balancing the GC Trilemma: Latency (how long threads pause), Throughput (how much total CPU is dedicated to business logic), and Footprint (memory overhead required for metadata and barriers). No collector maximizes all three simultaneously.
Garbage Collector Decision Tree
Follow this decision framework to match your application requirements to the optimal collector:
flowchart TD
START(["Start: Select JVM Collector"]) --> Q1{"Heap Size\n& Resource Budget?"}
Q1 -->|"Heap < 512MB or\nSingle-Core Container"| SERIAL["Serial GC\n-XX:+UseSerialGC\n(Zero thread overhead)"]
Q1 -->|"Multi-core,\nHeap >= 1GB"| Q2{"What is your primary\nperformance SLA?"}
Q2 -->|"Batch Compute / Data Pipelines\n(Throughput > Pauses)"| PARALLEL["Parallel GC\n-XX:+UseParallelGC\n(Highest CPU Efficiency)"]
Q2 -->|"Interactive Microservices\n(Standard Latency SLA)"| Q3{"Heap Size & P99 Pause\nRequirements?"}
Q3 -->|"Heap 2GB - 32GB\nPause SLA: 50ms - 200ms"| G1["G1 GC (Default)\n-XX:+UseG1GC\n(Balanced Throughput & Pauses)"]
Q3 -->|"Heap > 32GB OR\nStrict P99 SLA < 10ms"| Q4{"JVM Runtime\nVersion?"}
Q4 -->|"JDK 21 or newer"| ZGEN["Generational ZGC\n-XX:+UseZGC -XX:+ZGenerational\n(< 1ms Pauses, High Throughput)"]
Q4 -->|"JDK 11 to JDK 17"| ZSINGLE["Standard ZGC\n-XX:+UseZGC\n(< 1ms Pauses)"]
Comprehensive Collector Comparison Matrix
| Collector | JVM Flag | Pause Target | Throughput | Heap Range | Ideal Workload |
|---|---|---|---|---|---|
| Serial GC | -XX:+UseSerialGC |
100ms - 2s (Single-threaded STW) | 90% - 92% | < 512MB | CLI tools, serverless Lambdas, single-core embedded containers. |
| Parallel GC | -XX:+UseParallelGC |
200ms - 5s (Multi-threaded STW) | 98% - 99% (Max) | 1GB - 64GB | Offline batch jobs, ETL pipelines, non-interactive computational workloads. |
| G1 GC | -XX:+UseG1GC |
10ms - 200ms (Soft configurable) | 93% - 96% | 2GB - 32GB | Standard web backends, general microservices, enterprise business applications. |
| ZGC (Generational) | -XX:+UseZGC -XX:+ZGenerational |
< 1ms (Deterministic) | 91% - 95% | 8GB - 16TB | High-frequency trading, real-time gaming, low-latency APIs, ultra-large heaps. |
| Shenandoah GC | -XX:+UseShenandoahGC |
< 10ms (Concurrent Brooks/LRB) | 89% - 93% | 4GB - 100GB | Low-latency alternative in Red Hat OpenJDK distributions without colored pointer requirements. |
4 Practical Rules of Thumb
- Rule 1: If you do not know, stick with default G1 GC - G1 is the default collector from JDK 9 onwards for a reason. It delivers excellent throughput and predictable pauses for 90% of standard business workloads with heaps between 2GB and 32GB.
- Rule 2: Switch to Generational ZGC for strict P99 latency SLAs - If your microservice enforces strict latency SLAs (e.g., P99 < 15ms) or your heap exceeds 32GB, enable Generational ZGC (
-XX:+UseZGC -XX:+ZGenerationalon JDK 21+). It eliminates Stop-The-World evacuation stalls without major throughput penalties. - Rule 3: Use Parallel GC for offline processing - If your application runs nightly batch data transformations, Apache Spark workers, or machine learning pipelines where users are not waiting on interactive HTTP responses, use
-XX:+UseParallelGCto maximize raw CPU compute. - Rule 4: Avoid aggressive pause targets on G1 - Do not configure
-XX:MaxGCPauseMillis=1or5on G1 in an attempt to turn it into ZGC. G1 will enter safepoint churn, thrash the CPU, and fail its target anyway. If you need < 1ms pauses, switch to ZGC directly.
Symptoms Your Application Is Using the Wrong GC Configuration
When garbage collection is misconfigured or mismatched against your workload, the JVM produces distinct memory and latency telemetry patterns. Monitoring your heap sawtooth and pause metrics allows you to diagnose the root cause immediately:
Diagnostic Troubleshooting Flow
flowchart TD
START(["Production Metric Anomaly"]) --> SYMPTOM{"Observed JVM\nSymptom?"}
SYMPTOM -->|"Rapid Old Gen Growth\n& Frequent Full GCs"| S1["Premature Promotion\n(Eden & Survivor Too Small)"]
S1 --> F1["Action: Increase Young Gen sizing\nor tune -XX:SurvivorRatio"]
SYMPTOM -->|"High CPU Saturation +\nReduced Request Throughput"| S2["Safepoint Churn\n(MaxGCPauseMillis Too Low)"]
S2 --> F2["Action: Relax pause target to 50ms-100ms\nto reduce thread stop/start churn"]
SYMPTOM -->|"Threads Hanging on Allocation\n('Allocation Stall' in GC logs)"| S3["Allocation Rate > Reclamation\n(GC Threads Starved)"]
S3 --> F3["Action: Increase -XX:ConcGCThreads\nEnable -XX:+ZGenerational (JDK 21+)"]
SYMPTOM -->|"P99 Latency Spikes > 500ms\non Heaps > 32GB"| S4["Evacuation STW Bottleneck\n(Using G1/Parallel for Ultra-Low SLA)"]
S4 --> F4["Action: Switch to ZGC (-XX:+UseZGC)\nfor sub-millisecond tail pauses"]
Common GC Misconfiguration Pitfalls
- Premature Promotion (Old Gen Thrashing): If Eden or Survivor spaces are configured too small, temporary short-lived objects cannot survive in Young Gen long enough and get prematurely promoted into Old Gen. This fills the Old Gen rapidly, triggering frequent, expensive Full GC cycles.
- Safepoint Storms (Excessive Pause Churn): Setting an aggressive pause target like
-XX:MaxGCPauseMillis=1on G1 does not accelerate garbage collection. It fragments evacuation into dozens of micro-pauses, causing the JVM to waste 30%+ of total CPU cycles on thread suspension and safepoint coordination. - Allocation Stalls: In concurrent collectors like ZGC or G1, if mutator threads allocate memory faster than background GC threads can evacuate pages, threads enter an Allocation Stall - mutator threads are blocked at runtime waiting for memory pages to be freed.
- Wrong Collector for Latency SLAs: Using Parallel GC or G1 with high heap sizes (64GB+) for interactive microservices causes unavoidable multi-hundred-millisecond pauses during mixed evacuation cycles, violating strict P99.9 response latency SLAs.
The 4 Concepts to Remember
Whenever you think about modern JVM memory compaction, keep these four pillars in mind:
- Relocation: Moving live objects from fragmented pages to fresh pages so entire blocks of memory can be freed and recycled.
- Colored References: Encoding GC status directly into reference pointer bits so the runtime knows pointer health without table lookups.
- Load Barriers: Native assembly checks injected into reference reads that catch stale pointers before they can cause corruption.
- Remapping: Translating old object addresses to new ones on-the-fly using forwarding tables and writing the updated address back to the heap field (self-healing).
- To understand runtime memory areas (Heap, Stack, Metaspace), read JVM Memory Management: Heap, Stack & Metaspace.
- To explore JIT compilation, method inlining, and escape analysis, read Just-In-Time (JIT) Compilation & Optimizations.
- To learn how to analyze GC log files and troubleshoot memory leaks, read JVM Profiling, Monitoring & Troubleshooting.