Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Garbage Collectors: Serial, Parallel, G1, ZGC, Shenandoah

How each collector works, the throughput-versus-latency trade-off that separates them, what G1 regions and pause targets really do, and how ZGC achieves sub-millisecond pauses on huge heaps.

Advanced6 min readUpdated
On this page

Every collector makes the same trade: shorter pauses cost throughput, because concurrent work requires coordination with running application threads. Understanding that one axis explains the entire lineup.

Key Takeaways

  • All tracing collectors do the same job: find what is reachable, reclaim the rest. They differ in when they stop your threads.
  • Parallel maximises throughput with long pauses. G1 balances. ZGC and Shenandoah minimise pause time at a throughput cost.
  • G1 divides the heap into regions and collects the ones with most garbage first — "garbage first".
  • ZGC uses coloured pointers and load barriers to relocate objects while threads run; pauses stay under a millisecond regardless of heap size.
  • MaxGCPauseMillis is a target, and setting it too low actively harms both throughput and latency.

The common foundation

Every JVM collector is a tracing collector. It starts from GC roots — thread stacks, static fields, JNI references — marks everything reachable, and treats the rest as garbage. There is no reference counting, which is why circular references are collected without special handling.

Three mechanical strategies get combined in different ways:

Mark-sweep marks live objects and frees the rest in place. Fast, but leaves the heap fragmented.

Mark-compact additionally slides survivors together, eliminating fragmentation and making allocation a pointer bump. Slower, because everything moves and every reference must be updated.

Copying moves live objects into a fresh space and discards the old one wholesale. Cost is proportional to the live set, not the heap size — which is exactly why it suits the young generation, where almost everything is dead.

The lineup

CollectorFlagPauseThroughputBest for
Serial-XX:+UseSerialGCLongGood on 1 coreSmall heaps, single-CPU containers
Parallel-XX:+UseParallelGCLong (100ms–seconds)HighestBatch jobs, ETL
G1-XX:+UseG1GC (default)Moderate (~50–200ms)GoodMost server applications
ZGC-XX:+UseZGCUnder 1ms~10–15% lowerLatency-critical, huge heaps
Shenandoah-XX:+UseShenandoahGCUnder 10ms~10% lowerLatency-critical, OpenJDK

Serial uses one thread and stops the world for everything. That sounds obsolete and is not: in a container limited to one CPU, parallel collectors have no other core to run on, and their coordination overhead makes them slower than Serial. For a small sidecar or a function-style workload, Serial plus a small heap is often the fastest configuration.

Parallel is Serial with multiple GC threads. It still stops the world completely, but it finishes faster and does the least total work per byte reclaimed, which is why it still holds the throughput crown. For an overnight batch job where nobody is waiting on a response, it is the correct choice.

G1

G1 divides the heap into 1–32MB regions, each dynamically labelled eden, survivor, old or humongous. It is generational and it compacts, but it never collects the whole old generation at once.

G1 collects the regions with the highest garbage ratio first — hence the name — so a bounded amount of work reclaims most of the space.

The cycle is: young collections until old-generation occupancy crosses InitiatingHeapOccupancyPercent (45% by default), then a concurrent mark to find out which old regions are mostly garbage, then a series of mixed collections that each clean the young generation plus a few of the worst old regions.

MaxGCPauseMillis (200ms by default) drives everything: G1 measures how long collecting a region takes and picks how many regions fit in the budget. Setting it to 10ms does not produce 10ms pauses — it produces a tiny young generation, extremely frequent collections, high promotion rates and worse latency overall. This is the single most common G1 misconfiguration.

ZGC and Shenandoah

Both do essentially all of their work concurrently, including relocation — moving objects while application threads read and write them. That is the hard part, and each solves it differently.

ZGC stores metadata in unused bits of 64-bit pointers (coloured pointers) and installs a load barrier: every reference read checks the colour, and if the object has been relocated the barrier fixes the pointer on the spot. Pause times are bounded by the root-scanning work, which does not grow with heap size — so ZGC holds sub-millisecond pauses on a 16TB heap as easily as on a 16GB one.

Shenandoah uses a Brooks forwarding pointer — an extra word in each object header pointing at its current location — with a read barrier that follows it. Similar outcome, different mechanism.

The cost of both is roughly 10–15% throughput, from executing barrier code on essentially every reference access, plus higher memory overhead. Generational ZGC (-XX:+ZGenerational, Java 21, the default from 23) recovers much of that by applying the generational hypothesis, and is the version to reach for.

Choosing

a decision procedure
# 1. Heap under ~4GB, one or two CPUs (a small container)
-XX:+UseSerialGC
 
# 2. Batch job, throughput is everything, pauses irrelevant
-XX:+UseParallelGC
 
# 3. Anything else — the default, and correct for most services
-XX:+UseG1GC -XX:MaxGCPauseMillis=200
 
# 4. p99 latency matters more than throughput, or heap > 32GB
-XX:+UseZGC -XX:+ZGenerational

Two practical notes. Crossing 32GB of heap disables compressed oops, so references grow from 4 to 8 bytes and effective capacity drops — a 32GB heap can hold less live data than a 31GB one. And the collector is rarely the first thing to tune: excessive allocation is usually the real problem, and no collector fixes an application producing a gigabyte of garbage per second.

Measuring the difference

the comparison worth running
-Xlog:gc*:file=gc.log:time,uptime,level,tags -Xmx4g -XX:+UseG1GC

Run your real workload under each candidate and compare three numbers: throughput (percentage of wall-clock time not spent in GC — above 95% is healthy), p99 pause time, and allocation rate. Synthetic benchmarks mislead badly here, because collector behaviour depends entirely on the shape of your object graph and lifetimes.

Reading those logs is covered in GC logs and tuning.

What gets asked

"What garbage collectors do you know and when would you use each?" — answer along the throughput/latency axis rather than listing names. Then: how G1 differs from Parallel (regions, concurrent marking, partial old collections); what MaxGCPauseMillis does and why setting it too low backfires; and how ZGC achieves sub-millisecond pauses. Mentioning the 32GB compressed-oops cliff unprompted is a strong signal.

Frequently Asked Questions

Which garbage collector should I use?
G1 is the default from Java 9 and the right answer for most server applications with heaps from about 4GB to 32GB. Use Parallel when you want maximum throughput in a batch job and do not care about pause length. Use ZGC when p99 latency matters more than throughput, or when the heap is very large — it holds sub-millisecond pauses at hundreds of gigabytes. Serial is for containers with a single core and a small heap, where it genuinely wins.
What is a stop-the-world pause?
A point where every application thread is suspended so the collector can work on a stable view of the heap. Threads stop at safepoints, which the JIT inserts at method returns and loop back-edges, so stopping is not instantaneous. Every collector has some stop-the-world phases; the modern ones make them short and independent of heap size by doing the bulk of the work concurrently.
Does MaxGCPauseMillis guarantee a maximum pause?
No, it is a target, not a promise. G1 uses it to size the young generation and decide how many regions to collect in each cycle. Setting it very low — say 10ms — makes G1 shrink the young generation so aggressively that collections become extremely frequent, throughput collapses, and the target still gets missed. Between 100 and 200ms is a realistic starting point.

Related tutorials