in

Choosing a JVM Garbage Collector: G1, ZGC and Shenandoah

For most Spring Boot applications the default G1 is the right choice; but a p99 latency target, a heap above 32 GB or a 512 MB container changes the decision. We cover JVM memory regions, which workloads G1, ZGC, Shenandoah, Parallel and Serial win on, container memory settings and measuring with GC logs.

Comparison of the G1, ZGC and Shenandoah garbage collectors on pause time and throughput

The JVM's garbage collector (GC) is the most "tuned" and least understood component in enterprise applications. We see two extremes: a fifteen-flag JAVA_OPTS copied from a forum, and teams that don't remember the GC exists until the container gets OOMKilled. The truth is that for most Spring Boot applications the default G1 is the right choice; but an API with a p99 latency target, a heap above 32 GB or a 512 MB container changes the decision.

This article covers why JVM memory is more than the heap, which workloads G1, ZGC, Shenandoah, Parallel and Serial win on, container memory settings, measuring with GC logs and JFR, and the common mistakes we see in the field.

JVM memory is not just the heap

A container with a 2 GB limit, -Xmx2g set, and the application gets OOMKilled: that's the most common complaint we hear. The JVM's total memory use (RSS) is above the heap:

  • Heap: Objects; the young (eden + survivor) and old regions. -Xmx limits only this.

  • Metaspace: Class metadata; 100–200 MB is normal in Spring applications.

  • Code cache: JIT-compiled code; 50–240 MB.

  • Thread stacks: ~1 MB per platform thread; 200 Tomcat threads means 200 MB.

  • Direct/native memory: Netty buffers, file mappings, JDBC drivers, compression libraries.

  • The GC's own structures: 5–10% of the heap for G1, variable for ZGC.

Practical rule: the heap should be at most 70–75% of the container limit. A percentage instead of a fixed -Xmx scales the setting automatically when the container size changes:

# With a 2 GB container limit the heap ≈ 1.5 GB; the rest is for metaspace, threads and native memory
JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:InitialRAMPercentage=50 \
  -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps \
  -Xlog:gc*,gc+heap=info:file=/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=20m"

The JVM has been container-aware since JDK 10: it sees the cgroup limit as "total memory" and sizes GC threads by the CPU quota. A JVM that sees fewer than two CPUs or less than about 1.8 GB of memory doesn't consider itself a "server-class machine" and picks Serial GC — in small containers you may believe you're running G1 while actually running Serial; -Xlog:gc prints which GC was selected on its first line.

The collectors: what were they built for?

GC

Goal

Typical pause

Throughput cost

Memory overhead

Best fit

Serial

Simplicity, small heaps

Proportional to heap size; ms on small heaps

Single thread; negligible on small heaps

Lowest

<1 GB containers, CLI tools, sidecars

Parallel

Maximum throughput

Long (hundreds of ms – s), but rare

Lowest total CPU cost

Low

Batch jobs, ETL, nightly reports; latency irrelevant

G1 (default)

Balance of throughput and pause

Target 200 ms (tunable); usually 10–100 ms

Medium

Medium (5–10%)

Most web/API applications, 1–32 GB heaps

ZGC (generational)

Sub-millisecond pauses, large heaps

<1 ms, independent of heap size

Medium–high (concurrent work uses CPU)

High (needs headroom)

Latency-sensitive services, 16 GB+ heaps, p99 targets

Shenandoah

Low pause

ms range, independent of heap

Medium–high

Medium–high

Similar to ZGC; in OpenJDK distributions (not in Oracle JDK)

The difference between modern GCs in one sentence: Parallel says "stop and clean fast", G1 says "region by region, fit within the target time", ZGC and Shenandoah say "clean concurrently while barely stopping the application". Concurrency isn't free: the collector spends CPU alongside the application and needs free heap (headroom) to keep up with the application's allocation rate.

Selection guide

Situation

Recommendation

Why

Typical Spring Boot API, 2–8 GB heap, p99 < 300 ms target

G1 with default settings

Balanced; meets the target without tuning

p99 < 50 ms target, latency jitter unacceptable

Generational ZGC

Pauses independent of heap size; budget CPU and memory overhead

32 GB+ heap (caches, search, analytics)

ZGC

G1's pauses grow with the heap; ZGC's don't

Nightly batch, ETL, report generation

Parallel

Total time matters, pauses don't; lowest CPU cost

512 MB – 1 GB container, sidecar, small service

Serial (the JVM may pick it anyway)

Lowest memory cost for GC structures

Non-Oracle distribution, low pause, medium heap

Shenandoah or ZGC

Both low-pause; decide by measurement

# G1: pause target and string deduplication (when many copies of the same text exist)
-XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:+UseStringDeduplication

# Generational ZGC (JDK 21: via flag; JDK 23+: the default ZGC mode)
-XX:+UseZGC -XX:+ZGenerational -XX:SoftMaxHeapSize=6g

# Parallel: batch jobs
-XX:+UseParallelGC -XX:ParallelGCThreads=4

Don't decide without measuring

GC selection and tuning is a measurement problem; which flag helps only becomes visible under production-like load. Three sources are enough:

  • The GC log (-Xlog:gc*): pause durations, frequency, heap size before/after collection. Readable in minutes with tools like GCEasy or GCViewer. Look for: frequent old-gen (mixed/full) collections, a heap that doesn't shrink after collection (leak), humongous allocations on G1.

  • JFR (jcmd <pid> JFR.start duration=120s filename=recording.jfr): shows the allocation rate and which code produces the most objects. Most GC "problems" are actually allocation problems: N+1 queries, loading entities instead of DTOs, string concatenation in loops.

  • Micrometer metrics (jvm.gc.pause, jvm.memory.used, jvm.gc.memory.allocated): long-term trends and alerts in Grafana. If the allocation rate doubled after a release, the code changed, not the GC settings.

Common mistakes

  1. -Xmx = container limit. No room left for native memory; the result is OOMKilled with nothing in the heap dump.

  2. ZGC in a small container. The concurrent collector's headroom and CPU needs make it worse than G1 in a 1 GB container.

  3. Complaining about G1 pauses on a huge heap. On a 48 GB heap G1 pauses can approach a second; the fix is ZGC, not a flag.

  4. No GC log in production. No data when the problem hits; the log's cost is negligible, keep it always on.

  5. Forgetting off-heap. Netty, the Kafka client and compression libraries use memory outside the heap; -XX:MaxDirectMemorySize and native memory tracking (-XX:NativeMemoryTracking=summary) are needed.

  6. Tuning the GC instead of fixing the code. No GC rescues an application allocating 2 GB per second; reduce allocation first.

What changed with Java 21 and 25

  • Generational ZGC (opt-in in JDK 21, the default ZGC mode from 23): collecting young objects separately cut ZGC's CPU and memory cost noticeably, taking ZGC out of the "huge heaps only" category.

  • Virtual threads: Thread stacks now live on the heap; the memory impact of hundreds of thousands of virtual threads is measured through the GC, not the thread count.

  • Compact object headers (JDK 25, -XX:+UseCompactObjectHeaders): the object header drops from 12 bytes to 8; in applications dominated by small objects the heap shrinks 10–20% and cache usage improves. A free win; test it and turn it on.

Frequently asked questions

Can we go to production with default settings?

Yes, for most API applications: G1, MaxRAMPercentage=75, heap dump and GC log on. Every flag beyond that should correspond to a concrete problem shown by a measurement.

Why does a full GC happen, and how do I prevent it?

On G1, a full GC happens when concurrent collection can't keep up with the allocation rate or the heap is genuinely full. First rule out a leak (does the heap shrink after collection?), then reduce the allocation rate, and only then grow the heap or move to ZGC.

Is a bigger heap always better?

No. An unnecessarily large heap lengthens G1 pauses, raises container cost and hides leaks. Size the heap at 2–3 times the live data set after collection.

Conclusion

GC selection is a workload-profile question: G1 for balanced APIs, generational ZGC for latency-sensitive or huge-heap services, Parallel for batch, Serial for small containers. Heap at 75% of the container limit, GC log and heap dump always on, every setting backed by a measurement — these three rules prevent most GC-related production problems.

To measure your production application's memory and GC behavior and set up the right configuration, see our maintenance and support service.