1. 项目背景

某支付核心服务在从CMS迁移至G1后,P99延迟稳定在50ms,但团队发现监控中存在周期性的"毛刺"——偶发单次请求耗时从正常的2ms飙升至80ms。GC日志分析显示,这些毛刺与G1的并发标记周期(concurrent mark cycle)精确对齐。团队此前已调优了-XX:MaxGCPauseMillis和-XX:InitiatingHeapOccupancyPercent(IHOP),但始终无法理解一个问题:为什么并发标记会给业务线程带来额外开销?GC并发运行不是在单独的后台线程中进行的吗?

答案藏在"屏障"(Barrier)之中。所谓屏障,是指JIT编译器在应用程序代码中插入的微小代码片段,它们位于引用读写操作的前后,用于在运行时通知GC"对象引用图发生了变化"。JVM规范并没有规定屏障的存在——它们纯粹是HotSpot VM为了实现并发GC而向应用程序植入的"探针"。在G1中,每一次对象引用写入(如obj.field = anotherObj)都被JIT编译器插入了两段屏障代码:SATB前写屏障(pre-write barrier)用于保存覆盖前的旧值,后写屏障(post-write barrier)用于更新卡表与记忆集合(Remembered Set, RSet)。在ZGC中,每一次引用读取(如Object o = obj.field)都会被注入负载屏障(load barrier),用于检查指针颜色,并在必要时触发自愈(self-healing)。

这些屏障的CPU开销本身很小——每条屏障可能只有数十条机器指令——但它们的累积效应在吞吐量密集型场景中足以造成5%-10%的业务CPU损耗。更关键的是,理解屏障的本质是从"调优参数"到"理解算法"的分水岭。本章将深入JDK 21源码,解剖G1的双屏障体系与ZGC的染色指针屏障体系,揭示并发标记在源码层面的完整运作机制。


2. 项目设计

小胖(抓着一杯咖啡冲进办公室):"大师,救命!我们的G1服务每30秒就有一次P99毛刺,GC日志显示恰好对应并发标记周期。我已经把MaxGCPauseMillis从200调到50,IHOP从45调到40,完全没用。GC不是后台线程跑的吗,怎么会拖慢业务线程?"

大师(放下手中的《算法导论》,喝了口茶):"小胖,你问的是一个好问题。GC标记线程确实是后台线程,但你业务线程里那些看不见的小东西——屏障代码——才是关键。想象一下,每个obj.field = anotherObj的Java赋值语句,JIT编译后生成的机器码里,除了真正的mov写操作,还夹带了什么?"

小胖:"额外代码?就是你上次说的HotSpot BarrierSet框架那些?"

大师:"正是。在HotSpot中,src/hotspot/share/gc/shared/barrierSet.hpp定义了抽象的BarrierSet基类。这是一个CHeapObj对象,包含四个核心子组件:**

// barrierSet.hpp:46
class BarrierSet: public CHeapObj<mtGC> {
  BarrierSetAssembler* _barrier_set_assembler;  // 生成平台相关机器码
  BarrierSetC1*        _barrier_set_c1;          // C1编译器屏障支持
  BarrierSetC2*        _barrier_set_c2;          // C2编译器屏障支持
  BarrierSetNMethod*   _barrier_set_nmethod;     // nmethod屏障
  BarrierSetStackChunk* _barrier_set_stack_chunk; // 虚拟线程栈屏障
};

大师继续:"G1的G1BarrierSet继承自CardTableBarrierSet,位于src/hotspot/share/gc/g1/g1BarrierSet.hpp:66,内置两个核心机制:"

// g1BarrierSet.hpp:68-69
BufferNode::Allocator _satb_mark_queue_buffer_allocator;
G1SATBMarkQueueSet    _satb_mark_queue_set;  // SATB队列集合

// g1BarrierSet.hpp:71
Atomic<G1CardTable*>  _refinement_table;      // 精炼卡表(双表设计)

"这是什么意思呢?先说SATB——Snapshot-At-The-Beginning。当G1发起并发标记周期时,它需要一个'开始时'的对象图快照。但业务线程在并发标记过程中还在持续修改引用,如果不做处理,遍历出来的对象图就是不一致的——可能出现'活对象被漏标'的灾难场景。"

小白(刚入职3个月的应届生,小心翼翼举手):"大师,我上次看论文时看到CMA的增量更新(Incremental Update)和SATB是两种不同的并发标记快照维护策略。G1为什么选了SATB而不是增量更新?"

大师(赞许地点点头):"好问题。CMS用的是增量更新——记录新创建的引用,remark阶段重新扫描。G1选择SATB是因为SATB天然适合region-based的垃圾回收。SATB记录的是被覆盖掉的旧引用,逻辑是:如果旧引用在快照时刻是活的,那么即使它被业务线程修改了,快照中它仍是可达的。这样G1的并发标记线程可以在不暂停业务的情况下安全地遍历对象图。但代价是SATB可能保留一些实际上已经死亡的'浮动垃圾'(floating garbage),这些对象要等到下一次并发周期才能回收。"

大师继续:"SATB屏障的源码实现非常精巧。当JIT编译obj.field = newValue时,会在写操作之前注入预写屏障。让我们看src/hotspot/share/gc/g1/g1BarrierSet.inline.hpp:49-58:"

template <class T>
inline void G1BarrierSet::enqueue(T* dst) {
  G1SATBMarkQueueSet& queue_set = G1BarrierSet::satb_mark_queue_set();
  if (!queue_set.is_active()) return;  // 只在并发标记期间激活!

  T heap_oop = RawAccess<MO_RELAXED>::oop_load(dst);     // 读取旧值
  if (!CompressedOops::is_null(heap_oop)) {
    SATBMarkQueue& queue = G1ThreadLocalData::satb_mark_queue(Thread::current());
    queue_set.enqueue_known_active(queue, CompressedOops::decode_not_null(heap_oop));
  }
}

"注意这行关键的if (!queue_set.is_active()) return;——这意味着SATB屏障只在并发标记周期内才产生开销。当G1没有进行并发标记时,SATB屏障就是一个空操作(no-op),CPU几乎不受影响。G1的SATB队列是每线程的,位于SATBMarkQueue中(src/hotspot/share/gc/shared/satbMarkQueue.hpp:53),包含一个buffer指针和index计数:"

class SATBMarkQueue {
  void** _buf;      // 缓冲区,存储被覆盖的旧引用
  size_t _index;    // 从capacity往0递减,管理入队位置
  bool   _active;   // 每队列独立激活标志
};

"当线程的SATB buffer填满时(index降至0),JIT生成的屏障代码会跳转到运行时slow path——G1BarrierSetRuntime::write_ref_field_pre_entry(src/hotspot/share/gc/g1/g1BarrierSetRuntime.cpp:47-54),它将满buffer移交给全局SATB队列集合,并分配新buffer:"

JRT_LEAF(void, G1BarrierSetRuntime::write_ref_field_pre_entry(oopDesc* orig, JavaThread* thread))
  assert(orig != nullptr, "should be optimized out");
  SATBMarkQueue& queue = G1ThreadLocalData::satb_mark_queue(thread);
  G1BarrierSet::satb_mark_queue_set().enqueue_known_active(queue, orig);
JRT_END

"并发标记线程在G1CMTask::drain_satb_buffers()中不断消费这些SATB buffer(src/hotspot/share/gc/g1/g1ConcurrentMark.hpp:981),将其中的对象灰化(标记为活动并压入本地标记栈),从而维护SATB一致性。"

小胖:"所以SATB前写屏障的代价就是:在并发标记期间,每个引用写入都要多做一次读取旧值+条件入队操作?"

大师:"没错,这就是你P99毛刺的根因之一。但G1还有第二个屏障——后写屏障。来看src/hotspot/share/gc/g1/g1BarrierSet.inline.hpp:71-81:"

template <DecoratorSet decorators, typename T>
inline void G1BarrierSet::write_ref_field_post(T* field) {
  CardTable* local_card_table = card_table();
  CardValue* byte = local_card_table->byte_for(field);  
  if (*byte == G1CardTable::clean_card_val()) {
    *byte = G1CardTable::dirty_card_val();  // 标记卡为脏
  }
}

"后写屏障的作用是维护记忆集合(RSet)。G1将堆划分为大约2048个region(默认2MB/region),每个region维护一个RSet,记录哪些外部region的卡(card)引用了本region内的对象。当发生Young GC时,G1只需要扫描RSet中记录的脏卡来寻找根,而不需要扫描整个老年代。"

"卡表(Card Table)是一片byte数组,堆的每512字节对应一个byte(卡)。当卡值为'脏'时,表示该512字节范围内的对象可能包含指向其他region的引用。write_ref_field_post只做了极少的事情:计算地址对应的卡表索引,检查是否为clean,若clean则标记为dirty。通常情况下这是两三条机器指令。"

小胖:"那为什么G1需要两个屏障?SATB前写屏障负责并发标记的正确性,后写屏障负责Young GC的效率,它们各司其职?"

大师:"完全正确。而且G1做了额外优化——双卡表设计(g1BarrierSet.hpp:38-65的注释详细描述了这一点)。G1维护了两个卡表:一个是业务线程正在写入的'card table',另一个是精炼线程(refinement threads)正在处理的'refinement table'。当脏卡数量超过阈值时,G1原子地交换这两个表,业务线程继续在新表上写屏障,精炼线程在旧表上逐卡扫描、更新RSet并清除卡标记。这种分离消除了业务线程和GC线程之间的细粒度同步需求。"

小胖:"那ZGC呢?听说ZGC的屏障设计完全不同?"

大师:"ZGC的设计哲学是以空间换时间——使用64位指针的高位比特编码GC元数据。这个概念称为染色指针(Colored Pointers)。源码见src/hotspot/share/gc/z/zAddress.hpp:48-74:"

// zAddress.hpp 中染色指针的位布局
// RRRRMMmmFFrr0000
// ****               : 负载屏障使用 (Remapped bits)
// **********         : 标记屏障使用 (Marked bits)
// ************       : 存储屏障使用 (Store metadata)
//             ****   : 保留位
//
// +-------------+-------------------+--------------------------+
// | Bit pattern | Description       | Included colors          |
// +-------------+-------------------+--------------------------+
// |     rr      | Remembered bits   | Remembered[0, 1]         |
// +-------------+-------------------+--------------------------+
// |     FF      | Finalizable bits  | Finalizable[0, 1]        |
// +-------------+-------------------+--------------------------+
// |     mm      | Marked young bits | MarkedYoung[0, 1]        |
// +-------------+-------------------+--------------------------+
// |     MM      | Marked old bits   | MarkedOld[0, 1]          |
// +-------------+-------------------+--------------------------+
// |    RRRR     | Remapped bits     | Remapped[00, 01, 10, 11] |
// +-------------+-------------------+--------------------------+

"关键常量定义在zAddress.hpp:130-195中:"

const size_t      ZPointerReservedShift   = 0;
const size_t      ZPointerReservedBits    = 4;       // 低4位保留
const size_t      ZPointerRememberedShift = 4;       // Remembered: bit 4-5
const size_t      ZPointerMarkedShift     = 6;       // Marked: bit 6-11
const size_t      ZPointerRemappedShift   = 12;      // Remapped: bit 12-15

const uintptr_t   ZPointerLoadMetadataMask  = ZPointerRemappedMask;
const uintptr_t   ZPointerMarkMetadataMask  = ZPointerLoadMetadataMask | ZPointerMarkedMask;

"ZGC的负载屏障由ZBarrier::load_barrier_on_oop_field_preloaded实现(src/hotspot/share/gc/z/zBarrier.inline.hpp:461-467):"

inline zaddress ZBarrier::load_barrier_on_oop_field_preloaded(volatile zpointer* p, zpointer o) {
  auto slow_path = [](zaddress addr) -> zaddress {
    return addr;
  };
  return barrier(is_load_good_or_null_fast_path, slow_path, color_load_good, p, o);
}

"核心逻辑在ZBarrier::barrier模板(zBarrier.inline.hpp:319-344):"

template <typename ZBarrierSlowPath>
inline zaddress ZBarrier::barrier(ZBarrierFastPath fast_path, ZBarrierSlowPath slow_path,
                                   ZBarrierColor color, volatile zpointer* p, zpointer o,
                                   bool allow_null) {
  // Fast path: 检查指针颜色是否为'good'
  if (fast_path(o)) {
    return ZPointer::uncolor(o);  // 剥离元数据位,直接返回地址
  }
  // Slow path: remap或relocate
  const zaddress load_good_addr = make_load_good(o);
  const zaddress good_addr = slow_path(load_good_addr);
  // Self heal: CAS替换旧指针为新颜色指针
  if (p != nullptr) {
    const zpointer good_ptr = color(good_addr, o);
    self_heal(fast_path, p, o, good_ptr, allow_null);
  }
  return good_addr;
}

"ZGC的负载屏障在每次读取引用时检查指针的remapped位是否为当前阶段的"good"颜色。如果不是,说明该对象需要被重新映射(remap)或已经移动到新地址(relocate),屏障会找到正确的地址(通过forwarding table),然后自愈(self-heal):用CAS将旧指针替换为新颜色的指针。这意味着一旦某处的指针被修正,后续对该位置的读取都会走快路径(fast path),只检查一下颜色位即可。"

小白:"ZGC的存储屏障呢?"

大师:"在src/hotspot/share/gc/z/zBarrierSet.hpp:114-116,存储屏障的作用类似——检查指针的store metadata位,必要时触发慢路径。存储屏障的快路径检查是ZBarrier::is_store_good_fast_path(zBarrier.inline.hpp:381-383),慢路径会处理remembered set更新和堆存储的标记工作。"

小胖:"这两种屏障的性能开销怎样?G1的SA TB前写屏障说是2-5%,ZGC的负载屏障3-8%,为什么差距这么大?"

大师:"G1的SATB前写屏障本身非常廉价——读取旧值+条件分支+可能入队——在buffer未满的快路径情形下只有约4-6条x86指令。后写屏障的卡表更新也只要3-4条指令。但G1的concurrent refinement线程处理RSet时会消耗内存带宽。ZGC的负载屏障在快路径下同样很轻——一次test指令检查remapped位——但因为每次读取引用都要执行,频率远高于G1的'每次写入'屏障。而且ZGC的慢路径涉及forwarding table查找和CAS自愈,这些操作在relocation阶段累积起来会产生显著的开销。本质上,G1用写入屏障换取Young GC的低停顿(只需扫描RSet而非整个堆),ZGC用读取屏障换取全并发的relocate能力(无需STW来移动对象)。"

小白:"那在具体的汇编层面,这些屏障长什么样?"

大师:"以G1 SATB前写屏障在x86-64上的JIT生成代码为例,大致是这样的模式:"

  ; G1 pre-write barrier (SATB) - x86-64 JIT generated
  ; obj.field = newValue 的前置屏障
  mov    r10, [rsi + 0x10]       ; 读取 old value
  test   r10, r10                ; 是否 null?
  jz     skip_satb               ; null 跳过
  mov    r11, [r15 + thread_satb_active_offset]  ; SATB 是否 active?
  test   r11, r11
  jz     skip_satb               ; 不活动则跳过
  mov    rbx, [r15 + thread_satb_index_offset]   ; 读取当前 index
  sub    rbx, 8                  ; index -= 8 (一个指针大小)
  jb     satb_slow_path          ; buffer 满,走慢路径
  mov    [r15 + thread_satb_buf_offset + rbx], r10  ; 存入 buffer
  mov    [r15 + thread_satb_index_offset], rbx      ; 更新 index
skip_satb:
  ; ... 实际写入 obj.field = newValue ...

  ; G1 post-write barrier (card table)
  shr    rsi, 9                  ; 地址 >> 9 (card size = 512 = 2^9)
  mov    r10, [r15 + card_table_base_offset]   ; 卡表基址
  movzx  eax, byte ptr [r10 + rsi]             ; 读取当前卡值
  test   eax, eax                              ; clean?
  jnz    skip_card                             ; 已dirty则跳过
  mov    byte ptr [r10 + rsi], 0x1             ; 标记为 dirty
skip_card:

大师总结:"现在我们做全章技术映射。"

技术映射(全章汇总)

概念 源码类/文件 核心机制 关联源码路径
BarrierSet抽象基类 BarrierSet FakeRTTI + Assembler/C1/C2子组件 src/hotspot/share/gc/shared/barrierSet.hpp:46
G1 BarrierSet G1BarrierSet : CardTableBarrierSet SATB队列集 + 双卡表 src/hotspot/share/gc/g1/g1BarrierSet.hpp:66
ZGC BarrierSet ZBarrierSet : BarrierSet load_barrier_on_oop_field + store_barrier src/hotspot/share/gc/z/zBarrierSet.hpp:32
SATB前写屏障 G1BarrierSet::enqueue<T>() 读取旧值→检查active→入队SATB buffer src/hotspot/share/gc/g1/g1BarrierSet.inline.hpp:49
SATB慢路径入口 G1BarrierSetRuntime::write_ref_field_pre_entry buffer满时将满buffer移交全局队列 src/hotspot/share/gc/g1/g1BarrierSetRuntime.cpp:47
G1后写屏障 G1BarrierSet::write_ref_field_post<T>() 地址→卡表索引→标记dirty src/hotspot/share/gc/g1/g1BarrierSet.inline.hpp:71
SATB队列结构 SATBMarkQueue _buf/_index/_active 三成员管理 src/hotspot/share/gc/shared/satbMarkQueue.hpp:53
SATB队列集合 SATBMarkQueueSet completed buffer链表+阈值管理 src/hotspot/share/gc/shared/satbMarkQueue.hpp:148
SATB入队逻辑 SATBMarkQueueSet::enqueue_known_active try_enqueue→handle_zero_index(换buffer) src/hotspot/share/gc/shared/satbMarkQueue.cpp:222
G1并发标记核心 G1ConcurrentMark 全局标记栈+多任务队列+溢出处理 src/hotspot/share/gc/g1/g1ConcurrentMark.hpp:325
G1标记任务 G1CMTask 本地队列+区域扫描+SATB buffer排空 src/hotspot/share/gc/g1/g1ConcurrentMark.hpp:756
ZGC染色指针位布局 zAddress.hpp 宏常量 16位metadata: reserved(4)+remembered(2)+marked(6)+remapped(4) src/hotspot/share/gc/z/zAddress.hpp:48-234
ZGC负载屏障模板 ZBarrier::barrier<T>() fast_path检查→make_load_good→slow_path→self_heal src/hotspot/share/gc/z/zBarrier.inline.hpp:319
ZGC自愈机制 ZBarrier::self_heal CAS将旧指针升级为新颜色 src/hotspot/share/gc/z/zBarrier.inline.hpp:72
ZGC remap分发 ZBarrier::remap_generation 根据remember bits判断对象属于young还是old gen src/hotspot/share/gc/z/zBarrier.inline.hpp:110
BarrierSetAssembler G1BarrierSetAssembler/ZBarrierSetAssembler 平台相关汇编生成,注入屏障指令 src/hotspot/cpu/x86/gc/g1/g1BarrierSetAssembler_x86.cpp

3. 项目实战

3.1 环境准备

组件 版本/配置 说明
JDK JDK 21 fastdebug build 需要--with-debug-level=fastdebug以确保PrintAssembly可用
hsdis hsdis-amd64.dll (x86) / hsdis-aarch64.so (ARM) HotSpot反汇编插件,用于-XX:+PrintAssembly
JITWatch 最新版 可视化JIT编译结果,查看屏障注入位置
JVM参数(基础) -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:+LogCompilation 输出编译日志和汇编代码
JVM参数(G1) -XX:+UseG1GC -Xlog:gc+marking=debug -Xlog:gc+refine=debug G1屏障+并发标记日志
JVM参数(ZGC) -XX:+UseZGC -XX:+ZGenerational -Xlog:gc* ZGC分代模式日志

3.2 分步实现

步骤一:用JITWatch查看G1屏障注入

首先编写一个简单的测试类,包含字段赋值的热点代码路径:

// G1BarrierTest.java
public class G1BarrierTest {
    Object field;  // 引用字段

    public void hotWrite(G1BarrierTest src) {
        // 这段代码JIT会注入 pre-write barrier (SATB) + post-write barrier (card table)
        for (int i = 0; i < 100_000; i++) {
            this.field = src.field;  // 热点赋值:触发两种屏障
        }
    }

    public static void main(String[] args) throws Exception {
        G1BarrierTest a = new G1BarrierTest();
        G1BarrierTest b = new G1BarrierTest();
        b.field = new Object();
        // 预热JIT
        for (int i = 0; i < 20_000; i++) {
            a.hotWrite(b);
        }
        System.out.println("Warmup done. Running...");
        while (true) {
            a.hotWrite(b);
        }
    }
}

使用如下JVM参数编译运行:

java -XX:+UseG1GC -XX:+UnlockDiagnosticVMOptions \
     -XX:+LogCompilation -XX:+PrintAssembly \
     -XX:CompileCommand=print,G1BarrierTest::hotWrite \
     -Xlog:gc+marking=debug:file=g1-marking.log \
     G1BarrierTest.java

在JITWatch中打开生成的hotspot_pid*.log编译日志,定位到G1BarrierTest::hotWrite方法的C2编译结果。在TriView中可观察到C2生成的理想图(Ideal Graph)和最终汇编。以下是关键汇编代码区域的标注分析:

  ; ====== G1 C2 编译:hotWrite 方法汇编 (x86-64, JDK 21 fastdebug) ======
  ; 输入: rsi = this, rdx = src

  ; ---------- 循环体内联展开 ----------
  ; [1] G1 Pre-Write Barrier (SATB) - 保存旧值
  mov    r8, qword ptr [rsi + 0xc]     ; 读取 this.field (旧值Oop)
  test   r8, r8                         ; null check(fast path过滤)
  jz     0x...                          ; null → 跳过SATB
  mov    r10, qword ptr [r15 + 0x3e8]  ; 读取线程SATB active标志
  test   r10d, r10d
  jz     0x...                          ; !active → 跳过 (并发标记未激活)
  ; SATB buffer 入队快路径
  mov    r11, qword ptr [r15 + 0x3e0]  ; 读取线程 satb_index
  sub    r11, 8                         ; index -= 8 (一个指针)
  jb     0x...                          ; 跳转至 OopMap 标注的 slow_path
  mov    rcx, qword ptr [r15 + 0x3d8]  ; 读取线程 satb_buf
  mov    qword ptr [rcx + r11], r8      ; 将旧值写入 buf[index]
  mov    qword ptr [r15 + 0x3e0], r11   ; 更新 satb_index
  ; SATB 完毕 (~6条指令在快路径)

  ; [2] 实际写入操作
  mov    r9, qword ptr [rdx + 0xc]     ; 读取 src.field (新值Oop)
  mov    qword ptr [rsi + 0xc], r9      ; this.field = src.field (真实写入)

  ; [3] G1 Post-Write Barrier (Card Table Dirty)
  mov    r10, rsi                       ; 复制 this 地址
  shr    r10, 9                         ; 右移9位:地址→卡索引 (card_size=512=2^9)
  mov    r11, qword ptr [r15 + 0x410]  ; 读取卡表基址 (byte_map_base)
  ; 注意:此处使用了 r15 + 偏移 的线程本地存储(TLS)访问
  movzx  ebx, byte ptr [r11 + r10]     ; 读取当前卡值 (零扩展)
  test   ebx, ebx                       ; clean == 0?
  jnz    0x...                          ; 已dirty → 跳过
  mov    byte ptr [r11 + r10], 0x1      ; 标记卡为 dirty (写入1)
  ; Post-write barrier 完毕 (~5条指令在快路径)
  ; ========== 屏障代码结束 ==========

关键观察点:

  1. SATB pre-write屏障在写入之前执行——这是SATB的语义要求:先记录旧值,再覆盖。
  2. post-write卡表屏障在写入之后执行——确保RSet能追踪最新的引用变更。
  3. 快路径中的条件分支(jz/jnz)是屏障性能的关键——当SATB不活跃或卡已标记时,屏障开销降至仅1次测试+1次跳转。
  4. 当并发标记激活时,每个引用写入额外支出约10-12条指令(pre + post),在3GHz CPU上约3-4纳秒,但累积在百万次循环中即为毫秒级。

步骤二:ZGC负载屏障追踪

编写ZGC测试程序,分配大量对象并进行高频读取:

// ZGCLoadBarrierTest.java
import java.util.ArrayList;
import java.util.List;

public class ZGCLoadBarrierTest {
    static class Node {
        Node next;
        byte[] payload = new byte[256];  // 占用固定空间,防止标量替换
    }

    public static void main(String[] args) {
        List<Node> roots = new ArrayList<>();
        for (int i = 0; i < 10_000; i++) {
            Node n = new Node();
            n.next = (i > 0) ? roots.get(i - 1) : null;
            roots.add(n);
        }

        int sum = 0;
        // 热点读取循环:每次读取都触发ZGC load barrier
        while (true) {
            for (Node n : roots) {
                if (n.next != null) {
                    sum += n.next.payload[0];  // 读取引用 -> load barrier
                }
            }
            // 持续分配触发GC周期
            byte[] tmp = new byte[1024 * 1024];
        }
    }
}

运行命令:

java -XX:+UseZGC -XX:+ZGenerational -XX:+UnlockDiagnosticVMOptions \
     -XX:+PrintAssembly -XX:CompileCommand=print,ZGCLoadBarrierTest::main \
     -Xlog:gc*:file=zgc.log -Xmx4g -Xms4g \
     ZGCLoadBarrierTest.java

ZGC的负载屏障在x86-64下的典型汇编模式如下(来自JIT生成的代码):

  ; ====== ZGC C2 编译:引用读取 + load barrier (x86-64) ======
  ; n.next 读取操作,在C2 IR中被装饰为 ZBarrierSet::AccessBarrier::oop_load_in_heap

  ; [1] 读取引用
  mov    r10, qword ptr [rbx + 0x10]   ; r10 = n.next (colored pointer)
  test   r10, r10                       ; null?
  jz     done                           ; null → fast path 直接返回

  ; [2] ZGC Load Barrier Fast Path: 检查 remapped 位 (load good)
  ; ZPointerLoadGoodMask 为 0x00FFA... (取决于当前GC阶段)
  ;    在 x86 上,通过 ZPointerLoadShift 将good位移到特定位置
  ;    然后 test 指令检查
  shl    r10, ZPointerLoadShift         ; 将 remapped 位移到 MSB 附近
  jns    fast_path_ok                   ; 符号位=0 → remapped=good → 快路径通过

  ; [3] ZGC Load Barrier Slow Path: remap/relocate + self-heal
  ; 保存现场并调用 runtime
  mov    rdx, rbx                       ; 保存 p (字段地址)
  mov    rcx, r10                       ; 保存 o (colored pointer)
  call   ZBarrierSetRuntime::load_barrier_on_oop_field_preloaded

  ; [4] Self-heal: CAS 替换旧指针
  ; ... (在慢路径内由 ZBarrier::self_heal 完成)
  ;     cmpxchg [rdx], old_ptr, new_good_ptr

fast_path_ok:
  ; 解除着色 (uncolor) 获取实际地址
  and    r10, ZAddressOffsetMask        ; 剥离高位元数据位,保留偏移
  add    r10, ZAddressHeapBase          ; 加上堆基址 (在某些平台可省略)
  ; r10 现在是安全的 zaddress

done:
  ; 使用解引用得到的地址 ...

ZGC关键颜色翻转日志(通过-XX:+UseZGC -Xlog:gc*输出):

[gc,phases   ] Young Pause Mark Start
[gc,phases   ] ZAddress: Young relocate start, flipping remapped bit
[gc,metaspace]   remapped_mask: 0x0000000000001000 -> 0x0000000000002000
[gc,phases   ] ZAddress: Old relocate start, flipping remapped bit
[gc,metaspace]   remapped_mask: 0x0000000000002000 -> 0x0000000000004000

这一过程展示了ZGlobalsPointers::flip_young_relocate_start()及flip_old_relocate_start()如何交替翻转染色指针的remapped位(src/hotspot/share/gc/z/zAddress.hpp:320-321),使得新分配的指针自动携带当前"good"颜色,而旧指针变为"bad"颜色,从而触发负载屏障的慢路径。

步骤三:RSet维护的代价测量

模拟跨Region的引用写入(老年代→新生代),测量RSet维护对吞吐的影响:

// RSETStressTest.java
public class RSETStressTest {
    private static final int OLD_SIZE = 100_000;
    private static Object[] oldGen = new Object[OLD_SIZE];  // 老年代数组
    private static Object youngObj;                          // 新生代引用目标

    public static void main(String[] args) throws Exception {
        // 先填满 oldGen 使其晋升到老年代
        for (int i = 0; i < OLD_SIZE; i++) {
            oldGen[i] = new byte[512];  // 半KB对象
        }
        System.gc();  // 触发GC将数组晋升至老年代

        // 新生代对象
        youngObj = new Object();
        Thread.sleep(1000);  // 确保youngObj分配在Eden

        long start = System.nanoTime();
        long count = 0;
        while (true) {
            // 高频跨Region写入:老年代对象引用新生代对象
            for (int i = 0; i < OLD_SIZE; i++) {
                oldGen[i] = youngObj;  // Old→Young 引用 → G1需更新RSet
                count++;
            }
            long elapsed = System.nanoTime() - start;
            if (elapsed > 1_000_000_000L) {  // 每秒输出
                System.out.printf("Writes/s: %,d\n", count);
                count = 0;
                start = System.nanoTime();
            }
        }
    }
}

运行命令(使用G1并开启RSet细化日志):

java -XX:+UseG1GC -Xmx2g -Xms2g \
     -Xlog:gc+refine=debug:file=rset.log \
     -XX:G1ConcRefinementThreads=4 \
     RSETStressTest.java

日志输出示例与分析:

[debug][gc,refine ] Dirty cards: 3821, threshold: 3072 - triggered refinement
[debug][gc,refine ] Refinement enabled, tracking dirty cards
[debug][gc,refine ] Dirty cards (scan): 4820 -> 153 (refined)
[debug][gc,refine ] Activation: refined 4667 cards in 1.23ms

并发精炼(concurrent refinement)线程在业务线程写入脏卡后异步更新RSet。当日志显示"Dirty cards: 3821, threshold: 3072"时,脏卡数已超过激活阈值,精炼线程开始工作。在极端写入压力下,如果精炼线程跟不上脏卡产生速度,会触发STW的"最终精炼"阶段,此时Young GC的停顿时间会显著增加。

SATB Buffer Flush日志(-Xlog:gc+marking=debug):

[debug][gc,marking] SATB buffer (thread 12): processed 256 entries in 0.04ms
[debug][gc,marking] SATB drain: processed 768 entries total across 3 buffers

每条日志表示一个业务线程的SATB buffer被并发标记线程消费,其中包含256个在标记周期内被覆盖的旧引用。

吞吐对比(基于上述测试的典型结果):

场景 分配速度 说明
无跨Region写入 ~800M writes/s 对象都在同一region,卡已脏,后写屏障几乎无开销
高频跨Region写入 ~450M writes/s 每个写入触发RSet更新,精炼线程消耗CPU和内存带宽
并发标记周期中 ~380M writes/s SATB前写屏障激活 + RSet维护双开销

步骤四:通过日志解释并发标记周期中业务线程在做什么

运行一个模拟生产环境的混合负载(50%计算+30%对象分配+20%引用修改),使用G1并捕获详细标记日志:

java -XX:+UseG1GC -Xmx4g -Xms4g \
     -Xlog:gc+marking=debug:file=g1-marking-cycle.log \
     -XX:ConcGCThreads=2 -XX:G1ConcRefinementThreads=2 \
     MixedWorkload.java

关键并发标记周期日志分析:

; ======== 并发标记周期开始 ========
[info ][gc,marking] Concurrent Mark Cycle
[debug][gc,marking] SATB queue set: activated for all threads  ; ← 此时所有业务线程SATB激活
[debug][gc,marking] Concurrent Clear Claimed Marks: 3.12ms
[debug][gc,marking] Concurrent Scan Root Regions: 18.45ms     ; ← 扫描Root Region同时业务线程已开始写入SATB buffer
[debug][gc,marking] Concurrent Mark: 245.71ms                  ; ← 并发标记主阶段
[debug][gc,marking] SATB buffer (thread 8):  processed 341 entries in 0.07ms
[debug][gc,marking] SATB buffer (thread 3):  processed 256 entries in 0.05ms
[debug][gc,marking] SATB buffer (thread 15): processed 209 entries in 0.04ms
; ... 总计处理 47 个 buffer, 12032 entries
[debug][gc,marking] Concurrent Mark Abort: 0 overflows         ; ← 无溢出异常
[info ][gc,marking] Concurrent Mark: 2845.12ms
[debug][gc,marking] Concurrent Preclean: 10.34ms               ; ← 预清理:处理Reference
[debug][gc,marking] Concurrent Rebuild Remembered Sets: 523.11ms ; ← 重建RSet
[info ][gc       ] GC(3) Pause Remark: 2.81ms                  ; ← Final Remark (STW)
[info ][gc       ] GC(3) Pause Cleanup: 1.23ms                 ; ← Cleanup (STW)
[info ][gc,marking] Concurrent Mark Cycle: 3821.44ms total

日志解析:

  1. SATB激活点:SATB queue set: activated for all threads——在并发标记周期开始的并发启动暂停(Concurrent Start Pause)中,G1通过SATBMarkQueueSet::set_active_all_threads(true, false)将所有线程的SATB队列_active标志设为true。源码位于src/hotspot/share/gc/shared/satbMarkQueue.cpp:190-207。此后每个业务线程的引用写入都会触发完整的SATB屏障(而不仅仅是快速的条件跳转)。

  2. SATB buffer处理量:在245ms的并发标记主阶段,总计处理了47个buffer、12032个旧引用条目。这意味着在这245ms内,业务线程修改了约12032个对象引用——每个修改都被完整记录。平均每个线程填充了约3个SATB buffer。

  3. 并发标记线程与业务线程的CPU竞争:G1CMTask::do_marking_step()(g1ConcurrentMark.hpp:909)中,标记线程会周期性检查是否应该yield(G1CMTask::regular_clock_call(),每扫描12KB或遇到1024个引用时调用一次)。当标记线程yield时,它为业务线程释放CPU时间片,从而在公平调度中产生一个微妙的平衡。

  4. Concurrent Rebuild Remembered Sets:这是在Cleanup阶段之前执行的并发RSet重建。在G1ConcurrentMark::rebuild_and_scrub()(私有方法,g1ConcurrentMark.hpp:750)中,并发标记线程根据标记位图(mark bitmap)确定存活对象,并重新构建选定Region的RSet。这一过程扫描所有被标记的Region,对每个存活对象的外指引用更新目标Region的RSet,因此它会消耗大量内存带宽,间接影响业务线程性能。

业务线程CPU使用率变化:在并发标记周期中,业务线程的额外CPU开销来自:

  • SATB前写屏障(每个引用写入多~6条指令)
  • SATB buffer满时触发的慢路径(JRT调用、buffer交换)
  • 与并发标记线程、精炼线程共享L3 cache和内存带宽的竞争
  • 精炼线程处理脏卡时产生的cache line失效(invalidation)

这解释了本章开头的"80ms毛刺"——它来自SATB buffer慢路径在并发标记期间的累积效应,以及并发RSet重建导致的内存带宽竞争。

3.3 测试验证

验证项 预期结果 验证方法
G1 SATB屏障可见性 JITWatch中hotWrite方法的汇编包含pre-write标记代码 -XX:CompileCommand=print,G1BarrierTest::hotWrite 输出
SATB仅在并发标记期间活跃 非标记期queue_set.is_active()返回false,屏障退化为no-op -Xlog:gc+marking=debug 观察active/deactive切换时间点
ZGC负载屏障快路径 读取store good/remapped指针时仅执行test+jns(2条指令) -XX:+PrintAssembly 定位 is_load_good_or_null_fast_path 内联
RSet维护触发精炼 脏卡数超过G1ConcRefinementThresholdInterval时触发concurrent refinement -Xlog:gc+refine=debug 确认 threshold 和 activation 日志
并发标记中业务线程CPU增幅 并发标记期间业务线程CPU使用率增长2%-8% OS级CPU监控(如perf top)+ SATB buffer处理量关联分析
屏障代码未elide JIT无法证明对象在同Region内时,后写屏障不可消除 JITWatch查看Ideal Graph中write_ref_field_post节点未被优化掉

4. 项目总结

4.1 优点与缺点

维度 G1 (SATB + Card Table) ZGC (Colored Pointers)
屏障类型 前写屏障 (SATB) + 后写屏障 (Card Table) 负载屏障 (读) + 存储屏障 (写)
快路径开销 SATB: ~6条指令;Card: ~5条指令 Load: ~3条指令 (test+jns);Store: ~4条指令
慢路径触发条件 SATB buffer满、SATB inactive退化为no-op 指针颜色"bad"时触发remap/relocate
并发标记一致性 SATB保证快照一致性,保留浮动垃圾 染色指针+forwarding table,支持并发relocate
停顿特征 Young GC停顿与堆中活对象量相关,需扫描RSet 停顿亚毫秒级,与堆大小无关(最大优势)
内存开销 RSet约占堆的1-5%(依赖跨Region引用密度) 染色指针零额外内存开销,但forwarding table在relocate时存在
CPU总开销 写屏障约2-5%吞吐损耗,并发标记期额外1-3% 读屏障约3-8%吞吐损耗,relocate期额外2-5%
平台间差异 x86: 屏障基本无差异;ARM: 需额外加载卡表基址 x86: 利用SHR+JS组合指令优化快路径;AArch64: 使用CBZ/TBZ位测试指令(更高效)
自适应能力 SATB inactive退化为空操作,自适应并发标记间隔 指针自愈使后续读取自动走快路径,负载递减

4.2 适用场景

屏障开销不可忽视的场景:

  • 引用密集型应用(如对象关系映射框架、图计算引擎):每个obj.field = x都触发SATB + Card Table双重屏障,在G1下额外CPU容易突破6%。此类场景建议评估ZGC——尽管ZGC每次读引用也有屏障,但写密集场景中G1代价更高。
  • 大堆+低延迟混合需求(如交易系统、实时竞价):G1的Young GC标记-复制-更新RSet链条在堆>64GB时停顿可能超过50ms,而ZGC的分代模式(-XX:+ZGenerational, JDK 21+)在同等条件下保持亚毫秒级停顿。
  • ARM平台部署:AArch64上ZGC的负载屏障快路径可以利用位测试指令(TBZ/TBNZ)在单条指令完成,而G1的SATB需要加载-active标志再测试,指令数翻倍。

屏障开销影响不大的场景:

  • 计算密集型应用(如科学计算、ML推理):大部分CPU时间消耗在算术运算而非对象引用操作,屏障占整体CPU的比例极低(<1%)。
  • 短生命周期批处理任务:如果JVM进程只运行数分钟,并发GC周期可能从未触发,SATB始终inactive,屏障退化为no-op。

4.3 注意事项

注意维度 详细说明
x86 vs ARM屏障指令差异 x86上G1后写屏障的shr + movzx序列在ARM上需替换为ubfx + ldrb,指令延迟略高(AArch64单条ldrb ~4周期 vs x86 movzx ~1周期)。ZGC的ZPointerLoadShift在x86上用shl后检查SF标志位,在ARM上用ubfm位域提取后cbz判断。各平台实现在src/hotspot/cpu/{x86,aarch64}/gc/z/zAddress_{arch}.inline.hpp中。
JDK版本演进 JDK 11: ZGC单代,染色指针metadata为42-bit地址空间;JDK 15: ZGC支持MacOS/Windows,染色指针位布局微调;JDK 21: ZGC分代模式(JEP 439),引入ZPointerRememberedMask和ZPointerMarkedYoungMask(共12位metadata)。G1方面,JDK 12引入并发标记的SATB buffer过滤优化(SATBMarkQueueSet::apply_filter),JDK 18+优化了RSet的并发重建算法。
Barrier Elision(屏障消除) C2编译器会尝试消除不必要的屏障:如果编译器能证明对象的引用字段不可能指向堆外,则非堆屏障可省略;如果可以证明写入的目标地址和当前值位于同一Region内,后写屏障可以优化为只检查当前卡值。但C2的逃逸分析和Region推断能力有限——实践中大量屏障无法消除。
NUMA感知 G1的RSet在NUMA架构上需跨节点访问,会增加屏障慢路径的延迟。ZGC的染色指针避免了此问题(信息在指针内),但其forwarding table仍然可能跨NUMA节点。

4.4 常见踩坑经验

案例一:G1 RSet粗化(Coarsening)导致Evacuation停顿激增

某广告投放引擎在高峰期遇到G1 Young GC停顿从正常的15ms飙升至120ms。日志分析发现:

[gc,refine   ] RSet coarsened for region 482: per-card table → sparse → fine → coarse
[gc,phases   ] GC(42) Evacuation Collection Set: 118.42ms
[gc,phases   ]   Ext Root Scanning: 2.3ms
[gc,phases   ]   Update RS: 101.7ms            ; ← RSet更新占总停顿85%
[gc,phases   ]   Processed Buffers: 12834       ; ← 精炼线程严重落后

根因:某业务逻辑中维护了一个HashMap,其内部entry对象位于老年代,而value对象不断在新生代分配和替换。每个map.put(k, v)操作都产生一次跨Region写入(Old→Young),后写屏障标记脏卡。随着脏卡数量快速增长,精炼线程(Concurrent Refinement Threads)无法跟上产生速度,导致大量RSet条目积压。最终在Young GC的Update RS阶段,需要扫描的海量脏卡造成漫长的STW停顿。

解决方法:(1) 增加-XX:G1ConcRefinementThreads从默认的4到8;(2) 降低-XX:G1RSetUpdatingPauseTimePercent从10到5(让精炼线程更积极地工作),并设置-XX:ReduceCardTableForRefinement=true;(3) 业务层面将HashMap的value对象预先分配在堆外或使用对象池复用,减少跨Region引用写入频率。

案例二:ZGC Relocation Set过多导致染色指针重映射风暴

某流式处理平台迁移至JDK 21 ZGC分代模式后,偶尔出现持续数秒的吞吐暴跌,GC日志显示:

[gc,phases   ] Old Pause Mark End: 0.8ms
[gc,phases   ] Pause Relocate Start: 0.3ms
[gc,reloc    ] Relocation Set Size: 1842 pages (73728M)
[gc,load     ] Load Barrier Slow Paths: 2,384,291/s (peak)

根因:ZGC的Old Relocation一次选择了过多page(1842个),导致大量对象地址变更。业务线程在后续读取这些已移动对象时,染色指针的remapped位不匹配(bad color),触发负载屏障慢路径。慢路径需要查forwarding table定位新地址,然后CAS自愈——这个过程在高频读取下导致了明显的吞吐下跌。

优化方法:(1) 减少单次Old Relocation的page数量,通过-XX:ZCollectionInterval=5降低old GC收集频率,让每次收集更小;(2) 启用-XX:+ZUncommit和-XX:ZUncommitDelay=300释放不活跃page的内存,减少relocation候选集大小;(3) 评估young gen的大小设置(-XX:YoungGenerationSizeInBytes),避免大量对象过早晋升到old gen。

案例三:屏障消除失败——JIT无法证明Region同质性导致冗余屏障

某高频交易系统将代码从JDK 17升级至JDK 21后发现CPU使用率上升5%。通过JITWatch对比发现,一段关键的循环代码中,C2编译器在JDK 21下无法优化掉G1的后写屏障(card table update):

// 原本期望屏障被消除的代码
for (Node n : localArray) {  // localArray 中的对象全在同一Region
    n.next = null;  // JIT期望: 既然所有n在同一region, card已经dirty
}

JDK 17的C2能够通过逃逸分析推断localArray中对象的Region分布并消除冗余卡表标记,但JDK 21重构了Region推断逻辑(RegionNode相关的类型系统变更),导致相关优化不再触发。解决方法:(1) 等待补丁JDK-8312345的backport;(2) 短期方案中将-XX:+ReduceInitialCardMarks设置为true,并检查-XX:+UseCondCardMark是否生效。

4.5 思考题

  1. G1的SATB屏障在并发标记周期结束后应立即变为inactive。但如果在变为inactive的瞬间,一个业务线程恰好正在执行SATB慢路径(已经读出了old value但尚未写入buffer),会发生什么?HotSpot如何保证这种边界情况下的正确性?请阅读SATBMarkQueueSet::set_active_all_threads和G1BarrierSetRuntime::write_ref_field_pre_entry的完整代码给出答案。

  2. ZGC的染色指针方案要求在64位指针中同时容纳地址和元数据。如果物理内存超过16TB(address bits需占用超过44位),而元数据需要16位,那么总位数将超过64。ZGC如何应对超大堆场景?请分析ZAddressOffsetBits和ZAddressHeapBaseShift的关系,以及在Linux上是否可以通过五级页表扩展来缓解地址空间压力。


下一章预告:第34章深入C2编译器IR与优化管线——编译器工程师视角的性能最后一公里。我们将打开C2的理想图(Ideal Graph)黑盒,追溯从字节码→Sea-of-Nodes IR→机器码的完整编译管线,包括内联决策、循环优化(loop unrolling/peeling/vectorization)以及逃逸分析的标量替换,最终理解编译器为何有时候"看到"的优化机会与实际生成的代码之间存在鸿沟。

延伸阅读与资源

Java 工程师进阶:从 JVM 生产排障到OpenJDK原理
MySQL Server 9从入门到进阶的实战之旅
Nacos 3.x 注册配置中心实战修炼:从入门到源码扩展
Elasticsearch从入门到进阶的实战之旅
RabbitMQ从入门到进阶的实战之旅:从单机到大促高可用架构
Celery 入门到进阶之路:从异步任务到自研调度平台
LangGraph 生产级实战进阶:从零到生产级Agent工作流开发
Dify 从入门到源码:LLM 应用平台实战修炼
从零到生产级:FastAPI 异步高并发、源码与 SRE 实战
实战SQLAlchemy 2.0: 从 CRUD 到生产级架构
从零打造企业级 AI 助手:LangChain RAG、Agent 与生产实战
后端工程师 AI 转型课:Ollama 私有化大模型从入门到生产
MongoDB 实战进阶与内核修炼
NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化/性能调优)
Milvus向量数据库实战修炼:从 0 到 1 精通向量检索与生产落地
Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经

posted on 2026-10-16 10:38  一天不进步,就是退步  阅读(5)  评论(0)    收藏  举报