From CMS to ZGC: How Java GC Pauses Dropped Below 1 ms
The article chronicles Java’s garbage‑collector evolution—from the deprecated CMS to G1’s pitfalls and the low‑latency ZGC and Shenandoah—explaining why CMS was removed, how G1 can degrade, and how ZGC/Shenandoah achieve sub‑millisecond pauses, plus new JDK 25/26 features and practical tuning steps.
Story: Full GC Disaster
During a 2021 Double‑11 promotion, a core order service using CMS on a 24 GB heap suffered a 15‑second Full GC, causing the entire cluster to time out. Switching to G1 with -XX:MaxGCPauseMillis=200 seemed to fix the latency, but three months later G1 degraded to Serial Old Full GC and paused for 12 seconds.
GC Evolution Overview
Java’s GC history is a trade‑off between throughput, latency, and memory usage. No collector is universally optimal; each fits specific scenarios.
Java 1.3‑8: Serial, Parallel, CMS (small heap, low concurrency)
Java 7+: G1 (region‑based, predicts pause, for large heaps)
Java 11+: ZGC (colored pointers, load barriers, sub‑ms pause)
Java 12+: Shenandoah (Brooks pointers, sub‑ms pause)
Java 11: Epsilon (no collection, for performance testing)
Only two of throughput, latency, and pause can be optimal at once; Parallel favors throughput, ZGC/Shenandoah favor latency, G1 tries to balance.
CMS: Why It Was Removed
CMS was deprecated in Java 9 and removed in Java 14 (JEP 363) because its design cannot compress the old generation. Fragmentation leads to "promotion failed" and fallback to Serial Old Full GC, as illustrated by the 15‑second pause example. Additional drawbacks include CPU contention during concurrent phases, risk of Concurrent Mode Failure, and complex tuning.
G1 GC: Common Pitfalls
G1 became the default server‑side collector in Java 9. It divides the heap into equal‑sized regions and tries to meet a target pause ( -XX:MaxGCPauseMillis), but the target is not a guarantee. Setting the pause target too low (e.g., <50 ms) forces frequent small GCs, dropping throughput dramatically and potentially causing "to‑space exhausted" and a fallback to Serial Old Full GC.
Never set -XX:MaxGCPauseMillis below 100 ms; 200 ms is a reasonable default.
G1 adds overhead; for heaps <4 GB, Parallel GC is usually faster.
Humongous objects that exceed half a region size go directly to the old generation, quickly filling it.
String deduplication ( -XX:+UseStringDeduplication) reduces GC time by ~5 % but adds ~3 % CPU overhead.
ZGC: Sub‑millisecond Pauses
ZGC was introduced as an experimental feature in Java 11 (JEP 333) and became production‑ready in Java 15 (JEP 377). Its goal is to keep pause times under 10 ms for any heap size, often below 1 ms in production.
Core Mechanisms
Colored Pointers : ZGC repurposes the high bits of a 64‑bit pointer to store metadata (mark state, relocation state). This lets the GC thread mark and relocate objects concurrently without stopping application threads.
Load Barriers : When an application reads a reference, the JVM inserts a lightweight check. If the object has been moved, the barrier returns the new address; otherwise it returns the original address. The overhead is nanoseconds‑level.
ZGC Evolution
Java 15: officially released.
Java 16: added concurrent thread‑stack handling (JEP 376).
Java 21: introduced generational ZGC (JEP 439), improving throughput.
Java 23: made generational ZGC the default (JEP 474); non‑generational ZGC deprecated.
Java 24: removed non‑generational ZGC entirely (JEP 490).
How to Enable ZGC
# JDK 21+ – enable generational ZGC (recommended)
java -XX:+UseZGC -XX:+ZGenerational ...
# JDK 23+ – generational ZGC is default, no extra flag needed
java -XX:+UseZGC ...In practice, ZGC requires almost no tuning; pause times stay below 1 ms and P99 latency remains stable even under heavy load.
Shenandoah GC: ZGC’s Competitor
Shenandoah also arrived as an experimental collector in Java 12 (JEP 189) and became production‑ready in Java 15 (JEP 379). It uses Brooks (forwarding) pointers instead of colored pointers, adding a pointer field to each object header.
Both ZGC and Shenandoah achieve sub‑millisecond pauses, but Shenandoah works on 32‑bit JVMs and does not require 64‑bit pointers, at the cost of a larger object header.
Choosing Between Them
JDK 21/23: prefer ZGC (generational ZGC is default and ecosystem is mature).
JDK 25: both are generational; performance differences are minimal.
Red Hat OpenJDK: Shenandoah receives better support.
JDK 25/26 New Features
Compact Object Headers (JEP 519)
On 64‑bit JVMs, the ordinary object header (12‑16 bytes) is compressed to 8 bytes, saving 33‑50 % of header space. For workloads with many small objects, memory usage can drop 15‑25 % (e.g., a 16 GB heap reduced to 12.5 GB). Enable with:
java -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders ...Note: this is still an experimental flag; verify compatibility with libraries that depend on header size.
AOT Object Caching (JEP 516)
Introduced in Java 26, this feature creates and caches objects during ahead‑of‑time compilation, reducing GC pressure at startup—especially valuable for serverless or frequently restarted containers.
Other Important Improvements
Metaspace replaces PermGen (Java 8) and becomes elastic in Java 16 (JEP 387), allowing unused memory to be returned to the OS.
Biased locking is disabled by default in Java 15 and removed in Java 18 (JEP 374, JEP 416) because modern workloads see higher contention and the removal improves performance.
Epsilon GC (JEP 318) provides a no‑collection mode useful for pure performance testing or short‑lived tasks.
GC Selection Guide
Rather than vague "choose by scenario", the article gives concrete recommendations:
Heap < 4 GB : use Parallel GC ( -XX:+UseParallelGC) for best throughput.
Heap 4‑16 GB, latency not critical : use G1 (default) with -XX:MaxGCPauseMillis≈200.
Heap > 16 GB or latency‑sensitive (P99 < 10 ms) : use ZGC (JDK 21+) or Shenandoah (JDK 15+). ZGC is preferred on newer JDKs.
Compute‑intensive short tasks : Parallel GC or Epsilon GC.
Still on CMS : upgrade immediately; it was removed in Java 14 and receives no security patches.
Unified Logging for GC
Java 9 introduced a unified logging framework (JEP 158/JEP 271). Replace old flags ( -XX:+PrintGCDetails -XX:+PrintGCDateStamps) with -Xlog for richer, consistent output.
# Print GC logs to console
java -Xlog:gc*:stdout:time,uptime,level,tags
# Rotate log files every 10 MB
java -Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=10,filesize=10m
# Show only pause times
java -Xlog:gc+pause*=infoConclusion
Key takeaways:
CMS is dead; its design flaw is uncompressible old‑generation fragmentation.
G1 is a balanced collector, not a low‑latency solution; avoid setting pause targets too aggressively.
ZGC and Shenandoah provide true low‑latency GC for large heaps; ZGC is the default on recent JDKs.
Compact object headers (JDK 25) can save 15‑25 % memory; AOT caching (JDK 26) speeds up startup in cloud‑native environments.
Adopt the unified -Xlog framework for clearer GC diagnostics.
After switching the example service to ZGC, the longest pause observed was 0.8 ms, eliminating GC‑related outages.
Three Immediate Actions
Inspect current GC logs for Full GC events longer than 1 s; if using CMS or a degraded G1, plan an upgrade to JDK 21+ with ZGC.
If on G1, ensure -XX:MaxGCPauseMillis is around 200 ms; values below 100 ms typically cause throughput loss.
Replace legacy GC flags with -Xlog, run a load test, and analyze the results with tools like GCEasy or GCViewer to understand pause distribution and GC overhead.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Tinker Programmer
Solving problems with code, sharing practical tech insights, and leveling up together!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
