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.
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.
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:
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 addressthe 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.
X lives on page A. order.item points at it, and the pointer's color says it's good.
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.
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):
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:
how ZGC got here
| java | JEP | what changed |
|---|---|---|
| 11 | 333 | ZGC arrives, experimental |
| 15 | 377 | production ready |
| 16 | 376 | thread stacks processed concurrently: pauses no longer scale with threads |
| 21 | 439 | generational ZGC (opt in with -XX:+ZGenerational) |
| 23 | 474 | generational becomes the default mode |
| 24 | 490 | the 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.
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.
- 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.