Garbage Collection Algorithms Explained: Serial, Parallel, G1, ZGC, and Shenandoah
One of Java's defining promises is that you don’t manage memory by hand. You allocate objects; the JVM reclaims them when they’re no longer…
Garbage Collection Algorithms Explained: Serial, Parallel, G1, ZGC, and Shenandoah
One of Java's defining promises is that you don’t manage memory by hand. You allocate objects; the JVM reclaims them when they’re no longer reachable. That convenience hides an entire subsystem — the garbage collector (GC) — that quietly decides your application’s pause times, throughput, and memory footprint.
For most apps the defaults are fine. But the moment you care about p99 latency, large heaps, or maximizing batch throughput, you need to understand how GC works and which collector fits your workload. This article walks through the mechanics — reachability, generational collection, stop-the-world vs concurrent — then compares the five HotSpot collectors and gives concrete guidance and flags for choosing among them.
What “Garbage” Actually Means
A garbage collector reclaims objects that the program can no longer use. The definition of “can no longer use” is reachability.
The GC starts from a set of GC roots — references it knows are live:
- Local variables and parameters on every thread’s stack
- Static fields of loaded classes
- Active JNI references
- Live threads themselves
From these roots it traces every reference, transitively. Any object reachable through some chain from a root is live. Everything else is garbage, regardless of whether other garbage objects still point to it.
GC Roots
┌──────────┐
│ stack │──► A ──► B ──► C (live: reachable from root)
│ statics │──► D
└──────────┘
E ──► F ──► E (garbage: cyclic but unreachable)
This is the key reason Java doesn’t suffer from the reference-counting problem of leaking cycles: E and F reference each other, but since no root reaches them, both are collected.
The Generational Hypothesis
Decades of empirical study produced the weak generational hypothesis:
Most objects die young. The longer an object survives, the longer it’s likely to keep surviving.
Think of the typical request handler: it creates strings, DTOs, and intermediate collections that become garbage the instant the request finishes. A small fraction of objects (caches, connection pools, session state) live a long time.
Generational collectors exploit this by splitting the heap:
┌──────────────────────── HEAP ───────────────────────┐
│ YOUNG GENERATION OLD GENERATION │
│ ┌────────┬──────┬──────┐ ┌──────────────────┐ │
│ │ Eden │ S0 │ S1 │ │ Tenured │ │
│ └────────┴──────┴──────┘ └──────────────────┘ │
│ ^allocate ^survivor │
└─────────────────────────────────────────────────────┘
- Eden: where new objects are allocated. Fast — usually just a pointer bump.
- Survivor spaces (S0/S1): objects that survive a young collection are copied here.
- Old/Tenured: objects that survive enough young collections get promoted (tenured) here.
A minor GC collects only the young generation. Because most young objects are dead, the GC only copies the few survivors — collecting a mostly-dead space is cheap. A major/full GC touches the old generation and is far more expensive.
This is why generational collection is so effective: frequent, cheap minor GCs handle the bulk of garbage, and slow old-gen work happens rarely.
Core Collection Techniques
Underneath the generations, collectors combine a few primitives:
- Mark-Sweep: mark all reachable objects, then sweep (free) the unmarked ones. Simple, but leaves fragmentation.
- Mark-Compact: after marking, slide live objects together to eliminate fragmentation. More work, but allocation stays cheap (bump pointer).
- Copying (Scavenge): copy live objects from one space to another; the source space becomes entirely free. Ideal for young gen where survivors are few.
Stop-the-World vs Concurrent
A stop-the-world (STW) pause halts all application threads while the GC does its work. Pauses are simple and correct but visible as latency spikes. A 2-second full GC pause on a trading system is catastrophic.
A concurrent collector does much of its work while application threads keep running. This trades raw throughput (the GC competes for CPU) and complexity for dramatically shorter pauses. The challenge: the object graph changes underneath the collector as it traces. Concurrent collectors solve this with write barriers (and sometimes load/read barriers) — small bits of code injected around field reads/writes that let the GC track mutations, often via the tri-color marking invariant (white = unvisited, gray = visited but children pending, black = fully scanned).
No collector is fully pause-free; even ZGC and Shenandoah keep a few short STW phases (e.g. root scanning). The goal is to make those pauses sub-millisecond and independent of heap size.
The Five HotSpot Collectors
Serial GC
The simplest collector: a single thread does everything, STW, for both young (copying) and old (mark-compact) generations.
- Strengths: tiny footprint, no thread coordination overhead, very predictable on small heaps.
- Weaknesses: pauses scale with heap size; useless for multi-core throughput.
- Best for: small heaps (under ~100 MB), single-core or container environments, CLI tools, embedded.
-XX:+UseSerialGC
Parallel GC (Throughput Collector)
Like Serial, but uses multiple threads for both young and old collections. Still fully STW, but it crunches through collection fast by parallelizing. It optimizes for total throughput — maximizing time spent in application code vs GC — not for short individual pauses.
- Strengths: highest throughput, great for batch jobs that can tolerate occasional longer pauses.
- Weaknesses: pauses can be long (hundreds of ms to seconds on large heaps).
- Best for: batch processing, analytics, anything where throughput matters more than latency.
-XX:+UseParallelGC
-XX:MaxGCPauseMillis=200 # soft goal; GC sizes generations to try to meet it
-XX:GCTimeRatio=99 # throughput goal: 1/(1+99) = 1% time in GC
G1 GC (Garbage-First)
The default since Java 9. G1 divides the heap into many equal-sized regions (typically 1–32 MB), and each region is dynamically tagged Eden, Survivor, Old, or Humongous (for very large objects). It’s still generational and largely STW, but the pauses are bounded and incremental.
G1 HEAP = grid of regions, roles assigned dynamically
┌──┬──┬──┬──┬──┬──┬──┬──┐
│E │O │S │E │O │E │H │O │ E=Eden S=Survivor
├──┼──┼──┼──┼──┼──┼──┼──┤ O=Old H=Humongous
│O │E │E │O │S │O │E │O │
└──┴──┴──┴──┴──┴──┴──┴──┘
G1’s trick: it tracks how much garbage is in each region and collects the regions with the most garbage first (hence “garbage-first”). It targets a pause-time goal and collects only as many regions as it can within that budget. Marking of the old generation happens concurrently; the actual evacuation (copying live objects, compacting) is STW but incremental.
- Strengths: good balance of throughput and predictable pauses; compacts so no fragmentation; scales to multi-GB heaps.
- Weaknesses: more bookkeeping overhead (remembered sets, write barriers); pauses in the tens-to-low-hundreds of ms, not sub-ms.
- Best for: general-purpose server apps, the sensible default for heaps from ~4 GB to tens of GB.
-XX:+UseG1GC # (default; explicit for clarity)
-XX:MaxGCPauseMillis=200 # target pause time (default 200ms)
-XX:G1HeapRegionSize=8m # override auto-sized region
-XX:InitiatingHeapOccupancyPercent=45 # when to start concurrent marking
ZGC
A concurrent, region-based collector designed for very low pause times that stay flat regardless of heap size. ZGC does virtually all work — marking, relocation, reference processing — concurrently, using colored pointers and load barriers. Pauses are sub-millisecond, typically well under 1 ms, even on multi-terabyte heaps.
ZGC became production-ready in Java 15. Since Java 21 it has a generational mode (-XX:+ZGenerational, the default in Java 23+), which adds a young/old split for far better efficiency on typical workloads.
- Strengths: sub-ms pauses independent of heap size (tested to 16 TB); concurrent compaction.
- Weaknesses: higher CPU and memory overhead than G1; throughput slightly lower; non-generational mode churns CPU on allocation-heavy apps.
- Best for: large heaps + strict latency SLAs (trading, real-time bidding, low-latency services).
-XX:+UseZGC
-XX:+ZGenerational # generational mode (default Java 23+)
-Xmx16g
Shenandoah
Like ZGC, a concurrent compacting collector aimed at low, heap-size-independent pauses. Developed by Red Hat, it uses load reference barriers and (historically) Brooks forwarding pointers to relocate objects concurrently. Available in OpenJDK builds; pauses are typically a few milliseconds or less.
- Strengths: low pauses independent of heap size; works well across a broad heap range; available in many OpenJDK distributions.
- Weaknesses: barrier overhead reduces throughput vs Parallel/G1; not in every JDK build (notably absent from Oracle’s JDK).
- Best for: latency-sensitive apps where you want low pauses but ZGC isn’t available or preferred.
-XX:+UseShenandoahGC
Comparison at a Glance
Collector Threads Pause model Compacts? Pause time Throughput Heap sweet spot Serial single STW yes scales w/ heap low < 100 MB Parallel multiple STW yes high highest up to ~8 GB G1 multiple mostly STW, concurrent mark yes (incremental) tens–low 100s ms high ~4 GB — tens of GB ZGC multiple concurrent yes (concurrent) sub-ms medium-high up to multi-TB Shenandoah multiple concurrent yes (concurrent) low ms medium-high wide range
The Latency vs Throughput Trade-off
You fundamentally cannot maximize both. The relationship:
- Throughput collectors (Parallel) do all work in big STW bursts. CPU goes entirely to either the app or the GC, never split, so total useful work is high — but pauses are long.
- Low-latency collectors (ZGC, Shenandoah) spread GC work concurrently, so the app barely stops — but the GC steals CPU from the app continuously and runs extra barrier code, lowering peak throughput.
Throughput ◄──────────────────────────► Latency
Parallel G1 Shenandoah / ZGC
(long pauses, (tiny pauses,
max work) more overhead)
Pick based on what your users feel. A nightly ETL job cares about finishing fast → Parallel. A user-facing API with a p99 SLA cares about never stalling → G1 or ZGC.
Reading GC Behavior
You can’t tune what you can’t see. Enable unified GC logging (Java 9+):
-Xlog:gc*:file=gc.log:time,uptime,level,tags
A G1 minor collection line looks like:
[2.345s][info][gc] GC(12) Pause Young (Normal) (G1 Evacuation Pause)
512M->48M(1024M) 6.123ms
Read it as: GC #12 was a young pause; heap went from 512 MB used to 48 MB used out of a 1024 MB total, taking 6.1 ms. Things to watch:
- Pause times — are p99 pauses within your SLA?
- Promotion / heap-after-GC trend — steadily rising old-gen after every full GC suggests a leak.
- Allocation rate — high churn drives frequent minor GCs; reduce allocations or grow Eden.
- Full GC frequency — frequent full GCs on G1/ZGC usually means the heap is too small or marking starts too late.
Useful diagnostics and key sizing flags:
-Xms4g -Xmx4g # fix heap to avoid resize pauses
-XX:+HeapDumpOnOutOfMemoryError # capture a dump on OOM
-XX:NewRatio=2 # old:young ratio (Parallel/Serial)
-XX:SurvivorRatio=8 # eden:survivor ratio
jcmd <pid> GC.heap_info # live heap stats
jstat -gcutil <pid> 1000 # GC utilization every second
How to Choose: A Practical Decision Path
- Small heap / container with little memory? → Serial GC. Lowest overhead.
- Batch / offline job, throughput is king, pauses tolerable? → Parallel GC.
- General server app, balanced needs? → G1 (the default). Set a
MaxGCPauseMillisgoal and move on. - Large heap and strict latency SLA (p99 in single-digit ms)? → ZGC (generational). Shenandoah if ZGC isn’t available in your JDK.
- Unsure? Start with G1, measure with GC logs, and only switch if data shows a real pause or throughput problem.
Measure before and after every change. Synthetic benchmarks lie; your production allocation profile is the only ground truth.
Pitfalls and Gotchas
- Don’t pick a collector by reputation. “ZGC has the lowest pauses” doesn’t help a batch job — you’d lose throughput for a latency benefit you don’t need.
**System.gc()is almost always wrong.** It can trigger an expensive full GC. Disable surprises with-XX:+DisableExplicitGCif a dependency abuses it.- A too-small heap hurts more than the collector choice. Frequent full GCs and
OutOfMemoryError: GC overhead limit exceededusually mean undersized-Xmx, not a bad collector. **MaxGCPauseMillisis a goal, not a guarantee.** Setting it absurdly low (e.g. 5 ms) on G1 makes the GC collect tiny slices constantly, tanking throughput and often missing the goal anyway.- GC can’t fix a leak. Ever-growing old gen across full GCs means you’re holding references (static caches, listeners,
ThreadLocals). Take a heap dump. - Set
-Xms == -Xmxin production. Letting the heap grow on demand causes resize pauses and gives the GC less to work with early on. - Watch humongous allocations on G1. Objects larger than half a region go straight to special “humongous” regions and can fragment the old gen; size regions appropriately or avoid giant arrays.
- Container awareness matters. Modern JVMs respect cgroup limits, but verify
-XX:MaxRAMPercentageis sane so the heap doesn't get OOM-killed by the container.
Wrapping Up
Garbage collection is the JVM tracing reachable objects from roots and reclaiming the rest, leaning on the generational hypothesis to keep the common case cheap. The collectors form a spectrum:
- Serial / Parallel — simple, STW, throughput-oriented.
- G1 — the balanced default, region-based with bounded pauses.
- ZGC / Shenandoah — concurrent, compacting, sub-millisecond pauses at the cost of throughput.
Understand your workload’s tolerance for pauses versus its hunger for throughput, enable GC logging, and let measurements — not folklore — drive your choice.
Enjoyed the deep dive? Follow devdomain for more JVM internals and performance content. What collector are you running in prod, and why? Tell us in the comments.
메타데이터
- post_id
- e4ec5e2fde09
- slug
- garbage-collection-algorithms-explained-serial-parallel-g1-zgc-and-shenandoah-e4ec5e2fde09
- url
- https://medium.com/devdomain/garbage-collection-algorithms-explained-serial-parallel-g1-zgc-and-shenandoah-e4ec5e2fde09
- canonical_url
- https://medium.com/devdomain/garbage-collection-algorithms-explained-serial-parallel-g1-zgc-and-shenandoah-e4ec5e2fde09
- author_url
- https://medium.com/@marcelogdomingues
- status
- ok
- fetched_at
- 2026-07-11 01:06:15