skip to content
everything.gc.shenandoah

Shenandoah

concurrent compaction with forwarding pointers.

chapter
chapter 14.09
reading time
8 min read

Shenandoah is the other collector that moves objects while your app runs. it's region-based like G1, but it evacuates concurrently: GC threads copy live objects out, leave a forwarding pointer in the old copy's header, and a load reference barrier makes sure the app only ever sees the new copy.

its four pauses don't grow with the heap: in the cycle below they're between 2 and 142 microseconds. generational mode became a product feature in java 25.

the picture

you know how ZGC tracks moved objects in a forwarding table off to the side, and keeps state in the pointer's color? Shenandoah solves the same problem the other way around: the pointer stays a plain address, and the moved-from object itself says where it went.

think of moving house and leaving a note on the old front door: "we live at 42 elm street now". anyone who turns up at the old address reads the note and goes to the new one. and if they're helpful, they update their address book on the way. once everyone's address book has been updated, the old house can be torn down.

the note on the door is the forwarding pointer. reading the note before walking in is the load reference barrier (LRB).

GC threadis running this step
region in the collection seta free region (to-space)headerXheaderYfield → X

the region on the left is in the collection set: its live objects are about to move. X and Y have normal headers. the app's field points at X.

an objecta forwarding pointer in the old headerwhat this step touchesthe app's reference
step 1/7
step through one evacuation. the forwarding pointer replaces the old copy's header. step 5 is the interesting one: an app thread and a GC thread both copy Y at the same time, and a single CAS decides whose copy wins.

see what makes this safe? every object has exactly one canonical address at any moment: the one its header points to, or itself if it hasn't moved. the barrier always follows that. copies that lose the race are just thrown away.

the barrier

since java 13, every reference loaded from the heap passes through the LRB:

the load reference barrier, roughly
p = obj.field                      // a plain address
if (gc_is_evacuating && in_collection_set(p)) {   // a thread-local flag + a region check
    q = forwardee(p)               // read p's header. forwarded? use the new address
    if (q == p) q = evacuate(p)    // not copied yet: copy it now, CAS the header
    obj.field = q                  // heal the field
    p = q
}

outside of evacuation, it's a test of a thread-local flag that's almost always false. Shenandoah also has the SATB barrier from how GC works for concurrent marking. before java 13 it used a separate forwarding word in every object (the Brooks pointer) and a barrier on writes too. moving the forwarding pointer into the header of the old copy, which is dead weight once it's been copied anyway, cut that word and made Shenandoah's object footprint the same as everyone else's.

a real cycle

the same toy service as the previous chapters, -XX:+UseShenandoahGC -Xmx256m, one cycle from -Xlog:gc:

app stopped
concurrent GC
85 ms89 ms93 ms
hover or tap a block to see its log line.
stop-the-world pauseconcurrent GC work, app keeps running
four pauses: init mark (11 µs), final mark (142 µs), init update refs (2 µs), final update refs (29 µs). everything else, including moving the objects and fixing every pointer to them, is concurrent.

the phases, in order:

  1. init mark (pause): start marking, scan the roots.
  2. concurrent marking: trace the heap with SATB, while the app runs.
  3. final mark (pause): finish marking, pick the collection set (regions with the most garbage).
  4. concurrent evacuation: copy live objects out of the collection set, install forwarding pointers. the LRB covers the app.
  5. init update refs (pause): a tiny handover.
  6. concurrent update references: walk the heap, replace every pointer to an old copy with the new one.
  7. final update refs (pause): fix the roots, then the collection set's regions are free.

across three runs (median), all of Shenandoah's pauses added up to 49 ms. G1 on the same program: 515 ms. its longest pause was 5 ms, though: a final mark that got stretched because the laptop was busy with other work. on a quiet run, the same program's longest pause was 0.29 ms. concurrent collectors need spare CPU to stay ahead, and short pauses still need every thread to reach a safepoint quickly.

quick check
why does Shenandoah need an 'update references' phase, when ZGC doesn't have one?

Shenandoah or ZGC?

ShenandoahZGC
moves objects concurrentlyyesyes
where "it moved" is keptthe old copy's headera per-page forwarding table
pointer formata plain (compressed) addresscolored, 16 metadata bits
compressed oopsyes, smaller heaps stay compactno
barriersSATB + load reference barrierload + store barriers
generationalproduct in 25, default targeted for 28always, since 24
where to get itmost OpenJDK builds (Temurin, Corretto, Red Hat)every JDK

both get you sub-millisecond pauses. in practice: check that your JDK build has Shenandoah (Oracle's own builds have historically left it out), then try both under real load and read the logs. differences in throughput and footprint depend on the workload more than on the table.

one level deeper:generational Shenandoah+

like generational ZGC, generational Shenandoah splits the regions into young and old and collects the young ones far more often, with a card table and remembered set scanning for old-to-young pointers (you'll see Concurrent remembered set scanning in its log). it was experimental in java 24 (JEP 404), became a product feature in 25 (JEP 521, -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational), and JEP 535 is targeted to make it the default mode in 28, with the single-generation mode deprecated.

keylines
  • Shenandoah evacuates concurrently, like ZGC, with four short pauses per cycle.
  • the forwarding pointer lives in the old copy's header. no extra word per object since java 13.
  • the load reference barrier always hands the app the current copy, and heals the field.
  • racing copies are settled by one CAS on the header. losers throw their copy away.
  • generational mode is a product feature in 25, targeted to be the default in 28.