7 min readjava

The 2026 JVM garbage collector decision: G1 vs ZGC vs Generational Shenandoah

JDK 25 quietly reset the garbage-collector landscape — ZGC went generational-only, Generational Shenandoah is productized, and compact object headers ship by default-able. Here's how to actually choose and tune, from the architecture, developer, and DevOps angles.

On this page

# The Problem

A senior engineer joins your team, opens the deployment config for your flagship service, and finds this at the top of the JVM args:

-XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=16m
-XX:InitiatingHeapOccupancyPercent=45 -XX:G1NewSizePercent=30 ...

Twelve flags. Nobody on the current team knows why any of them are there. They were copied from a blog post in 2019, tuned for a workload that no longer exists, on a JDK three LTS versions old. They might be helping. They might be actively hurting. You genuinely can’t tell, and everyone’s too afraid to touch them.

This is the state of GC tuning at most shops: cargo-culted flags nobody understands, defended by nobody-wants-to-be-the-one-who-broke-prod. And in 2026 it’s more out of date than usual, because JDK 25 quietly reset the collector landscape. The good news buried in that reset: the modern collectors want fewer knobs, not more. That twelve-flag incantation isn’t just mysterious — it’s the wrong approach entirely.


# What JDK 25 Changed

Three things landed in the JDK 25 LTS that make this a live decision again:

  • ZGC is now generational-only. The non-generational mode was removed. If you had -XX:-ZGenerational anywhere, it’s now dead config.
  • Generational Shenandoah graduated from experimental to a product feature — a strong low-pause option that’s no longer behind an experimental flag.
  • Compact object headers were promoted from experimental (JDK 24) to product. With -XX:+UseCompactObjectHeaders, every heap object shrinks by ~4–8 bytes. That’s not just heap savings; the improved cache locality often speeds things up.
The modern collectors want fewer knobs

ZGC and Shenandoah in 2026 are designed to be tuned primarily by heap size and page policy, not by a dozen collector-specific flags. The old “paste a big flag block” approach is an anti-pattern now — it fights the collector’s own adaptivity.


# The Architecture Perspective

The decision is genuinely three-way in 2026, and the right way to make it is from your latency SLO and heap size, not from folklore. Here’s the honest decision tree:

flowchart TD
    Q1{p99 pause SLO?} -->|"tens of ms is fine"| G1["G1GC"]
    Q1 -->|"need sub-ms tails"| Q2{Heap size?}
    Q2 -->|"large, multi-GB"| ZGC["Generational ZGC"]
    Q2 -->|"low-latency,<br/>tighter heap"| SHEN["Generational Shenandoah"]
    G1 --> COH["+ UseCompactObjectHeaders"]
    ZGC --> COH
    SHEN --> COH

    classDef g1 fill:#2563eb,color:#fff,stroke:#1e40af;
    classDef zgc fill:#059669,color:#fff,stroke:#065f46;
    classDef shen fill:#d97706,color:#fff,stroke:#92400e;
    classDef coh fill:#475569,color:#fff,stroke:#334155;
    class G1 g1;
    class ZGC zgc;
    class SHEN shen;
    class COH coh;

The architectural framing that matters: “low pause” is not free. ZGC and Shenandoah buy you sub-millisecond pauses by spending CPU cycles (concurrent work, load/store barriers) and heap headroom. If your SLO tolerates tens-of-milliseconds pauses — most throughput-oriented and batch workloads do — G1 is still the right, cheaper answer, and pairing it with compact object headers is close to a free win. You reach for the pauseless collectors when a strict tail-latency SLO justifies paying for it.

The collector follows the SLO

Don’t pick a collector by reputation (“ZGC is the fancy one”). Pick it by the SLO it has to satisfy and the heap it has to manage. A latency-critical service with a big in-memory cache and a throughput-oriented batch job want different collectors — and that’s correct, not inconsistent.


# The Developer Perspective

The most important skill isn’t knowing flags — it’s reading GC behavior before changing anything. Start with logging, not tuning:

# Observe first. This tells you pause causes, allocation rate, and (Shenandoah)
# pacing/degenerated cycles — the data you need before touching a knob.
java -Xlog:gc* -jar app.jar

Then the minimal viable config for each collector — note how little there is:

# G1: the default. Add compact headers for a near-free win.
java -XX:+UseG1GC -XX:+UseCompactObjectHeaders -jar app.jar

# Generational ZGC: the primary lever is -Xmx headroom, not collector flags.
java -XX:+UseZGC -Xmx8g -XX:+UseCompactObjectHeaders -jar app.jar

# Generational Shenandoah: start with heap + page policy, then observe.
java -XX:+UseShenandoahGC -Xms8g -Xmx8g -XX:+AlwaysPreTouch \
     -XX:+UseCompactObjectHeaders -jar app.jar

Follow a tuning ladder, in order, and stop as soon as your SLO is met:

  1. Size the heap (-Xms/-Xmx).
  2. Memory-page policy (-XX:+AlwaysPreTouch, large pages).
  3. Turn on GC logging and look at it.
  4. Only then touch collector-specific knobs — and only the ones the logs justify.
Enable compact object headers on JDK 25

-XX:+UseCompactObjectHeaders is a product feature now. It typically shrinks the heap ~4 bytes per object and frequently improves throughput via better cache locality — one of the rare “just turn it on” wins. Measure it on your object-heavy workload and keep the number.

Delete the 2019 flag block

If you can’t explain what a GC flag does and why it’s there, it’s a liability, not a safeguard. Start from the collector’s defaults plus heap sizing, add compact headers, and re-derive any flag from GC-log evidence. Modern ZGC/Shenandoah adapt better than a stale hand-tune.


# The DevOps Perspective

GC tuning lives or dies on observability and honest before/after measurement — and in containers, on getting the memory story right.

Container awareness first. In a cgroup-limited pod, size the heap with -XX:MaxRAMPercentage rather than a hardcoded -Xmx, and verify the JVM sees the limit you think it does. A collector tuned against the wrong memory ceiling will mis-behave no matter which one you picked.

Measure with a repeatable harness. Don’t trust vibes. Run a representative load test (JMH for micro, a gatling/k6 run for the service) that prints p99 pause, allocation stalls, and throughput, so a collector change shows a real delta:

flowchart LR
    A["Size heap<br/>-Xms/-Xmx or MaxRAMPercentage"] --> B["Page policy<br/>AlwaysPreTouch + large pages"]
    B --> C["-Xlog:gc*<br/>observe pauses & pacing"]
    C --> D["Collector-specific knobs LAST<br/>(only what logs justify)"]
    D --> E["Load test:<br/>p99 pause, stalls, throughput"]

    classDef step fill:#2563eb,color:#fff,stroke:#1e40af;
    classDef obs fill:#d97706,color:#fff,stroke:#92400e;
    classDef win fill:#059669,color:#fff,stroke:#065f46;
    class A,B step;
    class C,D obs;
    class E win;

Watch the right signals per collector. For ZGC, the main lever is -Xmx headroom — alert on allocation stalls that mean you’re under-provisioned. For Shenandoah, watch pacing, degenerated cycles, and uncommit behavior in the GC log; those tell you when the concurrent collector is falling behind the allocation rate. For G1, the classic signals (pause time, mixed-collection behavior) still apply.

Audit dead flags on the JDK 25 bump. As part of upgrading, grep your configs for removed/renamed flags — -XX:-ZGenerational is gone, and stale flags can fail startup or silently no-op.

Pros

  • Three-way choice driven by SLO + heap, not folklore
  • Compact object headers: smaller heap and often faster, near-free
  • ZGC/Shenandoah tune by heap size, not a dozen flags
  • G1 stays the cheaper, correct pick for throughput workloads
  • Observe-then-tune ladder prevents cargo-culting

Cons

  • 'Low pause' costs CPU cycles and heap headroom — not free
  • Container heap sizing (cgroups) is easy to get wrong
  • Requires GC-log literacy to tune correctly
  • JDK 25 removed flags (e.g. -XX:-ZGenerational) — audit configs
  • Wrong collector for the SLO wastes memory or blows tails

# Putting It Together

JDK 25 turned GC selection back into a real, three-way decision — and simplified how you tune each option. The modern move is the opposite of the twelve-flag block: start from heap sizing, page policy, and GC logging; pick the collector from your latency SLO and heap size; turn on compact object headers as a near-free win; and derive every remaining flag from evidence in the logs. G1 for throughput, ZGC for large low-latency heaps, Generational Shenandoah for tighter low-latency ones — and delete the 2019 incantation nobody can explain.


# References

views

Click to show appreciation

Discussion