skip to content

ZGC

colored pointers and load barriers. sub-millisecond pauses.

chapter
chapter 14.08
reading time
9 min read

ZGC does almost everything while your app keeps running, including moving objects. its pauses don't grow with the heap or the live set: they're well under a millisecond (never more than 57 microseconds in the runs below).

two tricks make it work: colored pointers (GC metadata stored in the pointer itself) and a load barrier (a check on every reference you load that fixes stale pointers on the spot). since java 24, ZGC is always generational.

the picture

you know how G1 still stops the world to evacuate regions? the copying is fast, but it's a pause, and it grows with how much is alive in the regions it picks. ZGC moves objects while the app is running. which sounds impossible: the app might be holding a pointer to the old copy at the exact moment it moves.

think of a library reshuffling its shelves during opening hours. books move all day. people still walk in with old catalog cards. so the desk checks the stamp on every card handed over. a current stamp: go straight to the shelf. an old stamp: look the book up in the move log, fix the card, stamp it, then send them to the right shelf. no one ever gets a wrong shelf, every card gets fixed the first time it's used, and the library never closes.

the stamp is the color in the pointer. the desk is the load barrier.

good bit: R2
good: fast path

this pointer's remapped bit (R2) is the good one. the load barrier tests the color, sees it's fine, strips it and hands you the address. a test and a branch. hover the bits to see what each group is for.

blue the addressamber what the load barrier checkspurple what marking and the store barrier usedashed green the good remapped bit
a colored pointer, 64 bits. hover the groups. then start a new relocation phase: the good remapped bit changes, and this pointer is suddenly stale without anyone touching it. load it, and the barrier heals it.

see the trick in "start a new relocation phase"? the GC doesn't visit a single pointer to invalidate them. it changes which color counts as good, and every pointer in the heap that carries the old color is stale from that moment. each one gets fixed lazily, the first time someone loads it.

the load barrier

the JIT puts a few instructions after every load of a reference from the heap (obj.field, array[i]). not on stores of ints, not on locals, just on reference loads:

the load barrier, roughly
p = obj.field                 // the colored pointer, as stored
if (p & bad_mask)             // a stale color? rare
    p = slow_path(obj.field)  // find where the object really is,
                              // and write the good pointer back into obj.field
x = p >> 16                   // drop the color bits: now it's a plain address

the fast path is a test and a branch. the slow path does whatever the current phase needs: remap the pointer through a forwarding table, copy the object if nobody has yet, or mark it. and it heals the field, so the next load of the same field takes the fast path.

app threadis running this step
order (page C)orderitem: A goodpage A, relocatingpage BX

X lives on page A. order.item points at it, and the pointer's color says it's good.

the pointer in order.itemthe live object / a good colora stale colorGC bookkeeping
step 1/8
step through one object being moved while the app uses it. the forwarding table lives off to the side, per page, so the old page can be reused as soon as it's emptied. the app thread pays for the fix-up exactly once.

notice who did the work in steps 4 to 6: the app thread. ZGC doesn't need every pointer fixed before the app may continue. it only needs every pointer fixed before it's used, and the barrier makes sure of that.

quick check
a ZGC load barrier finds a stale pointer to an object that the GC hasn't copied yet. what happens?

what's left for the pauses

a ZGC cycle still has three stop-the-world pauses: mark start, mark end and relocate start. none of them walks the heap. they flip the phase (and the good color), deal with a small fixed set of roots, and get out. thread stacks are processed concurrently too since java 16 (JEP 376), so a thousand threads don't make the pauses longer. here's a real young collection (-Xlog:gc,gc+phases, the same toy service, 256 MB heap):

app stopped
concurrent GC
234 ms242 ms250 ms
hover or tap a block to see its log line.
stop-the-world pauseconcurrent GC work, app keeps running
the three pauses are 12, 6 and 19 microseconds: at this scale they're thinner than a line. everything that actually costs time, marking and relocating, is concurrent. hover to read the log lines.

and here's the longest pause each collector produced on that same program (default settings, -Xmx256m, jdk 25, a 14-core laptop that was busy with other work, median of three runs). same code, same allocation pattern, five collectors:

Serial26.6 ms
Parallel21.7 ms
G18.3 ms
Shenandoah5 ms
ZGC0.039 ms
log scale
longest stop-the-world pause in a run, log scale. Serial and Parallel copy and compact with the app stopped; G1 evacuates with the app stopped; Shenandoah and ZGC do both concurrently. Shenandoah's worst pauses were final-mark pauses stretched by the busy machine; ZGC's never went past 57 µs in any run.

how ZGC got here

javaJEPwhat changed
11333ZGC arrives, experimental
15377production ready
16376thread stacks processed concurrently: pauses no longer scale with threads
21439generational ZGC (opt in with -XX:+ZGenerational)
23474generational becomes the default mode
24490the non-generational mode is removed. ZGC is always generational

turn it on with -XX:+UseZGC. for java 28 there's a proposal for ZGC to size the heap by itself (JEP 546), so you'd stop setting -Xmx at all. it isn't final yet.

one level deeper:what changed when ZGC went generational+

the original ZGC had 4 color bits sitting just above the address bits and mapped the whole heap three times in virtual memory (once per color), so any colored pointer could be dereferenced directly. generational ZGC moved the colors into the low 16 bits and strips them with a shift in the barrier instead: no more multi-mapping. it also added store barriers, because a young collection needs to know about old-to-young pointers (that's what the "remembered" color bits and ZGC's remembered sets are for), and because marking the old generation concurrently needs to see overwritten references (the same problem SATB solves). the payoff: young objects are collected often and cheaply, so ZGC needs much less headroom than before for the same allocation rate.

keylines
  • ZGC marks and moves objects concurrently. its pauses are microseconds, whatever the heap size.
  • colored pointers keep GC state in the pointer's low 16 bits.
  • flipping the good color invalidates every pointer at once. the load barrier fixes each one on first use.
  • the app thread that hits a stale pointer heals it, and can even relocate the object itself.
  • it needs headroom: allocate faster than it can collect and you get allocation stalls.