13 min read

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:

  1. Mark live objects on the heap.
  2. Identify regions holding mostly dead garbage.
  3. Relocate live objects to clean pages.
  4. 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
Relocation vs. Promotion

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.

ZGC: Concurrent Execution Model Time flows left → right. Application threads never stop while GC does its work in the background. Application Threads (Mutators) • Serving HTTP requests  • Processing events  • Executing business logic  • Never paused <1ms <1ms ZGC Worker Threads (Concurrent GC) ➤ Concurrent Mark (Live Objects) ➤ Concurrent Relocate (Copy Live) ➤ Concurrent Remap (Fix References) Sub-millisecond STW root scan (thread stack safepoint only) All relocation happens concurrently - no full heap stop

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:

G1 GC Stop-The-World Evacuation Model Pause: 10ms - 200ms Application GC Threads STW EVACUATION PAUSE Mutator Threads Paused Evacuate CSet & Update All Refs Running Continues Execution ZGC Concurrent Relocation & Load Barriers Pause: < 1ms (Sub-millisecond) Application ZGC Worker <1ms Pause Continuous Mutator Execution Application Keeps Running Concurrent Mark & Relocation Load Barriers Remap Stale Reads

Architectural Decision Flow

Java Heap Management G1 GC (Garbage-First) STW Evacuation 1 Heap Divided into Uniform Regions Equal regions (1MB - 32MB) assigned logical roles 2 Generational Collection Strategy Eden, Survivor, and Old generations collected dynamically 3 STW Evacuation & Pause-Time Budget CSet chosen via -XX:MaxGCPauseMillis target 4 Eager Reference Fix-Up During Pause All references updated before mutators resume Outcome: Application Freezes During Evacuation • Pause duration scales with live object volume (10ms - 200ms) • Delivers high raw throughput for general enterprise apps ZGC (Z Garbage Collector) Concurrent Relocate 1 Dynamic Page-Based Heap Sizing Small (2MB), Medium (32MB), and Large pages 2 Concurrent Marking & Relocation Live objects copied while application threads execute 3 Colored Pointer Metadata Bits Pointers carry reference GC status without table lookups 4 Load Barriers & Self-Healing Remap Mutators resolve old pointers on read; future reads fast Outcome: Application Runs Continuously • Pauses strictly < 1ms regardless of heap size (≤ 16TB) • Optimized for ultra-low latency & strict SLA microservices

The Core Takeaway:

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.

Watch out for Allocation Stalls: If you see 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.

JVM Collector Positioning Matrix Mapping garbage collectors across Latency SLA targets, Throughput efficiency, and Heap size suitability 99% Max 94% Mid 90% Low THROUGHPUT EFFICIENCY → Seconds (High STW) 10ms - 100ms (Configurable) < 1ms (Sub-Millisecond) LATENCY STRICTNESS (PAUSE REDUCTION) → PARALLEL Parallel GC Throughput: 98% - 99% Pauses: 200ms - 5000ms Best for: Batch, ETL, Offline SERIAL Serial GC Footprint: Lowest (< 30MB) Best for: Micro-containers (< 512MB) DEFAULT (JDK 9+) G1 GC (Garbage-First) Throughput: 93% - 96% Pauses: 10ms - 200ms (Target) Heaps: 4GB - 32GB Best for: General Enterprise APIs ULTRA-LOW LATENCY ZGC (Generational) Pauses: < 1ms (Guaranteed) Throughput: 90% - 94% Heaps: 16GB - 16TB Concurrent Relocation Best for: Financial, Real-Time, High SLA Web Microservices Summary Rule of Thumb: Batch / Max Throughput → Parallel GC Balanced Default (Heaps ≤ 32GB) → G1 GC Strict SLA / Heaps > 32GB → ZGC

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:+ZGenerational on 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:+UseParallelGC to maximize raw CPU compute.
  • Rule 4: Avoid aggressive pause targets on G1 - Do not configure -XX:MaxGCPauseMillis=1 or 5 on 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:

JVM GC Telemetry Patterns Heap utilization and pause profiles across healthy vs. pathological GC configurations Heap Used Old Gen Baseline STW Pause Spike HEALTHY Normal Sawtooth Pattern (Proper Young Gen Sizing) Pauses: < 5ms (Predictable) 100% 50% 0% ANOMALY 1 Premature Promotion (Eden Undersized → Old Gen Thrashing) Pauses: 800ms - 3000ms (Full GC Spikes) 100% 50% 0% FULL GC (2.1s STW) FULL GC (2.4s STW) ANOMALY 2 Allocation Stall (Allocation Rate > Concurrent GC Reclamation) Symptom: Mutators Frozen (0 TPS) 100% 50% 0% ALLOCATION STALL ACTIVE Mutator threads blocked awaiting free pages

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=1 on 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:

  1. Relocation: Moving live objects from fragmented pages to fresh pages so entire blocks of memory can be freed and recycled.
  2. Colored References: Encoding GC status directly into reference pointer bits so the runtime knows pointer health without table lookups.
  3. Load Barriers: Native assembly checks injected into reference reads that catch stale pointers before they can cause corruption.
  4. 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).

Related Guides:
← PreviousJVM Architecture & ClassLoader SubsystemNext →Just-In-Time (JIT) Compilation & C2 Optimizations