Skip to content

ZGC Explained: Colored Pointers, Load Barriers, Stalls

How ZGC keeps pauses near zero on big heaps: colored pointers, load barriers, concurrent relocation, allocation stalls, and what generational ZGC changed.

zgc jvm garbage-collection jvm-internals
Gopi Gorantala
Reading Progress

On This Page

Why one JVM reported 3.2 GB and the other 1.2 GB

Same program, same -Xmx1g, same -XX:+AlwaysPreTouch, same machine. The only difference is one flag on ZGC. Both numbers come from /proc/<pid>/status while the program was running:

$ java -Xmx1g -Xms1g -XX:+AlwaysPreTouch -XX:+UseZGC -XX:-ZGenerational Churn
VmSize:	61302104 kB
VmRSS:	 3220728 kB

$ java -Xmx1g -Xms1g -XX:+AlwaysPreTouch -XX:+UseZGC -XX:+ZGenerational Churn
VmSize:	36154720 kB
VmRSS:	 1197232 kB

(OpenJDK 21.0.12.1, Linux, 2 vCPUs, one run each.) A one-gigabyte heap showed up as 3.2 GB resident under the old single-generation mode. That is ZGC's pointer trick leaking into ps, and it's the quickest way to see what ZGC does differently from every other HotSpot collector. If you run ZGC and your monitoring said "this pod uses three times its heap", this is probably why, and it's not a leak.

The rest of this post is about the trick itself: ZGC stores GC bookkeeping inside the pointers, and runs a tiny piece of code on every reference load so that the collector can move objects while your threads keep running.

The short version

Most collectors stop your threads to move objects, because a thread holding the old address of a moved object would read garbage. ZGC refuses to stop them for that. Instead, every reference your code loads from the heap goes through a check, the load barrier, that asks "is this pointer still good for the current phase of the collection?" If yes, nothing happens. If not, the barrier fixes it on the spot (remaps it to the new address, or marks the object) and writes the corrected value back so the next load is fast. The pointer carries a few spare bits that say which phase it was last valid for. Those are the "colors".

So the pauses stay small because the expensive work (marking, relocating) happens concurrently, and the stop-the-world parts only flip the phase and scan roots. In my lab the largest ZGC pause was 0.074 ms. The cost moved to two other places: every reference load pays for a barrier, and if you allocate faster than the collector can free memory, your allocating thread stalls. That second one is the failure ZGC users actually meet.

A lab you can run in five minutes

What you need. The JDK I used: OpenJDK 21.0.12.1 (Ubuntu build), 2 vCPUs, 8 GB RAM. No Docker. On 21, generational ZGC is opt-in with -XX:+ZGenerational; see the version table later for what changes on 23 and 24.

java -version   # openjdk version "21.0.12.1" 2026-08-18

The program. It holds a fixed live set (500 MB in 64 KB chunks, so G1 doesn't turn them into humongous objects) and then allocates short-lived 64 KB arrays as fast as it can for ten seconds. It reports how many it managed.

import java.util.*;
public class Churn {
    static final int LIVE_MB = Integer.getInteger("liveMb", 300);
    public static void main(String[] a) throws Exception {
        List<byte[]> live = new ArrayList<>();
        for (int i = 0; i < LIVE_MB * 16; i++) live.add(new byte[64 * 1024]); // long-lived set
        long end = System.nanoTime() + Long.getLong("secs", 20) * 1_000_000_000L;
        Object[] ring = new Object[4096];
        long n = 0;
        while (System.nanoTime() < end) {
            ring[(int)(n++ & 4095)] = new byte[64 * 1024]; // short-lived garbage, as fast as possible
        }
        System.out.println("allocs=" + n + " live=" + live.size());
    }
}

I trimmed the throttling and gap timing I'd used in the first version out of this listing; the real runs used a version that also tracked the worst loop gap, which is where "worstLoopGapMs" below comes from. You'll get the same shape without it.

Step 1: see the three-times address space.

java -Xmx1g -XX:+UseZGC -XX:-ZGenerational -Xlog:gc+init -version 2>&1 | grep -E "Address|Backing"
java -Xmx1g -XX:+UseZGC -XX:+ZGenerational  -Xlog:gc+init -version 2>&1 | grep -E "Address|Backing"
# -ZGenerational
Address Space Size: 16384M x 3 = 49152M
Heap Backing File: /memfd:java_heap
# +ZGenerational
Address Space Size: 16384M
Heap Backing File: /memfd:java_heap

Step 2: squeeze the heap, log everything. Live set 500 MB, heap 1 GB. Production heap is not 1 GB; I did this so that collections happen in seconds and the stall behavior shows up.

java -Xmx1g -XX:+UseZGC -XX:-ZGenerational -Xlog:gc*,safepoint:file=nongen.log:uptime -DliveMb=500 -Dsecs=10 Churn
java -Xmx1g -XX:+UseZGC -XX:+ZGenerational  -Xlog:gc*,safepoint:file=gen.log:uptime    -DliveMb=500 -Dsecs=10 Churn
java -Xmx1g -XX:+UseG1GC                     -Xlog:gc*,safepoint:file=g1.log:uptime     -DliveMb=500 -Dsecs=10 Churn

Step 3: count the evidence.

grep -c 'Allocation Stall (' nongen.log                       # how many times a thread was blocked
grep -oE 'Allocation Stall \(.*\) [0-9.]+ms' gen.log           # one line per stalled thread
grep -E 'Pause (Mark|Relocate)' gen.log                        # the real ZGC pauses

What I saw (single runs, 1 GB heap / 500 MB live):

                    allocation stalls   total stalled   largest stall   largest GC pause
ZGC non-generational        304            1,440 ms         10.6 ms          0.072 ms
ZGC generational            246            5,072 ms         41.1 ms          0.074 ms
G1                            0                 -               -         44.99 ms (young/full pause)

Then the same thing at 2 GB: zero stalls on both ZGC modes, 0.031 and 0.055 ms maximum pause, and G1's maximum pause was 61 ms. Same code, same live set. The only thing I changed was how much room the collector had.

The confirmation signal. Look for Garbage Collection (Allocation Stall) 1024M(100%)->802M(78%) in the log. The 100% before the arrow is the whole story: the heap was full when the cycle started. If you instead see only Garbage Collection (Warmup) and (Allocation Rate) lines, your heap is big enough for this load, and you need a smaller -Xmx or a bigger -DliveMb.

Environment differences. The number of GC workers is tiny here because there are two CPUs (GC Workers: 1 (dynamic)). On a 16-core machine ZGC will pick more and stalls will be rarer for the same ratio of heap to live set. Both ZGC modes reserve a 16 GB address range here (16384M), which is address space, not RAM. And I'm on a Linux box with memfd-backed heap; Windows and macOS builds map memory differently.

Cleanup. rm nongen.log gen.log g1.log. They're small at info level but -Xlog:gc* at higher levels is not.

What the JVM is actually doing

Four ideas carry the whole collector.

1. The pointer carries the phase. Every reference inside the heap has some of its upper bits reserved for metadata, in addition to the address. A bit says "I was last validated against marking cycle N" and another says "I've been remapped since the last relocation". Those bits are the colors. The address itself is the lower part; the JVM strips the color before dereferencing.

2. The load barrier is the enforcement. When compiled code loads a reference from a field, it does the load, then tests the pointer against a "bad mask" for the current phase. For a good pointer that's a test and a not-taken branch. For a bad one, a slow path runs: depending on the phase it marks the object, or looks up where the object moved to and returns the new address. Then it writes the corrected, now-good pointer back to the field it came from. That write-back is self-healing, and it's why the cost per reference drops after the first touch in each cycle.

3. Relocation is concurrent because of that. To compact memory ZGC picks sparse pages, copies live objects out, and keeps a forwarding table from old address to new. Your threads can still hold old addresses. They don't crash because their next load of that reference trips the barrier, finds the old address bad, consults the forwarding table, and moves on with the new address.

4. The pauses are tiny because only the phase flips are stop-the-world. Look at the log: Pause Mark Start, Pause Mark End, Pause Relocate Start. Each is a handful of microseconds. Nothing in them is proportional to heap size or live set; they flip the phase and scan thread roots.

 mutator thread          ZGC worker threads
 --------------          ------------------
 load ref ----> barrier  (concurrent mark)       <- barrier marks on the way
 STW: Pause Mark Start / Mark End / Relocate Start (microseconds)
 load ref ----> barrier  (concurrent relocate)   <- barrier remaps via forwarding table
                          page freed when empty

If I had to give one analogy: the barrier is a toll booth that also repaints your car's license plate whenever the plates change. Everyone passes at full speed once repainted. That's the only analogy you get from me.

Inside HotSpot, and how it got here

ZGC's code lives under src/hotspot/share/gc/z/ in the JDK repo. I read zAddress.hpp and zBarrier.hpp at jdk-21+35 and on master, through a fetch summariser rather than directly in an editor, so treat the details below as "what the headers declare", not as a walk through the code.

For generational ZGC the header lays out the 16 low bits of a colored pointer as RRRRMMmmFFrr0000: four bits of remapped, two of old-marked, two of young-marked, two finalizable, two remembered, four reserved. Exactly one of the four remapped bits is set at a time, which is what lets the load barrier check against a single mask. It also splits the barrier work. The load barrier only looks at the remap bits. The new store barrier also checks the marked and remembered bits; that's how generational ZGC records old-to-young references without a card table. The non-generational layout isn't in those headers any more, so I'm not going to quote it from memory.

I didn't find a comment explaining self-healing in zBarrier.hpp; the function self_heal is declared and the "Helpers for relocation" section declares relocate_or_remap, which is consistent with the barrier slow path doing relocation work, but I can't tell you from those files who relocates which page when. Treat the division of labor between GC workers and barriers as described in the JEP and talks, not as something I verified in source.

JDK 11   ZGC experimental, single generation (JEP 333); goal: pauses under 10 ms
JDK 13   max heap raised from 4 TB to 16 TB (ZGC wiki changelog)
JDK 15   ZGC is production (JEP 377); ZUncommit, ZCollectionInterval, ZFragmentationLimit,
         ZAllocationSpikeTolerance etc. become product flags; SoftMaxHeapSize added
JDK 21   generational ZGC, opt-in with -XX:+ZGenerational (JEP 439); multi-mapping dropped
JDK 23   generational is the default for -XX:+UseZGC (JEP 474); -ZGenerational prints deprecation
JDK 24   non-generational mode removed (JEP 490); ZGenerational obsoleted, will eventually be refused

JEP 490's page didn't print its release in the text I fetched; JDK 24 is from the search results listing it in the JDK 24 release notes, and I did not run JDK 24 myself.

What JEP 439 says it changed matters for the lab: non-generational ZGC multi-maps the heap, so the same physical memory is mapped at three virtual addresses, one per color view. That's why Address Space Size read 16384M x 3 and why ps showed three times the heap. Generational ZGC drops multi-mapping and uses explicit barrier code to deal with colors instead. VmRSS really did read 3.2 GB in the old mode, because the kernel counts a resident page once per mapping that touches it. I didn't read the container's cgroup memory counter in this lab. My expectation is that it counts the shared memfd pages once, but I haven't checked, so look at which number your platform alerts on before you trust either.

Where I got it wrong: I assumed the generational mode would also lose less to stalls, since the JEP lists fewer stalls as a goal. In my tight-heap run it was the other way around, 5.1 s of cumulative stall against 1.4 s. One run each, a two-CPU box with a single GC worker per generation, and a workload that is almost nothing but garbage. I don't have an explanation I'd defend. If you do, tell me.

How people misread it

"ZGC has no pauses." It has three short ones per cycle and they're real. What it can have instead is stall time, which doesn't show up as a pause at all. The GC log says Pause Mark Start 0.010ms while the application's worst loop iteration took 52 ms in my gen run. If you only graph jvm.gc.pause, ZGC looks perfect while a thread sits blocked.

"The heap is 3x bigger than I set." Only under the removed single-generation mode, and only in virtual-size and some RSS accounting, per the lab above.

Allocation stall versus pause. A pause stops every Java thread. A stall blocks only the thread that tried to allocate when no free page was available, until a concurrent cycle frees memory. The log line is Allocation Stall (main) 9.471ms; the cycle that triggered it says Garbage Collection (Allocation Stall). JFR has a ZAllocationStall event (promoted to a product event in JDK 15, JEP 377).

Cargo-cult flags for ZGC.

  • -XX:+UseZGC on JDK 21 and expecting the same collector as on 23. Same flag, different collector. On 21 you got the old one; on 23 you get generational; on 24 there's only generational.
  • -XX:-ZGenerational "to be safe". On 23 it warns twice. On 24+ the mode is gone.
  • -XX:+UnlockExperimentalVMOptions -XX:+UseZGC copied from a 2019 blog post. Unneeded since JDK 15.
  • -Xmn tuning copied from G1 posts. The JEP's stated goal is that generation sizes don't need manual tuning.

What to change, and what it costs

The honest answer is: give it room. Everything else is second.

Knob Status / default seen What it does Cost
-Xmx product headroom ZGC needs to finish a cycle before you exhaust it memory
-XX:SoftMaxHeapSize manageable; default = -Xmx (1566572544 on the 8 GB box when I didn't set -Xmx) target ZGC tries to stay below; can go above to avoid OOM none until you exceed it
-XX:ConcGCThreads product; 1 on 2 vCPUs more concurrent GC workers, faster cycles CPU taken from the app
-XX:ZAllocationSpikeTolerance product; 1.0 how aggressively ZGC starts early for spiky allocation (higher = earlier) more frequent cycles
-XX:ZCollectionInterval product; 0 (off) force a cycle every N seconds wasted cycles if idle
-XX:ZUncommit product; true, delay 300 s return memory to the OS page-fault cost on regrowth
-XX:ZFragmentationLimit product; 25 how much fragmentation before relocation is worth it

(Defaults from java -XX:+PrintFlagsFinal -XX:+UseZGC on 21.0.12.1.)

The order I'd try things in when you see stalls: look at heap-after-GC and make sure the live set isn't just too large for -Xmx; raise -Xmx (in my lab 1 GB to 2 GB took stalls from 304 to 0); then lower the allocation rate if you can; then add concurrent threads. Raising ConcGCThreads before checking headroom is how you burn CPU and still stall.

Not a fix: lowering SoftMaxHeapSize when you're already stalling. It makes the collector start earlier, but if the live set plus allocation during a cycle doesn't fit under -Xmx, nothing helps.

Keeping an eye on it

Leave -Xlog:gc:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20m on. At info it's cheap and it's where Allocation Stall lines appear. Then:

  • Count Allocation Stall lines per hour. The target is zero. A handful at startup is fine; sustained stalls aren't.
  • Track Garbage Collection (Allocation Rate) cycle cadence. If cycles run back to back, you're near the edge.
  • Watch heap after each cycle, not average usage. For ZGC the useful number is what was live when marking ended.
  • Alert on RSS against the container limit using the cgroup number, not VmSize. ZGC reserves a large address range by design (16 GB here, about 34 GB of virtual size for the whole generational process).
  • JFR: keep ZAllocationStall and ZPageAllocation events in a continuous recording.

Staying safe across upgrades. Moving from JDK 17 to 21: -XX:+UseZGC is still the single-generation collector unless you add -XX:+ZGenerational. Moving 21 to 25: -XX:+UseZGC now selects generational; non-generational no longer exists on 24+, and -XX:-ZGenerational should be removed from startup scripts before it stops the JVM from starting. JDK 17 and 25 were not run for this article.

Takeaways

  • ZGC isn't magic and doesn't have zero cost: pauses move into load barriers and, when the heap is too tight, into allocation stalls. In my tight 1 GB run, 0.07 ms pauses sat next to up to 41 ms stalls.
  • Room beats flags. Going from 1 GB to 2 GB removed every stall in the lab.
  • On JDK 21, -XX:+UseZGC and -XX:+UseZGC -XX:+ZGenerational are different collectors. From 23, the default is generational; from 24, non-generational is gone.
  • Old ZGC multi-mapped the heap, so ps can show roughly 3x your heap. Check cgroup memory, not VSZ.
  • Graph stalls. Pause metrics alone will tell you ZGC is perfect.

Quick answers about ZGC

Is ZGC better than G1? For tail latency on large heaps, usually, in the sense that pauses are far smaller: my lab's largest ZGC pause was 0.074 ms against 45 to 61 ms for G1's young and full pauses. But ZGC pays barrier costs on every reference load and needs headroom; with a small heap you may trade pauses for allocation stalls. I cover the G1 side in G1 GC Explained.

Is ZGC generational by default? From JDK 23 (JEP 474). On JDK 21 and 22 you must add -XX:+ZGenerational. From JDK 24 (JEP 490) there is no non-generational mode.

Why does ZGC show 3x memory in ps or top? The old single-generation mode mapped the heap at three virtual addresses. In my run: VmRSS 3.2 GB against 1.2 GB for generational, same 1 GB heap.

What is an allocation stall? A thread asked for memory, no free page existed, and it waited for a concurrent cycle to free some. It's logged as Allocation Stall (thread) N ms and does not show up as a GC pause.

zgcjvmgarbage-collectionjvm-internals

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments