Skip to content
Jarviix

Tech · 6 min read

JVM Garbage Collection: G1, ZGC, and How to Pick One Without Reading 800 Pages of Spec

What the JVM garbage collectors actually do, the trade-offs between G1, ZGC, Shenandoah, and Parallel, and how to pick one for the workload you're actually running.

By Jarviix Engineering · Apr 19, 2026

Macro shot of green circuit board traces
Photo via Unsplash

Garbage collection is one of those JVM topics that engineers either ignore entirely or over-tune badly. The reality sits in the middle — defaults are good, but knowing what each collector does lets you make sensible choices when the defaults don't fit.

This post is a calm walk through what the GCs actually do, what they trade, and how to pick one for the workload you're running.

The mental model

Every GC has the same job: identify objects that are no longer reachable and reclaim their memory. They differ in how they do this — when they pause your application, how much memory they use as overhead, how they handle large heaps, and what guarantees they make about pause times.

The two big knobs they trade between:

  • Throughput. Total work done divided by total time, including GC time. Higher is better; means more application time per unit clock time.
  • Latency / pause time. Maximum (or P99) duration of any single GC pause. Lower is better for user-facing systems.

You can have one or the other; you can rarely have both.

Generational hypothesis

All modern GCs (G1, Parallel, even ZGC partially) exploit the generational hypothesis: most objects die young.

Think of it: an HTTP request handler creates dozens of short-lived objects (DTOs, strings, response builders) that all become garbage as soon as the response is sent. A small fraction of objects (caches, long-lived data) survive much longer.

Generational GC splits the heap:

  • Young generation. Eden + two survivor spaces. New allocations go here. Most objects die here, never promoted.
  • Old (tenured) generation. Objects that survived several young collections get promoted here. Collected less often, more expensively.

This makes the common case (collecting young garbage) very fast, because most of it is dead.

The collectors, briefly

Serial GC

Single-threaded, stop-the-world. Used historically and on tiny heaps.

Reach for it when you have a small JVM (a few hundred MB) where multi-threaded GC overhead isn't worth it. Embedded systems, small CLIs.

Parallel GC (a.k.a. Throughput Collector)

Multi-threaded young + old gen collections, stop-the-world.

Pros. Excellent throughput. Simple. Predictable.

Cons. Pause times grow with heap size — 10s of seconds on a 30 GB heap is normal during full GC.

Reach for it when you have a batch workload (offline analytics, ETL) where total throughput matters and pauses don't.

CMS (Concurrent Mark Sweep) — deprecated

Was the default low-pause collector for years. Replaced by G1. Don't use it on modern JDKs; it's removed in JDK 14+.

G1 (Garbage First) — the modern default

Default since Java 9. Designed for "soft" pause-time targets (you can ask for a target max pause).

How it works. Heap is divided into regions (~1-32 MB each). G1 tracks "garbage density" per region and collects the regions with the most garbage first ("garbage first") within your target pause time. Mostly concurrent, with short stop-the-world pauses.

Pros. Predictable pauses (typically 50-200ms). Handles large heaps (4 GB - several hundred GB). Defaults work for most workloads.

Cons. Higher CPU overhead than Parallel GC. Pauses can grow under heavy allocation pressure.

Reach for it when you have a typical backend application (a few GB to ~50 GB heap) and want decent pauses without thinking too hard.

ZGC (Z Garbage Collector)

Production-ready since JDK 15. Designed for extremely large heaps and ultra-low pauses.

How it works. Almost all GC work happens concurrently with the application. Stop-the-world pauses are typically under 1ms regardless of heap size.

Pros. Sub-10ms pauses on hundreds of GB heaps. Scales to TB-class heaps.

Cons. Higher CPU and memory overhead. Slightly less throughput than G1 in compute-bound workloads.

Reach for it when pauses must be predictably low (latency-sensitive APIs, low-latency trading, large in-memory caches), and you have CPU headroom to pay for it.

Shenandoah

Red Hat's competitor to ZGC. Also concurrent, also targets low pauses on large heaps. Available in OpenJDK builds.

Practical differences from ZGC are minor. Pick whichever your distribution supports best.

Epsilon

A no-op GC. Doesn't collect. The heap fills up and the JVM dies.

Reach for it when you're benchmarking GC overhead (compare with Epsilon to measure GC cost) or running short-lived JVM tasks where you'd rather just terminate than collect.

Picking a collector

A short flow:

  1. Default is fine for most web/backend services. G1 with no tuning.
  2. Need pauses under 10ms? ZGC or Shenandoah.
  3. Throughput-bound batch job? Parallel GC. You don't care about pauses; you want maximum work per second.
  4. Tiny JVM (< 1 GB)? Serial GC. Less overhead than the parallel collectors.
  5. Massive heap (100+ GB)? ZGC. G1 starts to struggle around 50-100 GB depending on allocation rate.

Switch with a single flag:

java -XX:+UseG1GC      # default in modern Java
java -XX:+UseZGC
java -XX:+UseShenandoahGC
java -XX:+UseParallelGC
java -XX:+UseSerialGC

Sizing the heap

The single biggest tuning knob isn't the GC — it's the heap size.

  • -Xms / -Xmx. Initial / maximum heap. Set them equal in production to avoid resize-induced pauses.
  • A good starting point. 50-70% of container/VM memory. Leave room for off-heap memory (direct buffers, native libraries, thread stacks).
  • Too small: constant GC pressure. App spends most of its time collecting.
  • Too large: longer GC pauses (G1) or higher overhead (ZGC).

Watch GC time as a fraction of total time. If it's above 5-10%, the heap is too small or the allocation rate is too high.

Tuning that actually matters

Most flags people copy from blog posts make things worse. The tuning that genuinely helps:

  • Set -Xms = -Xmx in production. Avoid resize pauses.
  • Set -XX:MaxGCPauseMillis=200 (or similar) on G1 to give it a target. Default is 200ms; set lower if you can tolerate the throughput cost.
  • Enable GC logs. -Xlog:gc*:file=gc.log:time,uptime (modern unified logging). Without logs, you're guessing.
  • Use -XX:+UseStringDeduplication with G1 if you have lots of duplicate strings (common in JSON-heavy services).

What to NOT do:

  • Random -XX:+SomeFlag from a Stack Overflow post. Most JVM flags are situational; the wrong ones make things slower or unstable.
  • Manually triggering System.gc(). The collector knows better than you do.
  • Tuning before measuring. Always have GC logs and a profile before you change anything.

What GC pauses actually look like

A typical G1 GC log entry:

[15.234s][info][gc] GC(42) Pause Young (Normal) (G1 Evacuation Pause)
                    1024M->256M(2048M) 23.5ms

Read: 23.5ms pause, freed 768 MB (1024 → 256), heap is 2 GB. Healthy.

What unhealthy looks like:

[15.234s][info][gc] GC(42) Pause Full (G1 Compaction Pause)
                    1948M->1700M(2048M) 4234ms

A multi-second full GC pause that didn't free much memory. Heap is too small for the allocation rate. Either grow the heap, reduce allocation, or move to a collector designed for the workload.

Three rules

  1. Measure, then tune. GC logs and profiling come first. Random flag tuning is how stable systems become unstable.
  2. The heap size matters more than the GC choice. Right-sizing the heap fixes 80% of GC problems before you change collectors.
  3. Keep allocation rates reasonable. The cheapest GC is the one that has nothing to collect. Object pooling, primitive types, and avoiding unnecessary intermediate allocations beat any collector tuning.

JVM tuning ties into the broader operational picture. Microservices observability covers how to actually watch GC behavior in production. Java multithreading is the companion read for high-allocation concurrent workloads where GC pressure shows up first.

Frequently asked questions

What's the default GC in modern Java?

G1 (Garbage First) since Java 9. It's a balanced choice for most workloads. ZGC and Shenandoah are opt-in for ultra-low-pause requirements.

Should I tune GC?

Default settings work for most apps. Tune when you have measured GC pauses that exceed your latency SLO, or when allocation rate is genuinely extreme. Random tuning makes things worse.

When does ZGC make sense?

Heaps in the tens of GB or larger, where pause times must stay under ~10ms. Trading-system back-ends, real-time analytics, large in-memory caches.

Related Jarviix tools

Read paired with the calculator that does the math.

Read next