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条指令在快路径)
; ========== 屏障代码结束 ==========
关键观察点:
- SATB pre-write屏障在写入之前执行——这是SATB的语义要求:先记录旧值,再覆盖。
- post-write卡表屏障在写入之后执行——确保RSet能追踪最新的引用变更。
- 快路径中的条件分支(
jz/jnz)是屏障性能的关键——当SATB不活跃或卡已标记时,屏障开销降至仅1次测试+1次跳转。 - 当并发标记激活时,每个引用写入额外支出约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
日志解析:
-
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屏障(而不仅仅是快速的条件跳转)。 -
SATB buffer处理量:在245ms的并发标记主阶段,总计处理了47个buffer、12032个旧引用条目。这意味着在这245ms内,业务线程修改了约12032个对象引用——每个修改都被完整记录。平均每个线程填充了约3个SATB buffer。
-
并发标记线程与业务线程的CPU竞争:
G1CMTask::do_marking_step()(g1ConcurrentMark.hpp:909)中,标记线程会周期性检查是否应该yield(G1CMTask::regular_clock_call(),每扫描12KB或遇到1024个引用时调用一次)。当标记线程yield时,它为业务线程释放CPU时间片,从而在公平调度中产生一个微妙的平衡。 -
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 思考题
-
G1的SATB屏障在并发标记周期结束后应立即变为inactive。但如果在变为inactive的瞬间,一个业务线程恰好正在执行SATB慢路径(已经读出了old value但尚未写入buffer),会发生什么?HotSpot如何保证这种边界情况下的正确性?请阅读
SATBMarkQueueSet::set_active_all_threads和G1BarrierSetRuntime::write_ref_field_pre_entry的完整代码给出答案。 -
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的网络实战圣经

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号