Java garbage collectors in 2026: revisit G1, ZGC and Shenandoah before you add another gigabyte of heap

A payments API starts showing slow requests every few minutes. The dashboard shows p99 latency jumping from tens of milliseconds to over a second, and someone finds a GC pause in the logs. The quick fix is familiar: raise -Xmx from 2 GB to 3 GB, copy a few flags from an old blog post, and redeploy. The spikes go away for a week. Then the pod gets killed by Kubernetes for using too much memory, because the container limit never changed, and the team is back where it started with a bigger bill.

Most Java pause problems get “fixed” this way. The real work is less exciting: measure what the collector is doing with GC logs and JDK Flight Recorder, choose the collector that fits the workload and its container, and check again after every LTS upgrade, because the defaults keep moving.

This post revisits G1, ZGC and Shenandoah, and where the older Parallel and Serial collectors still fit. Versions and JEP numbers come from openjdk.org and vendor documentation, listed at the end.

The short version

  • G1 (Garbage-First): the default collector in HotSpot since JDK 9. It balances throughput and pause time, works on a soft pause goal (200 ms by default) and collects the heap region by region. From JDK 27 it is the default in every environment, including small containers where the JVM used to pick Serial. Best fit for most services when you do not yet know what you need.
  • ZGC: a concurrent collector designed to keep pauses under a millisecond, independent of heap size, from a few hundred megabytes to terabytes. Since JDK 24 it is generational only. It trades some CPU and memory headroom for very short pauses. Best fit for latency-sensitive services with spare CPU.
  • Shenandoah: another concurrent, low-pause collector that compacts the heap while the application runs. Its generational mode became a product feature in JDK 25. It is included in OpenJDK builds from Red Hat, Eclipse Temurin, Amazon Corretto, Azul Zulu and SapMachine, but Oracle does not ship it in its builds. Best fit for low-pause needs when your JDK vendor includes and supports it.
  • Parallel: the throughput collector. Pauses are stop-the-world and grow with live data, but total CPU spent on GC is low. Still a good fit for batch jobs, ETL and offline processing where nobody waits on a single request.
  • Serial: a single-threaded collector with the smallest footprint. Still useful for tiny heaps, command line tools and single-CPU containers, but from JDK 27 you must choose it explicitly.

What job is the collector actually doing

Every collector balances four things, and no collector wins all four.

  • Throughput. How much CPU time goes to the application instead of the collector.
  • Latency. How long and how often application threads are paused.
  • Footprint. How much memory beyond the live data the collector needs to work comfortably.
  • Startup and warmup. How quickly the JVM reaches its first response and then its peak speed.

A nightly billing job cares about throughput, a payments API about tail latency, a small sidecar about footprint, and a service that scales out all day about startup. The right collector depends on which of these hurts you, and you only know that by measuring.

A short history of the collectors

G1

G1 became the default collector for server-class machines in JDK 9 through JEP 248. In the same release, the CMS collector was deprecated (JEP 291), and CMS was later removed in JDK 14 (JEP 363). Since then G1 has been improved in almost every release:

  • JDK 10: parallel full GC for G1 (JEP 307), so the worst case was no longer single-threaded.
  • JDK 12: abortable mixed collections (JEP 344) and promptly returning unused committed memory to the operating system (JEP 346), which matters in containers.
  • JDK 22: region pinning (JEP 423), so JNI critical regions no longer have to disable garbage collection.
  • JDK 24: late barrier expansion for G1 (JEP 475), a JIT compiler change that simplifies G1’s barrier code.
  • JDK 26: reduced synchronization between application and GC threads (JEP 522), using a second card table.
  • JDK 27: G1 becomes the default in all environments (JEP 523).

ZGC

ZGC arrived as an experimental feature in JDK 11 (JEP 333) and became production ready in JDK 15 (JEP 377). Generational ZGC came in JDK 21 (JEP 439) as an opt-in mode. JDK 23 made the generational mode the default (JEP 474), and JDK 24 removed the non-generational mode entirely (JEP 490). On JDK 25, -XX:-ZGenerational only prints a warning that the option is ignored. On JDK 27 the option has expired, and a JVM started with it refuses to start.

Shenandoah

Shenandoah arrived as an experimental collector in JDK 12 (JEP 189) and became a product feature in JDK 15 (JEP 379). It was also contributed to OpenJDK 11u, and a downstream backport exists for 8u. Generational Shenandoah was added as experimental in JDK 24 (JEP 404) and became a product feature in JDK 25 (JEP 521). In JDK 25 the default mode is still single generation; you turn on the generational mode with -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational.

Parallel and Serial

Both are the oldest HotSpot collectors and both are still maintained. JDK 14 deprecated only the odd mix of Parallel young with Serial old (JEP 366), and JEP 523 says plainly that it does not deprecate or remove any collector.

Why revisit this in 2026

Four changes, spread across JDK 25, JDK 26 and JDK 27, make an old GC setup worth checking again.

First, JDK 25 is the current LTS release, out since 16 September 2025, and many teams are moving to it from JDK 17 or 21 in 2026. Between JDK 21 and JDK 25, ZGC became generational only, Shenandoah gained a product generational mode, and compact object headers became a product feature (JEP 519). Compact object headers shrink object headers from 96 bits to 64 bits on 64-bit platforms, which means less heap for the same data. In JDK 25 they are opt-in with -XX:+UseCompactObjectHeaders.

Second, JDK 26, released on 17 March 2026, changed heap sizing defaults. The default value of InitialRAMPercentage was removed, so if you do not set -Xms, the initial heap now starts at the minimum heap size instead of 1/64 of physical memory. The MaxRAM flag was deprecated. JDK 26 also brought the G1 synchronization work (JEP 522) and let the ahead-of-time cache work with any collector, including ZGC (JEP 516), so you no longer choose between ZGC and faster startup.

Third, JDK 27, released on 15 September 2026, made G1 the default everywhere (JEP 523) and turned compact object headers on by default (JEP 534). It also made MaxRAM obsolete; the JVM now ignores it with a warning. Before JDK 27, a JVM that saw one CPU or less than 1792 MB of memory picked Serial; JEP 523 says G1 is now competitive with Serial at all heap sizes. JEP 534 quotes OpenJDK’s own runs where SPECjbb2015 used 22% less heap and 8% less CPU in one setting with compact headers. Treat those as the project’s own numbers, not a promise for your service.

Fourth, more change is already queued for JDK 28, which is scheduled for 9 March 2027 and is in early access today. JEP 535 (targeted) makes generational mode the Shenandoah default and deprecates the single-generation mode. JEP 545 and JEP 546 (both integrated into JDK 28 builds) aim to give ZGC faster startup and adaptive heap sizing, with the stated goal that you will not need -Xms, -Xmx or -XX:+AlwaysPreTouch with ZGC. An automatic heap sizing proposal for G1 is only a JEP draft. None of this is in an LTS release yet, so plan for it but do not depend on it.

G1, ZGC and Shenandoah side by side

Pause behaviour

  • G1: works towards a soft pause goal, 200 ms by default (MaxGCPauseMillis). Its stop-the-world collections are incremental, so it avoids full collections in normal operation, but they still happen when the heap is too small for the live data.
  • ZGC: does marking, relocation and reference updating concurrently. JEP 439 states the goal that pauses should not exceed one millisecond, independent of heap size.
  • Shenandoah: also compacts concurrently, so pause times are not directly tied to heap size. If the application allocates faster than Shenandoah can collect, it falls back to a “degenerated” cycle or a full GC, which shows up clearly in its logs.
  • Parallel and Serial: all collection work happens in stop-the-world pauses, which grow with live data.

CPU and throughput cost

  • G1: moderate overhead from write barriers and concurrent marking. JEP 522 reports OpenJDK’s own measurements of 5 to 15% throughput gains in applications that heavily update references, and smaller gains elsewhere, from JDK 26.
  • ZGC and Shenandoah: GC threads run alongside your threads, so they need spare CPU. In a container with a tight CPU limit, concurrent collectors compete with the application for the same cores.
  • Parallel: usually the lowest total GC CPU cost, paid for in longer pauses.

Memory headroom

  • G1: works well with moderate headroom, and returns unused memory to the operating system.
  • ZGC: uses as much of the maximum heap as it can, because a bigger heap means fewer cycles (JEP 546 explains this). Leave room for non-heap memory in the container.
  • Shenandoah: needs headroom so that concurrent cycles can finish before the heap fills. Too little headroom means degenerated cycles.

Availability in your JDK

  • G1, Parallel and Serial: in every HotSpot build.
  • ZGC: in OpenJDK builds on the main platforms, including Oracle’s builds.
  • Shenandoah: depends on the vendor. The OpenJDK Shenandoah wiki lists Red Hat, Amazon Corretto, Azul Zulu, SapMachine and Adoptium (now Eclipse Temurin) builds as shipping it, and notes that Oracle does not ship it in either its OpenJDK builds or its commercial builds. Red Hat documents that its build of OpenJDK includes Shenandoah in all versions. If your base image uses Oracle’s builds, -XX:+UseShenandoahGC fails at startup.

Measure first: GC logs and JFR

Before you change any flag, turn on GC logging. Since JDK 9, unified logging (JEP 158 and JEP 271) replaced the old -XX:+PrintGCDetails style flags. A practical production setting is -Xlog:gc*:file=/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=20m. This writes GC events with timestamps to a rotating set of files. Plain -Xlog:gc gives one line per collection, which is often enough for a first look.

What to read in the log:

  • Pause lines and their durations. For G1, look for “Pause Young” and especially “Pause Full”. ZGC pauses appear as “Pause Mark Start”, “Pause Mark End” and “Pause Relocate Start”. For Shenandoah, look for “Pause Init Mark”, “Pause Final Mark” and any degenerated or full cycles in the summary statistics.
  • Heap after collection. The “before->after(capacity)” numbers show your live data. If the “after” value keeps climbing, you have a leak or a growing cache, and a bigger heap only delays the crash.

JDK Flight Recorder (JEP 328, open since JDK 11) gives the same data with more context. Start a recording with -XX:StartFlightRecording=filename=app.jfr,settings=default, or attach later with jcmd <pid> JFR.start. jfr print --events jdk.GCConfiguration app.jfr shows which collector and thread counts the JVM really used, and events such as jdk.GCPhasePause and jdk.GCHeapSummary line up GC activity with allocation hot spots in JDK Mission Control. If you already run a continuous profiler, see the earlier comparison of Pyroscope, Parca and the OpenTelemetry eBPF profiler for the always-on side.

Also check that the slowness is really GC. Slow requests can come from a database, a lock, a cold JIT or time-to-safepoint problems. The GC log tells you how much of your p99 is the collector.

Heap sizing in containers and Kubernetes

The JVM has been container aware for a long time. JDK 10 added detection of container memory and CPU limits (JDK-8146115), later backported to 8u191. JDK 10 also added MaxRAMPercentage, InitialRAMPercentage and MinRAMPercentage. cgroup v2 support arrived in JDK 15 and was backported to 11.0.16 and OpenJDK 8u372. Since JDK 19, CPU shares (Kubernetes CPU requests) are no longer used to compute the processor count, only CPU limits, with backports to 11.0.17 and 17.0.5.

Points that matter in practice:

  • Default maximum heap is 25% of available memory. In a pod with a 2 GiB limit and no -Xmx, the heap tops out around 512 MB. A proposal to raise this default for containers was closed as “Won’t Fix” in favour of JEP 523 and the G1 automatic heap sizing draft. So the 25% default stays for now.
  • Set MaxRAMPercentage instead of a fixed -Xmx when the limit changes per environment. The right value depends on your non-heap memory: thread stacks, metaspace, the code cache, direct buffers and native libraries all count against the container limit.
  • Do not use MaxRAM any more. It is deprecated in JDK 26 and ignored in JDK 27.
  • The initial heap changed in JDK 26. Without -Xms, the heap starts small and grows. If you depended on the old 1/64 initial heap, set -Xms or InitialRAMPercentage explicitly.
  • CPU limits change GC thread counts. A pod limited to one CPU gets few GC threads, and concurrent collectors have little room to work.
  • Raising the heap without raising the limit turns a GC pause problem into an OOMKilled problem.

How to choose

Start from the problem you measured, not from a collector name.

  • If you have no measured problem, stay on G1 with a sensible maximum heap. It is the default for good reasons, and every LTS release improves it.
  • If G1 pauses break your latency target even with enough headroom, try generational ZGC first on Oracle builds, or either ZGC or generational Shenandoah on builds that include it. Give the concurrent collector spare CPU and a heap with room to breathe.
  • If your job is batch processing and nobody waits on individual requests, compare Parallel against G1 on total runtime. Parallel can win on throughput.
  • If you run tiny containers or short command line tools, measure G1 against Serial on JDK 27. JEP 523 says G1 should now be close, but keep Serial where your own numbers say so.
  • If you move from JDK 17 or 21 to 25, test -XX:+UseCompactObjectHeaders. It is the default from JDK 27, so you will meet it soon anyway.

Common mistakes

  • Raising the heap as the first fix. A bigger heap delays collections, but it does not fix a live set that keeps growing, and it eats into container headroom.
  • Copying flags from old posts. On JDK 27, -XX:+UseConcMarkSweepGC, -XX:-ZGenerational and -XX:+UseBiasedLocking stop the JVM from starting, and -XX:MaxRAM is ignored with a warning.
  • Setting -Xmx equal to the container limit. The JVM needs memory outside the heap. The kernel will kill the pod before the JVM throws an OutOfMemoryError.
  • Picking ZGC or Shenandoah for a CPU-starved pod. Concurrent collectors need CPU to keep up. With too little, you get allocation stalls in ZGC or degenerated cycles in Shenandoah.
  • Assuming every JDK has Shenandoah. Check your vendor first.
  • Never re-checking after an upgrade. JDK 26 and 27 changed the default collector selection, the initial heap and the object header layout. A setup tuned for JDK 17 may now be fighting the defaults.

A practical starting plan

  1. Inventory your services: JDK vendor and version, collector in use, heap flags, container memory and CPU limits.
  2. Turn on rotating GC logs (-Xlog:gc* with a file and rotation) everywhere. It costs little and saves guesswork.
  3. Remove obsolete and copied flags. Keep only a maximum heap (or MaxRAMPercentage) and, if needed, a pause goal.
  4. Capture one JFR recording under real load for each important service, and note the longest pauses and the live data size.
  5. For services that miss latency targets, test one alternative collector in staging with the same container limits, and compare p99 latency, CPU and memory over several runs.
  6. When moving to JDK 25, try compact object headers in staging and watch heap after GC.
  7. Repeat the review after each LTS upgrade, and add the JDK 28 ZGC and Shenandoah changes to your watch list.

Take a made-up example. A 25-person team in Jaipur runs a hotel inventory API for travel agents, as Spring Boot services on Kubernetes with a 2 GiB memory limit per pod. During a festive sale, p99 latency jumps and someone raises -Xmx to 1800m. A day later, pods start getting OOMKilled. The team rolls back, turns on GC logs and records JFR during a load test. The logs show G1 full GCs caused by an in-memory rate cache that kept growing, not by the wrong collector. They cap the cache, set MaxRAMPercentage=60, delete eight copied flags from the Dockerfile, and keep G1. One latency-critical search service moves to generational ZGC with an extra CPU in its limit. The next upgrade will start with a measurement, not a guess.

A small hands-on drill

To check some of these claims, I unpacked two JDK tarballs in a scratch folder on a shared 8 vCPU Linux machine: the OpenJDK 27.0.1 GA build from jdk.java.net (it reports version 27.0.1.0.3) and Eclipse Temurin 25.0.5. Nothing was installed system wide.

What the defaults showed:

  • With -XX:ActiveProcessorCount=1, JDK 25 picked Serial and JDK 27 picked G1, as JEP 523 describes.
  • UseCompactObjectHeaders was false by default on JDK 25 and true on JDK 27.
  • On JDK 27, -XX:MaxRAM=1g printed “Ignoring option MaxRAM; support was removed in 27.0”, and InitialRAMPercentage showed 0.
  • -XX:+UseShenandoahGC failed on the jdk.java.net JDK 27 build with “Option -XX:+UseShenandoahGC not supported”, and worked on Temurin 25, including the generational mode without any unlock flag.

I also ran a synthetic allocation loop for 15 seconds with -Xmx512m, once per collector, reading pauses from -Xlog:gc*. G1 on JDK 27 had a longest pause of about 4.7 ms and no full GCs. Parallel and Serial completed more loop iterations but did full GCs, with longest pauses of about 20 ms and 77 ms. ZGC stayed near 0.2 ms, and Shenandoah on Temurin 25 stayed under 1 ms in both modes. A G1 run on JDK 25 had one pause above 120 ms. This is a toy loop on a shared machine, not a benchmark; it only shows the shape of the trade-off and why one run proves nothing.

Closing

Learn what each collector is built for: G1 for balance, ZGC and Shenandoah for short pauses, Parallel for throughput and Serial for tiny footprints. Unlearn the habit of adding a gigabyte of heap or a block of copied flags whenever latency jumps. Re-learn the defaults after every LTS upgrade, because JDK 25, 26 and 27 changed more of them than most teams noticed. Practise reading a GC log and a JFR recording before the next incident, not during it. Apply the result with the container limit in mind, change one thing at a time, and measure again.

Sources

comments powered by Disqus

Releted Posts

Log storage in 2026: revisit Grafana Loki, VictoriaLogs and OpenObserve before you keep another month of logs

Picture a platform team in Pune that runs a B2B lending stack for NBFCs across Maharashtra and Gujarat (an invented example, not a real client).

Read more

Kubernetes multi-tenancy in 2026: revisit vCluster, Capsule and Kamaji before you hand another team its own cluster

Sooner or later a platform team gets the same request again. A product squad wants “its own cluster” because it needs to install an operator with cluster-scoped CRDs, a data team wants admin rights to try a new scheduler, and a customer-facing team asks for hard isolation because an auditor said so.

Read more

Progressive delivery in 2026: revisit Argo Rollouts, Flagger and PipeCD before you ship another big-bang deploy

A Kubernetes RollingUpdate feels safe because pods come up gradually and readiness probes gate traffic. For many services that is still fine.

Read more