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).
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.
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:
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:
the phases, in order:
- init mark (pause): start marking, scan the roots.
- concurrent marking: trace the heap with SATB, while the app runs.
- final mark (pause): finish marking, pick the collection set (regions with the most garbage).
- concurrent evacuation: copy live objects out of the collection set, install forwarding pointers. the LRB covers the app.
- init update refs (pause): a tiny handover.
- concurrent update references: walk the heap, replace every pointer to an old copy with the new one.
- 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.
Shenandoah or ZGC?
| Shenandoah | ZGC | |
|---|---|---|
| moves objects concurrently | yes | yes |
| where "it moved" is kept | the old copy's header | a per-page forwarding table |
| pointer format | a plain (compressed) address | colored, 16 metadata bits |
| compressed oops | yes, smaller heaps stay compact | no |
| barriers | SATB + load reference barrier | load + store barriers |
| generational | product in 25, default targeted for 28 | always, since 24 |
| where to get it | most 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.
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.
- 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.