Enterprise Java

The JVM Garbage Collector Decision in 2026: G1 vs ZGC vs Shenandoah for Real Workloads

UPDATED — now with Parallel GC

ZGC and Shenandoah have both gone generational. G1 picked up meaningful JDK 25 improvements. Most teams still default to G1 without ever checking whether that is actually the right call for their workload and a fourth option, Parallel GC, is still quietly the best pick for a whole class of jobs nobody in this conversation usually thinks about.

1. The Default-and-Forget Problem

Ask most Java engineers which garbage collector their service uses and you will get one of two answers: “G1, I think” or a slight pause followed by “the default.” For the vast majority of production Java services running today, that default is G1, and for many of them, that is actually fine. G1 is a capable, well-tuned collector that balances throughput and latency reasonably well for typical workloads.

The problem, however, is that the conversation rarely goes any further than that. Teams spin up a new service, leave the GC configuration untouched, and move on. Meanwhile, ZGC and Shenandoah have matured enormously since they first appeared, and both are now fully production-ready collectors with real advantages for specific workloads. Moreover, G1 itself has changed. JDK 25 brought meaningful improvements to how it handles remembered sets and mixed GC pause time spikes. And underneath all three of them sits Parallel GC, the original throughput-first collector, which is still running a large share of the world’s batch and ETL workloads simply because nobody ever had a reason to change it. None of this filters through to teams that treat GC as a “set it and forget it” concern.

So this guide is not here to tell you G1 is wrong. Instead, it is here to give you the framework to make a conscious decision rather than an accidental one, across all four collectors you are actually likely to encounter in a modern JDK 25 environment. As a result, your team ends up with a collector that was actually chosen for your workload, and that makes a measurable difference in production.

JEP 523, delivered in Java 25, makes G1 the universal JVM default in all environments, including constrained ones that previously fell back to Serial GC. This is a good change that eliminates a common source of confusion. It does not, however, mean G1 is always the best choice, only that it is now always the starting point.

2. State of Each GC in 2026

Before getting into benchmark numbers, it helps to understand where each collector actually stands today. The landscape has shifted quite a bit since Java 21, and some of the older assumptions about ZGC and Shenandoah no longer hold. We are also bringing Parallel GC into the comparison this time around, since several readers pointed out, correctly, that it is still very much alive and still the right answer for a real slice of production workloads.

DimensionG1ZGC (generational)Shenandoah (generational)Parallel GC
Status in JDK 25Universal default via JEP 523, including constrained environments that previously fell back to Serial GCGenerational only. Non-generational mode removed in JDK 24; -XX:+ZGenerational no longer existsFinalized in JDK 25. Was experimental in JDK 24, no longer requires -XX:+UnlockExperimentalVMOptionsUnchanged, opt-in. Stable since JDK 8, was the default HotSpot server collector before G1 took over in JDK 9
Pause behavior20–200ms typical, up to 500ms on large heaps. JDK 25 improved mixed GC region selection, reducing spike frequency0.1–0.5ms typical, <1ms worst case, regardless of heap size, 10 GB or 10 TB1–5ms typical, ~10ms occasional. Generational mode significantly reduced pause frequency vs its single-generation predecessorHundreds of ms to several seconds on large heaps. Both young and old generation collections are fully stop-the-world; no concurrent phase at all
Memory overheadBaseline (~10% headroom). JDK 25 cut remembered set memory from 2 GB to 0.75 GB on 64 GB heaps+15–30% vs G1. No compressed oops, needs ~25% heap headroom, higher RSS in containers+10–20% vs G1. Supports compressed oops, more memory-efficient than ZGCLowest of the four. No remembered sets, no colored pointers, no concurrent bookkeeping structures
Throughput costBaseline, best raw throughput since stop-the-world gives app threads predictable CPU access5–15% lower than G1, ~10% improvement over its non-generational predecessor5–10% lower than G1, ~30% throughput improvement in generational mode vs single-generation ShenandoahHighest of the four. No CPU cycles spent on concurrent marking or evacuation
Best heap range4 GB – 32 GB32 GB – multi-TB8 GB – 64 GB512 MB – 32 GB, workload permitting
Oracle JDK availabilityYesYesOpenJDK onlyYes
Older assumption that no longer holdsStill the best default, but JEP 523 making it universal does not mean it is always the best choice“ZGC always uses more memory than it’s worth” — generational mode recouped much of that cost with a ~10% throughput gain“Shenandoah is experimental” — generational mode is fully production-ready in JDK 25, no flags needed“Parallel GC is obsolete now that G1 is the default” — it is still the highest-throughput HotSpot collector for workloads that can tolerate its pauses
Best fit workloadGeneral-purpose APIs, batch and ETL jobs, CPU-saturated workloadsHFT and real-time systems, strict p999 SLA services, hyperscale in-memory appsMicroservices on Kubernetes, memory-constrained pods, medium-heap OpenJDK deploymentsBatch and ETL pipelines, Spark/Kafka worker nodes, offline scoring and reporting jobs

2.1 What Changed in JDK 25 Specifically

For G1, the most impactful JDK 25 change relates to remembered sets. Thomas Schatzl’s blog post on the GC team details how G1 now merges old-generation region remembered sets. On a GC stress test with a 64 GB Java heap, this reduces peak remembered set memory from 2 GB down to 0.75 GB. G1 also now uses incoming reference counts gathered during marking to improve candidate region selection, which directly reduces pause time spikes in the last mixed GC phase.

For Shenandoah, the headline change is straightforward: generational Shenandoah, released as experimental in JDK 24, is no longer experimental in Java 25 and no longer requires experimental flags to enable. Generational mode also significantly improved throughput compared to the original single-generation implementation, with pause times typically in the 1–5ms range.

For ZGC, the focus in JDK 25 was memory management ergonomics. Non-generational ZGC was removed in JDK 24, so if you are on ZGC at all today, you are already running the generational variant. Generational ZGC improves throughput by around 10% compared to its single-generation predecessor by exploiting the Weak Generational Hypothesis: most objects die young.

Parallel GC, by contrast, has not changed in any meaningful way for several releases, and that is precisely the point. It is one of the oldest collectors in HotSpot, has no concurrent phase to refine, and its design goal has stayed constant since it was the default server collector before JDK 9: pause the application, do the collection with every available core, resume, repeat. There is nothing to modernize because the trade-off it makes is still exactly the trade-off some workloads want.

2.2 Where Parallel GC Fits In

It is worth being explicit about why Parallel GC belongs in this comparison at all, given that the rest of this guide is largely about escaping stop-the-world pauses. The answer is that Parallel GC is not competing on latency, it never was, and for a specific category of workload that is exactly correct. When nobody is waiting on an individual response, an occasional multi-second pause is irrelevant, and what actually matters is how much total wall-clock time it takes to chew through a dataset. Because Parallel GC spends zero CPU cycles on concurrent marking, colored pointers, or remembered set maintenance, it converts more of your available CPU into useful application work than any of the concurrent collectors can. That is the entire pitch, and for batch jobs, ETL pipelines, and worker nodes behind a queue, it is still frequently the right one.

3. The Benchmark Data: Pause Times, Throughput, Memory

Raw numbers tell the story more clearly than any description can. The figures below are drawn from published data across multiple sources: OpenJDK’s own performance blog, Gunnar Morling’s widely-cited ZGC latency study, the Foojay adaptive JVM guide, and Andrew Baker’s Java 25 GC deep-dive, cross-checked against community tuning guides for Parallel GC. Following a reader request, all three charts below are now interactive with a visible legend, and Parallel GC is included alongside the other three.

Typical and worst-case stop-the-world pauses. Y-axis is logarithmic because Parallel GC’s pauses are roughly 1,000× longer than ZGC’s, a linear scale would flatten the other three bars to nothing. Sources: andrewbaker.ninja, morling.dev ZGC latency study, InformIT Parallel GC overview.

The pause time gap between G1 and the two concurrent collectors is stark at the tail. In real-world testing, latencies are practically identical up to the 99th percentile, but the p999 and p9999 latencies show a clear advantage for ZGC: G1 showed pauses of more than 20ms, while ZGC’s longest observed pause was around 50 microseconds. That is a 400x difference in worst-case pause behavior, and it is the kind of difference that shows up directly in SLA breaches and timeout logs. Parallel GC is not really part of that conversation at all, its pauses run into the seconds on large heaps, which is exactly why it never appears in a latency-sensitive deployment, and exactly why it is still fine for a nightly batch job that nobody is watching in real time.

However, and this is an important caveat, when the workload is CPU-bound and all system cores are fully saturated, ZGC can actually produce higher request times than G1, because the collector cannot free memory fast enough to keep up with the application allocating new objects at an extreme rate, leading to allocation stalls. This is the scenario where G1’s stop-the-world approach, while pausing longer, actually gives the application thread more predictable CPU access. Knowing your allocation rate is therefore essential before switching.

Memory and CPU overhead relative to G1, set as the 0% baseline. G1, ZGC, and Shenandoah figures: andrewbaker.ninja, toolshelf.tech, Datadog GC deep dive. Parallel GC’s −5%/−5% is our own directional estimate, not a published benchmark figure. Sources agree it carries no remembered sets, colored pointers, or concurrent bookkeeping structures and sits below G1 on both axes, but nobody publishes an exact percentage.

The memory picture is where Shenandoah starts to look attractive compared to ZGC. Because Shenandoah supports compressed object pointers and ZGC does not, ZGC’s memory consumption is approximately 15–30% higher than G1, while Shenandoah’s overall memory overhead is typically 10–20% compared to G1. If you are running in a resource-constrained Kubernetes pod, that difference can determine whether you trigger an OOMKill or not. Parallel GC sits at the opposite end from ZGC on this chart, and that is worth remembering the next time someone tells you it is “old and wasteful”: it is old, but it is the leanest of the four on both memory and CPU, which is exactly why it still shows up in cost-sensitive batch infrastructure.

If you are running a pod with a hard memory limit, say 2 Gi, the overhead of a concurrent collector might trigger an OOMKill where G1 or Parallel GC would have survived. Always account for the GC’s off-heap native memory structures when sizing container memory limits for ZGC or Shenandoah workloads.

4. Matching GC to Real Workloads

Theory and benchmarks are useful, but the practical question most engineers face is: “what does my application actually look like, and which collector fits that profile?” Fortunately, the workload archetypes map cleanly onto the four collectors.

Use G1 when…

G1 remains the right choice for general-purpose services where latency requirements are moderate (SLAs in the tens of milliseconds range), heap sizes stay below roughly 32 GB, and the team values predictable behavior with minimal tuning. It also holds up reasonably for batch and ETL workloads when you want a single collector configuration across your whole fleet, even if it is not the absolute fastest option for that specific job.

Use ZGC when…

ZGC is the collector for applications where tail latency is the business metric. Think high-frequency trading systems, real-time analytics dashboards, APIs with strict p99 SLAs, or any application where a 50ms pause at the wrong moment causes a visible user-facing degradation. It also excels at very large heaps, 32 GB and above, where G1’s pause times scale poorly with heap size. ZGC achieves sub-1ms pause times regardless of heap size, whether that heap is 10 GB or 10 TB, which makes it essentially the only viable option for hyperscale in-memory workloads. The trade-off is that you need approximately 25% additional memory headroom and an extra 5–10% CPU budget.

Use Shenandoah when…

Shenandoah occupies an interesting middle ground that becomes especially relevant in two scenarios. The first is memory-constrained environments where you want ZGC-class latency improvements but cannot afford ZGC’s memory overhead, because Shenandoah supports compressed oops, it is more efficient per byte. The second is teams running on non-Oracle OpenJDK distributions where ZGC’s multi-mapping memory approach creates complications. Shenandoah is also the better choice for applications with moderate heap sizes (8–32 GB) that need sub-10ms pauses but where ZGC’s hardware resource requirements feel excessive.

Use Parallel GC when…

Parallel GC is still the right call for throughput-first, non-interactive workloads: nightly batch jobs, ETL pipelines, Spark or Kafka Streams worker nodes, offline model scoring, report generation, anything where the success metric is “total time to finish the job” rather than “time to respond to this one request.” It is also worth a look for cost-sensitive infrastructure at scale, since its lower CPU and memory overhead compared to the concurrent collectors can translate directly into fewer nodes for the same batch throughput. The one place to be careful is anything with even a loose latency expectation, a full GC on a large heap can pause the application for multiple seconds, which is invisible in a queue-driven worker and disqualifying in anything user-facing.

Illustrative scoring based on the workload fit described throughout this guide, informed by foojay.io’s 10-year GC guide and toolshelf.tech. Not a benchmark output. Treat it as a starting point for your own testing, not a substitute for it.

5. Full Comparison Table

The table below summarizes all the key dimensions in one place, now with Parallel GC included as a fourth column. Use it as a quick reference when making the decision for a specific service, rather than having to hold all of the above in your head at once.

DimensionG1ZGC (Generational)Shenandoah (Generational)Parallel GC
Default in JDK 25?Yes (JEP 523)No — opt-inNo — opt-inNo — opt-in
Typical STW pause20–200ms0.1–0.5ms1–5msHundreds of ms – multiple seconds
Worst-case pauseUp to 500ms on large heaps<1ms~10ms occasional spikesMultiple seconds on large heaps
Throughput (vs G1)Baseline5–15% lower5–10% lowerHighest of the four
Memory overhead (vs G1)Baseline (~10% headroom)+15–30% (no compressed oops)+10–20% (supports compressed oops)Lowest of the four
CPU overhead (vs G1)Baseline+5–10%+5–15%Lowest of the four
Best heap size range4 GB – 32 GB32 GB – multi-TB8 GB – 64 GB512 MB – 32 GB
Generational modeAlways generationalGenerational only (since JDK 24)Final in JDK 25Always generational
Compressed oopsYesNoYesYes
Oracle JDK availabilityYesYesOpenJDK onlyYes
Tuning complexityLow-MediumLow (self-tuning)Low-MediumLow
Best workload fitGeneral-purpose, batch, ETLHFT, real-time, hyperscaleMicroservices, medium heaps, constrained memoryBatch, ETL, offline/worker jobs
JDK 25 key improvementRemembered set memory −62%, better mixed GC selectionGenerational mode now default/only modeGenerational mode finalized (no experimental flag)None, design unchanged since before JDK 9

6. Practical JVM Flags to Start With

Once you have selected a collector, you need to know the right flags to use. The snippets below are starting-point configurations that have been validated in production environments. In every case, measure before and after with your actual workload; these are sensible defaults, not universal truths.

G1 — sensible production baseline (JDK 25)

G1 production baseline

java -XX:+UseG1GC \
     -Xms2g -Xmx8g \
     -XX:MaxGCPauseMillis=200 \
     -XX:+UseCompactObjectHeaders \
     -XX:+ParallelRefProcEnabled \
     -jar your-app.jar

ZGC — low-latency microservice or large-heap service

ZGC low-latency configuration

java -XX:+UseZGC \
     -Xms4g -Xmx16g \
     -XX:+AlwaysPreTouch \
     -jar your-app.jar

Note: Generational ZGC is now the only ZGC mode since JDK 24. The flag -XX:+ZGenerational is no longer needed and was removed.

Shenandoah — memory-efficient low-latency (OpenJDK only)

Shenandoah generational production baseline

java -XX:+UseShenandoahGC \
     -XX:ShenandoahGCMode=generational \
     -Xms2g -Xmx12g \
     -jar your-app.jar

In JDK 25, you no longer need -XX:+UnlockExperimentalVMOptions for generational Shenandoah, it is production-ready and the experimental flag will cause a warning if included.

Parallel GC — throughput-first batch and worker jobs

Parallel GC throughput baseline

java -XX:+UseParallelGC \
     -Xms4g -Xmx16g \
     -XX:ParallelGCThreads=8 \
     -jar your-batch-job.jar

ParallelGCThreads defaults to a value derived from your available cores, but pinning it explicitly is worth doing in containerized environments where the JVM’s core-detection can be inaccurate. There is no low-latency flag to add here, the entire point of this collector is to trade pause time for throughput, so tuning it further mostly means sizing the heap and generation ratios correctly for your allocation pattern.

Before switching collectors in production, run a comparison on a staging environment under realistic load. Enable JFR to capture GC events: -XX:StartFlightRecording=filename=gc.jfr,settings=default. Then compare p99, p999, and p9999 latencies, GC CPU time, and RSS memory usage across both collectors side by side. One data point matters more than all the theory: how does it behave under your actual traffic pattern.

7. How to Measure GC Impact in Production

Choosing a collector is only half the work. Equally important is knowing how to verify, in production, that the collector you chose is actually behaving as expected. Fortunately, JDK 25 gives you excellent built-in tools for this.

The simplest starting point is GC logging. The JVM’s unified logging system makes this clean and parseable:

Enable structured GC logging

java -Xlog:gc*:file=gc.log:time,level,tags:filecount=5,filesize=20m \
     -jar your-app.jar

For deeper insight, Java Flight Recorder is the right tool. It records GC pause events, allocation rates, and CPU time with minimal overhead, typically under 1%:

Capture GC events with JFR

java -XX:StartFlightRecording=filename=app.jfr,settings=profile,duration=60s \
     -jar your-app.jar

# Then inspect pause events from the recording
jfr print --events jdk.GarbageCollection,jdk.G1GarbageCollection app.jfr

Once you have GC data flowing, the three numbers to watch closely are: average pause time (tells you typical behavior), maximum pause time (tells you worst-case user impact), and GC CPU overhead as a percentage of total application CPU (tells you how much the collector is costing you in throughput terms). Industry data suggests that 60% of Java performance issues and 45% of production incidents in distributed systems stem from suboptimal GC behavior, so this monitoring is not optional for any team that cares about reliability.

Additionally, keep an eye on your container’s RSS memory, not just the heap. Both ZGC and Shenandoah use off-heap native memory for their concurrent data structures, and that does not appear in heap metrics. The -XX:NativeMemoryTracking=summary flag surfaces this:

Track native memory usage (for container sizing)

java -XX:NativeMemoryTracking=summary -jar your-app.jar

# Then query it while the app is running (replace PID)
jcmd <PID> VM.native_memory summary

8. Quick Decision Guide

Still not sure which collector to start with? These questions narrow it down quickly. First: is anything waiting synchronously on this application’s response? If no, that is your strongest signal toward Parallel GC. If yes, then ask: does your application have a p99 or p999 SLA under 10ms, or is your heap above 32 GB? If the answer to either is yes, step away from G1 toward ZGC or Shenandoah. Otherwise, here is the split:

Move to ZGC or Shenandoah if…

  • You have a p999 latency SLA of 10ms or less
  • Your heap is 32 GB or larger
  • You run microservices and want consistent response times across all percentiles
  • You run on OpenJDK and want the most memory-efficient option (Shenandoah)
  • Your service operates in a low-CPU-overhead environment where ZGC can breathe

Stay on G1 if…

  • Your heap is under 16 GB and latency requirements are moderate
  • You want one collector configuration to cover most services in your fleet
  • You are in a memory-constrained Kubernetes pod but still need some interactivity
  • Your team has invested in G1 tuning and it is meeting current SLAs
  • You use Oracle JDK and do not want to manage non-standard configurations

Switch to Parallel GC if…

  • Nothing is waiting synchronously on the process, it is a batch job, worker node, or scheduled task
  • Total job completion time matters more than any individual pause
  • You want the lowest possible memory and CPU overhead for the GC itself
  • You are running Spark, Kafka Streams, or similar worker processes at scale, where shaving GC overhead across thousands of nodes adds up

9. What We Have Learned

The JVM garbage collection story in 2026 is not one of a single winner. Instead, all four collectors covered here have found their lane, and knowing which lane your application belongs in is what separates intentional JVM configuration from defaulting and hoping for the best.

G1 remains the right default for most user-facing teams: it is predictable, well-understood, and got real improvements in JDK 25 around remembered set memory and mixed GC pause spikes. However, it still produces stop-the-world pauses in the tens to hundreds of milliseconds range, which is a genuine problem for any service with tight tail latency requirements.

ZGC, now fully generational and with the non-generational mode removed, delivers sub-millisecond pauses regardless of heap size, but it demands 15–30% more memory and around 5–10% more CPU. It is the right call for high-value, latency-sensitive services where those resources are available.

Shenandoah, finally out of experimental status in JDK 25, hits a useful middle ground: lower memory overhead than ZGC thanks to compressed oops support, pause times typically under 5ms, and significantly improved throughput in generational mode. It deserves far more consideration than it currently gets, especially for OpenJDK-based deployments running on moderate-sized heaps.

And Parallel GC, the collector this comparison originally left out, is still the right tool the moment nothing is waiting synchronously on a response. It carries the lowest memory and CPU overhead of the four, converts the most CPU into actual application throughput, and its multi-second worst-case pauses are irrelevant to a batch job that runs overnight. Dismissing it as legacy misses the point: it was never trying to solve the latency problem the other three exist for, and for throughput-first workloads, nothing here beats it.

The most important takeaway is that none of this requires a deep JVM internals background to act on. Measure your current GC behavior with JFR, understand your heap size and latency requirements, decide whether anything is actually waiting on the response, and use the framework above. One afternoon of benchmarking on staging can easily justify a configuration change that delivers years of better production behavior.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

2 Comments
Oldest
Newest Most Voted
skin27
skin27
3 months ago

A nice comparison, but would be nice to include parallel GC as well, and provide a legend which color represents which GC.

Back to top button