nSkillHub
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage

JVM Garbage Collection: From Java 8 to 21

Garbage collection is one of those topics where “I let the JVM handle it” is a perfectly valid answer until it isn’t — and for EMs, that inflection point usually shows up as unexplained latency spikes in production, OOM kills in containers, or a team paralyzed by which GC flag to tweak. Here’s the full picture from Java 8 through 21.


The Baseline: Java 8 Collectors

Parallel GC (the Java 8 default)

Stop-the-world collection on both minor (young gen) and major (old gen) GCs. All available CPU cores run the collection in parallel — hence the name. Good for batch and throughput-oriented workloads where pause time doesn’t matter, terrible for latency-sensitive services.

If you’ve ever seen a Spring Boot service pause for 500ms–2s randomly, and the app has been running since Java 8 days, Parallel GC with a large old gen is almost certainly the culprit.

CMS (Concurrent Mark Sweep)

CMS was designed to solve Parallel GC’s pause problem by doing most of the marking concurrently with application threads. It worked — pauses dropped significantly — but at a cost:

  • Fragmentation: CMS didn’t compact the heap (no relocation). Over time, the old gen becomes fragmented, triggering a “concurrent mode failure” which falls back to a full stop-the-world compact — often worse than if you’d never used CMS.
  • Complexity: Tuning CMS required understanding initiating occupancy thresholds, incremental mode, and other knobs most teams didn’t have time to learn.
  • CPU overhead: Concurrent phases consume significant CPU alongside the application.

CMS was deprecated in Java 9 and removed in Java 14. If you’re still on it, that’s your migration trigger.

PermGen → Metaspace (Java 8)

PermGen was a fixed-size memory region (outside the heap) storing class metadata. The classic OutOfMemoryError: PermGen space showed up in large applications deploying many classloaders (app servers, OSGi, Groovy-heavy systems). Java 8 replaced it with Metaspace — native memory, grows dynamically. The OOM still happens, just with OutOfMemoryError: Metaspace instead, and is much rarer.


Java 9: G1 Becomes the Default

G1 (Garbage First) had been around since Java 7 but became the default in Java 9. It represents a fundamentally different approach: instead of a contiguous young/old gen layout, G1 divides the heap into equal-sized regions (~1–32MB each). Young and old generations are still logical concepts, but physically they’re sets of regions.

Why this matters:

  • G1 can predict and meet pause time targets (-XX:MaxGCPauseMillis=200). It achieves this by only collecting enough regions to stay within the pause budget.
  • Handles large heaps (10GB+) better than Parallel/CMS because it can work incrementally.
  • Compacts the heap during collection (no fragmentation like CMS).

G1’s weak spot: It’s a generational collector — throughput takes a hit compared to Parallel GC. For batch jobs or anything throughput-oriented, Parallel GC still wins on raw numbers.

For most Spring Boot services: G1 is the right default. It’s well-understood, has great tooling, and the 10–200ms pause targets are acceptable for typical microservice workloads.


Java 11: ZGC and Epsilon Enter

ZGC (Experimental in Java 11)

ZGC is designed around one constraint: pause times under 10ms regardless of heap size. It achieves this by doing almost all work concurrently with the application, including relocation (moving objects). The pause phases (initial mark, pause roots) are bounded and short.

How ZGC achieves this: Load barriers + colored pointers. Every reference read goes through a barrier that checks whether an object has been relocated. This has CPU overhead (~15% throughput cost in early versions), but pause times stay flat even on multi-terabyte heaps.

Java 11–14: Linux x86-64 only, experimental, no generational collection.

Epsilon GC

A no-op collector. It allocates memory but never frees it. The JVM will OOM once the heap is exhausted.

Sounds useless. It’s actually perfect for:

  • Performance benchmarking: Measure raw allocation rate and throughput without GC noise. Compare two algorithms? Run with Epsilon to eliminate GC variability.
  • Ultra-short-lived JVMs: Serverless functions, CLI tools that run for <1 second. If the JVM exits before the heap fills, you paid zero GC overhead.
  • Diagnosing GC impact: Run with Epsilon to see what your actual GC overhead is.

Java 14: CMS Removed, ZGC Goes Multi-Platform

CMS is gone entirely. Any codebase using -XX:+UseConcMarkSweepGC needs to migrate (G1 is the safe default).

ZGC becomes available on macOS and Windows (still experimental).


Java 15: ZGC and Shenandoah Go Production-Ready

Shenandoah GC

Developed by Red Hat, Shenandoah has similar goals to ZGC: sub-millisecond pauses, concurrent relocation. The implementation differs — Shenandoah uses forwarding pointers rather than colored pointers.

ZGC vs Shenandoah: Both aim for ultra-low pauses. Shenandoah tends to perform better on smaller heaps; ZGC on very large heaps. In practice, both are production-viable — your choice often comes down to which JVM distribution you’re running (Red Hat / OpenJ9 users often see Shenandoah in their ecosystem).

Both become non-experimental in Java 15.


Java 17: G1 and ZGC Improvements

  • ZGC gains dynamic scaling of GC threads (previously fixed count)
  • G1 improvements for better throughput and reduced native memory overhead
  • ZUncommit — ZGC can now return unused heap memory to the OS (important in containerized environments where memory limits are strict)

Java 21: Generational ZGC — The Big Deal

Before Java 21, ZGC collected the entire heap on every cycle. This was intentional (simpler, easier to get right), but had a cost: high throughput overhead because most objects die young and are being collected alongside long-lived objects.

Generational ZGC adds the standard generational hypothesis optimization — separate young/old generations — to ZGC’s concurrent, low-pause foundation. Result:

  • Young gen collections are fast and frequent (most objects die young)
  • Old gen is collected less often
  • Throughput overhead drops from ~15% to single digits
  • Pause times remain sub-millisecond

This removes the primary reason teams stayed on G1 instead of ZGC. You now get both low pauses and competitive throughput.

Enable it: -XX:+UseZGC -XX:+ZGenerational (Java 21), or set as default in a future release.


The Decision Tree: Which GC to Pick

Is this a batch job / ETL / throughput-only workload?
  YES → Parallel GC
  NO  ↓

Is this a standard Spring Boot / microservice?
  YES, Java < 21 → G1 (default, well-understood, good tooling)
  YES, Java 21+  → G1 or Generational ZGC (worth benchmarking)
  NO  ↓

Do you have hard p99 latency requirements (< 10ms GC pauses)?
  YES → ZGC (Java 21: Generational ZGC)
  Consider Shenandoah if on Red Hat / OpenJDK distro

Large heap (10GB+) with latency requirements?
  → ZGC is the clear winner; G1 pauses grow with heap size

Performance benchmarking / short-lived JVM?
  → Epsilon

EM-Level Interview Questions and How to Answer Them

“Your service has p99 latency spikes every few minutes. How do you diagnose?”

Enable GC logging: -Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=20m. Look for long stop-the-world pauses correlating with the latency spikes. Check allocation rate and promotion rate — if the old gen fills too fast, minor GCs promote too aggressively, leading to major GC pressure.

“When would increasing heap size make things worse?”

With non-concurrent collectors (Parallel GC), a larger heap means less frequent but longer GCs. If you’re already on a 16GB heap with G1, doubling to 32GB might push major GC pauses from 200ms to 400ms. With ZGC this is less of a concern — pause times don’t scale with heap size.

“Container memory limits and JVM heap — what’s the gotcha?”

The JVM, by default, sizes the heap based on total system memory. Inside a container with a 2GB limit, the JVM sees the host’s 64GB and sizes the heap to 16GB+ — instantly getting OOM-killed. Fix: -XX:MaxRAMPercentage=75 (Java 10+) or explicit -Xmx. Also, Metaspace, DirectByteBuffer, thread stacks, and JIT code cache all consume memory outside the heap — your container limit needs headroom for all of them.

“Virtual threads and GC — what’s the relationship?”

Virtual threads are cheap to create, which means applications can create millions of them. Each virtual thread has its own stack, which is heap-allocated in small chunks. This increases object allocation rate significantly. Generational collectors handle this well (short-lived stacks in young gen die quickly). This is partly why generational ZGC in Java 21 is so timely — the Loom era increases GC pressure, and generational collection is the right answer.


Quick Reference: GC Flags

# Enable G1 (default Java 9+)
-XX:+UseG1GC -XX:MaxGCPauseMillis=200

# Enable ZGC (Java 15+ production-ready)
-XX:+UseZGC

# Enable Generational ZGC (Java 21)
-XX:+UseZGC -XX:+ZGenerational

# Enable Shenandoah
-XX:+UseShenandoahGC

# Container-aware heap sizing
-XX:MaxRAMPercentage=75

# GC logging (essential in prod)
-Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=20m

# Diagnose virtual thread pinning
-Djdk.tracePinnedThreads=full