ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

G1 写屏障双子:写前(SATB)与写后(脏卡)的设计与实现

G1 写屏障双子:写前(SATB)与写后(脏卡)的设计与实现 关键词写屏障、SATB、写前屏障、写后屏障、脏卡、卡表、RSet、记忆集、并发标记、疏散、可达性拷贝姊妹篇《G1 并发标记设计思想与解决方案》g1-concurrent-marking.md讲 SATB 如何支撑并发标记本文把 SATB 和与它同生同灭的另一道屏障——脏卡写后屏障——放在一起交代它们的分工、配合与实现细节。1. 背景一次引用写入要同时服务两个目标G1 的运行可以拆成两个半边并发标记跑在应用线程旁边负责算出T0 时刻哪些对象活着并写进位图。它依赖 SATB 快照来保证这个存活判定在并发改图下不失真防漏标。疏散(Evac)是完全 STW 的负责把活对象搬走。它依赖 RSet(记忆集)来快速找到谁引用了我要搬的区域而不必全堆扫描。这两件事都需要在应用线程一边跑、一边改引用的混乱中保持正确。而应用线程改引用这件事统一发生在一次对象字段的引用写入上a.fb;// 把对象 a 的字段 f从原来指向的某个对象改成指向 b问题就在这里这一行赋值对 G1 的两个半边都构成了威胁。对并发标记如果a在标记启动T0时是可到达的那么a.f的旧值对象在快照里也可到达。若标记线程还没遍历到a、这条边就被覆盖删掉了标记的遍历就再也抵达不了旧值对象。单独看这并不可怕——旧值真死了不标它是对的旧值还有别的路标记线程自会找到它。真正的危险是Wilson 组合场景删边灰路径被删光的同时某个已扫完的黑对象又长出指向该旧值对象的新边——此时它活着、却又无人能抵达才是漏标。所以需要写前屏障在新值覆盖前把旧值兜住充要条件的完整论证见姊妹篇第 2 节。对疏散当以后某次 GC 要搬走b所在 region 时必须知道有哪些外部指针指向b才能把它们更新成b的新地址。而a.f b恰恰建立了一条从a的 region 指向b的 region 的引用。这条边如果不被记录下来疏散时就找不到a.f搬完b后a.f会变成悬空指针。所以需要一种机制在新值写入后把这条边记进b的 RSet。于是 G1 给每次引用写入同时挂了两道写屏障写前屏障(SATB 预屏障)写入新值之前捕获被覆盖的旧值交给并发标记。写后屏障(脏卡后屏障)写入新值之后把被写入字段所在的卡标脏交给细化线程维护 RSet。两道屏障在同一个赋值点上先后触发各自回答不同的问题。下面分两条线展开再并排对比。2. 一道赋值如何同时触发两道屏障应用线程每写一个引用字段最终都会走到oop_storeoop.inline.hpp。这个函数把赋值拆成三段屏障就嵌在前后// oop.inline.hpp:533templateclassTinlinevoidoop_store(T*p,oop v){if(always_do_update_barrier){oop_store((volatileT*)p,v);}else{update_barrier_set_pre(p,v);// ① 写前屏障SATB捕获旧值oopDesc::encode_store_heap_oop(p,v);// ② 真正把新值写进字段update_barrier_set((void*)p,v,false);// ③ 写后屏障脏卡记录卡}}顺序是严格固定的先捕获旧值 → 再落盘新值 → 再标脏卡。注意第 ① 步传进去的是即将被覆盖的槽位pp里此刻还装着旧值第 ③ 步传进去的是刚写完的槽位p和新值v。两道屏障读的是同一个槽位但时机不同因此看到的内容不同。负责这两道屏障的是同一个 barrier set 对象类型是G1SATBCardTableLoggingModRefBS“G1 带 SATB 且带卡日志的修改引用屏障集”。它继承自G1SATBCardTableModRefBS而后者继承自通用的CardTableModRefBSCardTableModRefBS (通用卡表基类) └─ G1SATBCardTableModRefBS (加写前 SATB 预屏障enqueue 旧值) └─ G1SATBCardTableLoggingModRefBS (加写后脏卡日志dirty card 入队)也就是说SATB 预屏障的逻辑在基类G1SATBCardTableModRefBS里脏卡日志的逻辑在派生类G1SATBCardTableLoggingModRefBS里二者由同一个对象在每次赋值时一起执行。下面分别看。3. 写前屏障SATB 快照的捕获器3.1 它捕获什么写前屏障的核心动作只有一句话在槽位被新值覆盖之前把里面现有的旧引用读出来进队。入口在G1SATBCardTableModRefBS::write_ref_field_pre_staticg1SATBCardTableModRefBS.hpp:64// g1SATBCardTableModRefBS.hpp:64templateclassTstaticvoidwrite_ref_field_pre_static(T*field,oop newVal){T heap_oopoopDesc::load_heap_oop(field);// 读出槽位里的旧值if(!oopDesc::is_null(heap_oop)){enqueue(oopDesc::decode_heap_oop(heap_oop));// 非 NULL 则入 SATB 缓冲}}注意三点它读的是field里当前的内容也就是旧值。参数newVal在这个函数里根本没被用到——因为要抓的是被换下来的那个引用。旧值是 NULL 时直接跳过is_null判断没有对象需要保活无需入队。这是static方法不碰任何 barrier set 内部状态所以可以在编译器生成的内联机器码里直接调用开销极小。3.2 入队与激活开关enqueueg1SATBCardTableModRefBS.cpp:41把旧值压进当前线程自己的SATB 缓冲每线程一个避免竞争// g1SATBCardTableModRefBS.cpp:41voidG1SATBCardTableModRefBS::enqueue(oop pre_val){assert(pre_val-is_oop(true),Error);if(!JavaThread::satb_mark_queue_set().is_active())return;// 关键闸门Thread*thrThread::current();if(thr-is_Java_thread()){JavaThread*jt(JavaThread*)thr;jt-satb_mark_queue().enqueue(pre_val);// 每线程缓冲}else{MutexLockerExx(Shared_SATB_Q_lock,Mutex::_no_safepoint_check_flag);JavaThread::satb_mark_queue_set().shared_satb_queue()-enqueue(pre_val);// GC 线程走共享缓冲}}这里有一道闸门is_active()只有并发标记周期开启期间这道屏障才真正干活平时is_active()为 false直接返回等于这道屏障被关闭。开关由SATBMarkQueueSet::set_active_all_threadssatbQueue.cpp:267在 safepoint 统一翻转所有线程的队列状态// satbQueue.cpp:267voidSATBMarkQueueSet::set_active_all_threads(boolactive,boolexpected_active){assert(SafepointSynchronize::is_at_safepoint(),Must be at safepoint.);_all_activeactive;for(JavaThread*tThreads::first();t;tt-next()){t-satb_mark_queue().set_active(active);}shared_satb_queue()-set_active(active);}为什么要在 safepoint 切因为要保证所有线程要么都开、要么都关不能出现有的线程还在往已关闭的缓冲里写、有的已经开了的不一致窗口。set_active(true)发生在 Initial Mark 那个 STW 暂停里checkpointRootsInitialPost作为标记周期开始的信号之一set_active(false)在 Remark(STW) 收口时执行checkpointRootsFinal此后整个并发周期剩余阶段cleanup 等屏障保持关闭。这也是为什么上一篇文章里说标记开始时把 SATB 屏障打开。3.3 缓冲如何被消费每线程的 SATB 缓冲填充满后会被移入已完成缓冲链表并发标记线程在drain_satb_buffersconcurrentMark.cpp:4258位于CMTask::do_marking_step内里逐个取出把缓冲里的旧引用当作根重新标灰。语义上这正是标记启动那一刻可达的对象即使后来引用被改也仍然标活的实现落点旧值被兜住后由标记线程负责把它及其子树补标完整。缓冲的是oop对象指针本身不是卡、也不是地址——因为 SATB 要保活的是那个具体的旧对象只有拿到 oop 才能把它标灰。捕获旧值、而非旧值所在的卡是由 SATB 的语义目标决定的。4. 写后屏障RSet 的脏卡记录器4.1 它记录什么写后屏障不关心被换下来的旧值是谁它关心的是被写入的这个字段落在哪张卡上。卡card是 G1 把堆按 512 字节默认card_size_in_words 512/8 64字切出来的粗粒度块卡表card table是为每块卡维护的一个字节状态。入口在派生类的write_ref_field_workg1SATBCardTableModRefBS.cpp:176// g1SATBCardTableModRefBS.cpp:176voidG1SATBCardTableLoggingModRefBS::write_ref_field_work(void*field,oop new_val,boolrelease){volatilejbyte*bytebyte_for(field);// 找到字段所在的卡if(*byteg1_young_gen){return;// 年轻代 region 的卡不记录}OrderAccess::storeload();if(*byte!dirty_card){*bytedirty_card;// 把这张卡标脏Thread*thrThread::current();if(thr-is_Java_thread()){JavaThread*jt(JavaThread*)thr;jt-dirty_card_queue().enqueue(byte);// 卡的地址进每线程脏卡缓冲}else{MutexLockerExx(Shared_DirtyCardQ_lock,Mutex::_no_safepoint_check_flag);_dcqs.shared_dirty_card_queue()-enqueue(byte);}}}它的动作是把字段所在卡的卡表字节置为dirty_card再把这张卡的地址不是对象、不是引用而是一字节状态所在的指针压进每线程的脏卡缓冲。同样地每线程一个缓冲避免竞争GC 线程走共享缓冲。两道屏障记录的单位在这里形成鲜明对比SATB 缓冲存的是oop对象指针脏卡缓冲存的是卡字节地址jbyte*一张卡在卡表里的位置。为什么写后屏障记录卡而不是新值b因为 RSet 需要回答的是哪些卡里可能包含指向我这个 region 的引用。疏散时G1 拿到b的 region 的 RSet知道有一堆源 region 引用了它然后去扫描那些源 region 里被标脏的卡就能在卡的内存范围内找出具体的引用a.f并更新成b的新地址。记录卡、而不是记录具体引用是为了让重新扫描、发现当前引用成为可能——卡是一段内存范围扫描它能拿到此刻卡内所有最新的引用。4.2 一道优化同 region 写入跳过write_ref_field_staticg1SATBCardTableModRefBS.cpp:199在真正脏卡之前做了一个判断// g1SATBCardTableModRefBS.cpp:199voidG1SATBCardTableLoggingModRefBS::write_ref_field_static(void*field,oop new_val){uintptr_t field_uint(uintptr_t)field;uintptr_t new_val_uintcast_from_oopuintptr_t(new_val);uintptr_t combfield_uint^new_val_uint;combcombHeapRegion::LogOfHRGrainBytes;// 只看 region 粒度if(comb0)return;// 字段和新值在同一 region跳过if(new_valNULL)return;// 写入 NULL 也跳过// 否则才走 write_ref_field_work 标脏...}两个跳过条件字段和新值落在同一个 regiona.f b且a与b同区时无需记录。因为疏散按 region 整体搬同区内的对象会被一起搬走互相之间的引用在搬完后仍然同区、仍然自洽根本不需要 RSet 来跨区追踪。这是一个安全的省事优化。写入 NULL把某个字段置空意味着少了一条引用。RSet 是允许过时的它是引用的上近似漏记一条已消失的引用最多让 RSet 多一条无用条目疏散时去扫那张卡发现引用已不在属于浪费一点工作量但不会出错。所以置空写入可以放心跳过脏卡。与之相对写前 SATB 屏障没有同 region 跳过这回事——哪怕a和旧值同区只要旧值对象在快照里可到达就必须被兜住否则位图的 T0 快照就缺一块Wilson 组合场景下会真的漏标。这是两道屏障在是否关心同区上的不对称根子上是它们服务的语义不同。4.3 脏卡如何变成 RSet 条目脏卡缓冲由ConcurrentG1RefineThread并发细化线程持续消费。它从JavaThread::dirty_card_queue_set()取已完成缓冲对每个脏卡调用细化闭包最终落到G1RemSet::refine_cardg1RemSet.cpp:441。refine_card的核心逻辑根据卡指针还原出它覆盖的内存范围扫描这段范围里所有引用凡是指向 regionr之外的引用就把它登记进目标 region 的 RSet记录regionr里有指针指向你// g1RemSet.cpp:441 (节选)boolG1RemSet::refine_card(jbyte*card_ptr,uint worker_i,boolcheck_for_refs_into_cset){...HeapWord*start_ct_bs-addr_for(card_ptr);// 卡覆盖的内存起点HeapRegion*r_g1-heap_region_containing(start);// 这张卡属于哪个 region...HeapWord*endstartCardTableModRefBS::card_size_in_words;MemRegiondirtyRegion(start,end);...G1UpdateRSOrPushRefOopClosureupdate_rs_oop_cl(_g1,...);update_rs_oop_cl.set_from(r);// 来源 region rr-oops_on_card_seq_iterate_careful(dirtyRegion,filter_then_update_rs_oop_cl,card_ptr);// 闭包对每个指向 region r 之外的 oop调用 rem_set()-add_reference(..., r)// 即在目标 region的 RSet 里登记来源 region r 有引用指向我}注意 RSet 存的是入边谁指向我a.f b这条边最终登记在b所在 region 的 RSet 里内容是region-of-a引用了我。疏散b的 region 时G1 查它的 RSet就知道要去a所在的 region 找外部指针来更新。这与根扫描正向、已知源读目标正好相反是反向、已知目标查源。细化线程的节奏是受限的它按阈值green_zone / yellow_zone / red_zone调节每轮处理的卡数既保证脏卡不会无限堆积拖慢下次 GC又不至于抢占了应用线程的 CPU。这套采样-限流机制正是 Refine 线程的核心本文只点其消费侧细化节流细节可单独成文。一个容易误会的边界必须点破脏卡 → RSet 这条链只服务疏散不服务标记。RemarkSTW 收口只重扫根集合 排干 SATB 残余checkpointRootsFinalWorkconcurrentMark.cpp:2748不重扫卡表/脏卡。所谓收尾时扫一遍脏卡补标记——不存在千万别往这个方向理解。那标记期间才被引用、位图里却是白对象的东西怎么保命答案是疏散时按可达性拷贝而不是按位图G1ParCopyClosure::do_oop_workg1CollectedHeap.cpp:4553对 CSet 内对象只查转发指针copy_to_survivor_spaceg1ParScanThreadState.cpp:216没有isMarked判断。a.f b建立的新边由本节的脏卡 → RSet 流程登记进b所在 region 的 RSet疏散该 region 时扫 RSet 就能重新找到a.f把b哪怕是白对象拷走。位图只用于 CSet 选择、活字节记账和 humongous 判活——它回答T0 哪些对象活着不回答这个对象现在收不收姊妹篇第 2.5 节。5. 一个具体例子串起两道屏障以a.f b为例其中a在 regionRab在 regionRbRa ≠ Rb且此刻正处于并发标记周期中赋值前: a.f ──► X (X 是某旧对象可能在 Rb 也可能在别处) 执行: a.f b ┌─ 写前屏障(①, 在落盘前) ───────────────────────────┐ │ 读出 a.f 的旧值 X │ │ 若 X 非 NULL → 入线程 SATB 缓冲 │ │ 并发标记线程之后把 X 标灰 → X 即使没人再引用也保活 │ └───────────────────────────────────────────────────┘ ┌─ 真正写入(②) ──────────────┐ │ a.f 现在指向 b │ └────────────────────────────┘ ┌─ 写后屏障(③, 在落盘后) ───────────────────────────┐ │ 找到 a.f 所在的卡 C属于 Ra │ │ 把 C 标脏卡的地址进线程脏卡缓冲 │ │ 细化线程之后扫 C发现 a.f 现指向 b(Rb) │ │ 在 Rb 的 RSet 里登记 Ra 引用了 Rb │ └───────────────────────────────────────────────────┘两道屏障在同一行赋值上各取所需写前屏障拿走旧对象 X交给标记保活写后屏障拿走卡 C交给 RSet 记账。它们互不干扰却共同维持了 G1 两个半边的正确性。图上X 即使没人再引用也保活请带上前提读如果 X 真的死了、且没有任何新边指向它这次保活只是一份浮动垃圾保守无害——写前屏障分不清于是对每次覆盖都兜住。真正性命攸关的是Wilson 组合场景X 的 T0 路径被删的同时某个已扫完的黑对象又引用了 X此时没有写前屏障X 就会漏标但活着完整论证见姊妹篇第 2 节。6. 两道屏障并排对比维度写前屏障SATB 预屏障写后屏障脏卡后屏障触发时机新值写入之前新值写入之后捕获对象槽位里的旧引用值oop被写字段所在的卡卡表字节地址服务目标保证位图是忠实的 T0 快照破 Wilson 组合场景的灰路径被删光半边RSet 维护疏散时按可达性定位并拷走被引用对象记录内容语义“这个旧对象快照时刻可达保活”“这张卡里可能有指向某 region 的引用待扫”激活条件仅并发标记周期is_active()为 true始终开启卡表常驻缓冲结构每线程satb_mark_queue存 oop每线程dirty_card_queue存卡指针jbyte*消费者并发标记线程drain_satb_buffersConcurrentG1RefineThread→refine_card同 region 优化无旧值无论同区都要兜有同区写入、写 NULL 都跳过脏卡旧/NULL 处理NULL 旧值不入队无对象可保活写 NULL 可跳过脏卡RSet 容许过时代码位置G1SATBCardTableModRefBS::write_ref_field_pre_staticg1SATBCardTableModRefBS.hpp:64G1SATBCardTableLoggingModRefBS::write_ref_field_workg1SATBCardTableModRefBS.cpp:1767. 几个容易被忽略的细节(1) 两道屏障由同一个 barrier set 在每次赋值时一起执行。它们不是两套独立设施而是G1SATBCardTableLoggingModRefBS这一个对象上的两个方法由oop_store在前后固定顺序调用。理解这一点就不会把写屏障理解成某一种单一机制。(2) 激活条件完全不同。SATB 预屏障有is_active()闸门平时关闭、标记期开启脏卡后屏障没有这样的闸门卡表是常驻的——因为 RSet 在任何一次疏散前都需要是最新的不随标记周期开关。一道时开时关、一道永远在线是二者最直观的区别。(3) GC 线程自己改引用也要走屏障。不是只有应用线程的赋值才触发。例如引用处理referenceProcessor.cpp:516/518在改动引用链表时会显式调用write_ref_field_pre并传入 NULL 作为新值强制把被摘下的旧引用送进 SATB 缓冲。这说明屏障的普遍性不仅仅针对 mutator只要是在标记周期里改动引用无论是谁改都必须经过 SATB否则快照就被撕裂。脏卡一侧同理GC 内部移动对象后也会通过invalidate把相关卡标脏。(4) 两道屏障记录的单位不同是设计使然。SATB 存 oop是因为它要保活那个具体的旧对象必须拿到对象本身才能标灰脏卡存卡地址是因为 RSet 要的是去哪段内存里重新扫描发现当前引用卡正好是一段可重扫的内存范围。一个要对象、一个要内存区间目的决定了数据结构。(5) RSet 是过近似脏卡允许漏记。写 NULL 跳过脏卡、以及细化线程限流都可能导致 RSet 多算或滞后但 RSet 本就是可能指向我的上近似多一条无用条目只浪费扫描、不破坏正确性。这与 SATB 那边宁可多保活浮动垃圾也绝不漏标的哲学一致两边都选择了保守但绝对正确、代价是可接受的冗余工作。(6) 写后屏障不服务标记疏散拷贝不认位图。收尾的 Remark 从不重扫脏卡——脏卡 → RSet 的消费者只有疏散。而疏散决定拷不拷看的是可达性根 RSet 字段跟随扫描不是标记位图因此标记期间被黑对象重新引用的白对象照样会被拷走保命。位图与疏散是两套独立机制前者供 CSet 选择与记账后者裁决对象生死。这条边界姊妹篇第 2.5 节是理解两道屏障分工的最后一环。8. 小结G1 把应用线程改动引用这件无法避免的事转化成了两套互补的观测写前屏障(SATB)在赋值发生前截取旧引用把它交给定时开启的并发标记保证快照时刻可达的对象不会被漏标——代价是浮动垃圾。写后屏障(脏卡)在赋值发生后标脏字段所在的卡交给常驻的细化线程把跨区引用登记进目标 region 的 RSet保证疏散时能反向找到并修正外部指针。它们同生于一次oop_store一个抓旧值、一个抓卡一个随标记周期开关、一个常驻在线一个缓冲 oop、一个缓冲卡地址。正是这两道屏障的分工与协作让 G1 能够既并发地算存活又增量地搬对象而互不踩踏。最后一条边界别忽略写后屏障的产出RSet只服务疏散、不服务标记——Remark 不重扫脏卡疏散拷贝也不看位图白对象靠 RSet 可达性保命姊妹篇第 2.5 节。两道屏障的分工到此才真正闭环。
返回列表