Skip to content
Go back

JVM ShenandoahGC:Brooks Pointer与并发转移

一句话结论(30s)

Shenandoah 的本质是 Red Hat 贡献的低延迟 GC,核心目标是让”并发转移”成为可能——移动对象的同时应用线程继续运行。关键设计是对象头前面的 Brooks Pointer 转发指针:因为应用线程每次读引用都先经过转发指针跳转,配合 CAS 原子更新,转移与访问之间无需 STW 即可完成同步。权衡是读屏障和并发转移的 CPU 开销会让吞吐量下降 10-20%,用来换取 10ms 以下的暂停时间。

核心原理(2min)

Shenandoah 把堆划分为大小相等的 Region(不是分代),跨 Region 引用用比 G1 RSet 更粗粒度、维护成本更低的连接矩阵(二维 bit 表)追踪。它的 GC 周期大部分并发:除初始标记、最终标记、引用更新首尾等短 STW 外,标记、清理、转移、更新引用都与应用线程并行。核心机制是每个对象头前多一个 Brooks Pointer——对象在 from 区时指向自身,GC 决定转移时在 to 区分配新副本、拷贝数据,再用一次 CAS 把转发指针从”指向自身”改为”指向新副本”,此后应用线程无论从哪条引用访问该对象都先解引用转发指针跳到真实位置;每次读对象引用时插入一个轻量读屏障(仅多一次指针解引用)。与 ZGC 的对比点在于:ZGC 用对象头内 Mark Word 复用 + 彩色指针,而 Shenandoah 用额外 8 字节的 Brooks Pointer。

底层深入(5-10min)

Shenandoah是Red Hat贡献给OpenJDK的低延迟垃圾回收器,主打特性是并发转移(Concurrent Compaction)——移动对象的同时让应用线程继续运行。这个看似矛盾的命题,Shenandoah通过一个精巧的设计解决了:Brooks Pointer(转发指针)。

为什么并发转移是难题

传统GC做压缩(Compaction)时必须STW(Stop The World)——因为如果GC在移动对象,应用线程同时读写这个对象,会发生什么?读可能读到一半旧一半新的数据,写可能写到了旧位置而新位置没有更新,导致数据丢失。

想一想:为什么”移动对象的同时应用线程还在读写”就一定会出问题?因为移动不是一瞬间完成的——读可能卡在”半旧半新”的中间态,写可能落到旧位置而新副本没同步,数据就撕裂了;这就是传统 GC 压缩必须 STW 的根本原因。

G1通过”标记-复制”在一定程度缓解了这个问题(只转移存活对象,速度快),但转移阶段仍需短暂的STW。

Shenandoah的目标是:转移阶段也完全并发

核心机制:Brooks Pointer

Shenandoah在每个对象头前面加了一个额外的指针字段,叫Brooks Pointer(也叫Forwarding Pointer,转发指针)。注意不是在对象头里面,而是在对象头前面——多了整整8字节(64位环境,压缩后4字节)的开销。

工作原理:

  1. 对象在”from”区域时,Brooks Pointer指向对象自身。
  2. GC决定转移这个对象,在”to”区域分配新副本,把数据拷贝过去。
  3. CAS操作把原对象的Brooks Pointer从”指向自身”改为”指向新副本”。
  4. 此后,应用线程无论通过什么引用访问这个对象,都先读Brooks Pointer,然后跳转到真正的对象。

想一想:为什么读一次转发指针就能保证读到”最新”对象?因为 CAS 是原子的,转发指针要么指向自身、要么指向新副本,两个状态之间没有中间态;应用线程无论在哪一刻读,都只会跳到”当前公开的那个地址”。

关键优势:GC移动对象的过程和应用线程访问对象的过程,通过一个原子操作(CAS更新Brooks Pointer)就完成了同步。不需要STW暂停所有线程来更新引用。

这个”CAS 更新转发指针”的原子同步点,源码在 shenandoahForwarding.inline.hpp

// 源码:src/hotspot/share/gc/shenandoah/shenandoahForwarding.inline.hpp
inline oop ShenandoahForwarding::try_update_forwardee(oop obj, oop update) {
  markWord old_mark = obj->mark();
  if (old_mark.is_marked()) {
    return cast_to_oop(old_mark.clear_lock_bits().to_pointer());
  }
  if (old_mark.is_self_forwarded()) {
    // Another thread lost the evacuation race; the object stays put.
    return obj;
  }

  markWord new_mark = markWord::encode_pointer_as_mark(update);
  markWord prev_mark = obj->cas_set_mark(new_mark, old_mark, memory_order_conservative);
  if (prev_mark == old_mark) {
    return update;
  }
  // Concurrent writers on a cset object's mark can only be other evacuation
  // threads installing forwarding (real or self). ...
  if (prev_mark.is_marked()) {
    return cast_to_oop(prev_mark.clear_lock_bits().to_pointer());
  }
  assert(prev_mark.is_self_forwarded(),
         "concurrent writers on cset objects must install forwarding: prev=" INTPTR_FORMAT,
         prev_mark.value());
  return obj;
}

inline oop ShenandoahForwarding::try_forward_to_self(oop obj, markWord old_mark) {
  assert(!old_mark.is_forwarded(),
         "caller must pass a non-forwarded mark: old=" INTPTR_FORMAT, old_mark.value());
  markWord new_mark = old_mark.set_self_forwarded();
  markWord prev_mark = obj->cas_set_mark(new_mark, old_mark, memory_order_conservative);
  if (prev_mark == old_mark) {
    // We installed the self-forward.
    return nullptr;
  }
  if (prev_mark.is_marked()) {
    return cast_to_oop(prev_mark.clear_lock_bits().to_pointer());
  }
  assert(prev_mark.is_self_forwarded(),
         "concurrent writers on cset objects must install forwarding: prev=" INTPTR_FORMAT,
         prev_mark.value());
  return obj;
}

try_update_forwardee 就是文章第 3 步的 CAS:把 from 区对象的转发指针从”指向自身”切到 to 区新副本。CAS 成功说明本线程赢得了转移、update(新副本)从此公开;CAS 失败则读回已被竞争者装好的转发指针返回——无论谁赢,所有线程最终看到的都是同一个新地址,全程无需 STW。try_forward_to_self 是分配失败时的兜底:设置 self-forward 位,让其他线程看到”此对象已处理、留在原地”。

需要注意:在这份较新的 JDK 源码里,转发指针已不再占用对象头前的独立 8 字节,而是复用 Mark Word——markWord::encode_pointer_as_mark(update) 把新地址编码进 mark word 的锁位(marked_value = 0b011),解码时 clear_lock_bits().to_pointer() 再取回。存储位置变了,但 Brooks Pointer 的核心语义——访问前先经转发指针跳转、用一次 CAS 原子切换——完全保留。

想一想:把转发指针从”独立 8 字节”改成”复用 Mark Word 锁位”,省的是什么?省的是每个对象 8 字节的空间开销——encode_pointer_as_mark 把新地址编码进锁位、解码时 clear_lock_bits 再取回,语义不变但内存账单大大下降。

应用线程如何配合:读屏障(Read Barrier)

Shenandoah在每次读对象引用时插入读屏障:

// 源码:src/hotspot/share/gc/shenandoah/shenandoahBarrierSet.inline.hpp
inline oop ShenandoahBarrierSet::load_reference_barrier(oop obj) {
  if (!ShenandoahLoadRefBarrier) {
    return obj;
  }
  if (_heap->has_forwarded_objects() && _heap->in_collection_set(obj)) {
    // Subsumes null-check
    assert(obj != nullptr, "cset check must have subsumed null-check");
    oop fwd = ShenandoahForwarding::get_forwardee(obj);
    if (obj == fwd && _heap->is_evacuation_in_progress()) {
      Thread* t = Thread::current();
      return _heap->evacuate_object(obj, t);
    }
    return fwd;
  }
  return obj;
}

读屏障的快速路径只有一次指针解引用:get_forwardee 读 mark word,若对象已被转发就直接返回新地址;只有当对象还在待回收区且尚未转发时,才走慢路径 evacuate_object——由当前访问线程顺手完成转移。_heap->has_forwarded_objects() && _heap->in_collection_set(obj) 这层判断把开销压到最低:不在待回收区、或堆里根本没有转发对象的普通访问,直接原样返回,什么都不多做。

注意这个读屏障非常轻量,只是多了一次指针解引用(本来就要读对象头,现在多读了8字节而已)。

想一想:读屏障为什么能”轻”到只多一次解引用?因为 has_forwarded_objects() && in_collection_set(obj) 这两个判断,把”不在回收区、堆里根本没转发对象”的绝大多数访问挡在了屏障外直接返回;只有命中回收区且尚未转发的对象才走慢路径 evacuate_object

并发转移:复制对象并安装转发指针

把”转移”本身拆到源码层面,核心动作在 shenandoahHeap.cpptry_evacuate_object

// 源码:src/hotspot/share/gc/shenandoah/shenandoahHeap.cpp
oop ShenandoahHeap::try_evacuate_object(oop p, Thread* thread, ShenandoahHeapRegion* from_region,
                                               ShenandoahAffiliation target_gen) {
  // ... 在 to 区分配新副本(优先 GCLAB,失败则共享分配)...

  // Copy the object:
  Copy::aligned_disjoint_words(cast_from_oop<HeapWord*>(p), copy, size);

  oop copy_val = cast_to_oop(copy);
  // ... relativize stack chunks before publishing the copy ...

  // Try to install the new forwarding pointer.
  oop result = ShenandoahForwarding::try_update_forwardee(p, copy_val);
  if (result == copy_val) {
    // Successfully evacuated. Our copy is now the public one!
    shenandoah_assert_correct(nullptr, copy_val);
    return copy_val;
  } else {
    // Failed to evacuate. We need to deal with the object that is left behind.
    if (alloc_from_lab) {
      ShenandoahThreadLocalData::gclab(thread)->undo_allocation(copy, size);
    } else {
      fill_with_object(copy, size);
    }
    return result;
  }
}

转移的实际动作就三步:先 Copy::aligned_disjoint_words 把对象数据整体拷到 to 区新副本,再 cast_to_oop 得到副本指针,最后 try_update_forwardee 用 CAS 把转发指针装到旧对象上。关键顺序是”先拷贝、后 CAS”——哪怕 CAS 竞争失败、本线程的副本被弃用,也只是浪费了一次分配,不会产生数据不一致;失败路径里 undo_allocation 回滚 LAB 分配、或 fill_with_object 用填充对象覆盖废弃副本,保证不会留下指向已回收区的悬空引用。

连接矩阵(Connection Matrix):跨区引用的高效管理

Shenandoah的分代是”区域(Region)“而不是”分代(Generation)“。堆被划分为大量大小相等的Region。为了追踪跨Region引用,Shenandoah使用连接矩阵(Connection Matrix)

这比G1的Remembered Set(RSet)更精简——RSet记录的是”谁指我了”(所有引用的精确来源),而连接矩阵记录的是Region级别的指向关系,粒度更粗但维护成本更低。

Shenandoah的完整GC周期

  1. 初始标记(Init Mark):STW,扫描GC Roots直接可达的对象。时间与GC Roots数量成正比,非常短。
  2. 并发标记(Concurrent Marking):遍历对象图,标记可达对象。应用线程同时运行。
  3. 最终标记(Final Mark):STW,处理并发标记期间的引用变更,清空SATB队列(Snapshot-At-The-Beginning,快照开始标记)。时间很短。
  4. 并发清理(Concurrent Cleanup):回收已经没有任何存活对象的Region。
  5. 并发转移(Concurrent Evacuation):将存活对象从待回收Region复制到目标Region,同时应用线程在运行。这是Shenandoah最独特的阶段。
  6. 初始更新引用(Init Update Refs):STW,准备引用更新阶段。
  7. 并发更新引用(Concurrent Update Refs):遍历对象图,把所有指向旧位置的引用更新为新位置。
  8. 最终更新引用(Final Update Refs):STW,更新GC Roots的引用。
  9. 并发清理(Concurrent Cleanup):回收已经无引用的旧Region。

Shenandoah vs ZGC

两个都是低延迟收集器,目标都在10ms以下的暂停时间。核心区别:

特性ShenandoahZGC
转发信息存储对象头前Brooks Pointer(额外8字节)对象头内Mark Word(复用,彩色指针辅助)
读屏障每次读都过转发指针(Java对象读取)加载引用屏障(只拦截从堆读引用)
批量转移转移一批对象到新Region转移单个Page
指针技术普通指针彩色指针(Colored Pointers,重映射+标记用指针位)
跨Region引用连接矩阵Page Table
内存开销每个对象8字节指针中元数据位(不额外占对象内存)

适用场景

Shenandoah适合对响应时间有严格要求的应用:交易系统、实时风控、在线游戏服务端。代价是吞吐量通常会下降10-20%(读屏障和并发转移的CPU开销)。如果你的服务用G1时偶尔出现1秒以上的GC暂停,Shenandoah是一个值得考虑的替代方案。

章末提问

追问 1:Shenandoah 靠什么做到”转移对象时不 STW”?

回答思路:结论先行——靠对象头前的 Brooks 转发指针 + 一次 CAS + 读屏障。因为应用线程每次读引用都先经转发指针跳转,GC 转移时先在 to 区拷贝数据、再用 CAS 把转发指针从”指向自身”切到”指向新副本”;CAS 是原子的,所以无论应用线程在转移前后哪一刻读,都只会跳到当前公开的地址,全程不需要暂停所有线程。

追问 2:Shenandoah 的读屏障这么轻,为什么吞吐还是会掉 10-20%?

回答思路:结论先行——因为屏障是”每个读引用都要过”,单次虽轻、累加起来就是固定税。因为快速路径确实只有一次指针解引用,但普通访问也要先经过 has_forwarded_objects && in_collection_set 判断再返回;所有读都付这笔税,这就是用吞吐换低延迟的必然权衡——换来的是 10ms 以下的暂停。

追问 3:Shenandoah 和 ZGC 都是低延迟收集器,核心区别在哪?

回答思路:结论先行——区别在转发信息的存储位置和读屏障的拦截点:Shenandoah 用 Brooks Pointer(早期独立 8 字节、现在复用 Mark Word 锁位),ZGC 复用 Mark Word + 彩色指针。因为两者都追求 10ms 以下暂停,但 Shenandoah 的读屏障拦所有 Java 对象读、ZGC 只拦从堆读引用;Shenandoah 用连接矩阵管跨 Region 引用、ZGC 用 Page Table;内存开销上 ZGC 把元数据放进指针位、不额外占对象内存。


Share this post on:

Previous Post
JVM对象创建与生命周期:从new指令到GC回收
Next Post
JVM Epsilon回收器:什么也不做的GC