本文整理 G1 GC 中"脏卡(dirty card)→ 记忆集(RSet)"这条数据通路的整体设计。
覆盖 RSet、DCQ(DirtyCardQueue)、DCQS(DirtyCardQueueSet)三个核心对象的功能与相互关系,并延伸到并发精化线程(Concurrent Refinement Thread,非采样线程)的职能与设计、DCQS 的大小与三档阈值管理、并发消费模型,以及热卡(hot card)管理子系统。
关于单张卡在 refine 线程内部的具体处理流程(
G1RemSet::refine_card及其闭包链),将在后续独立文章中展开,本文不展开该流程。
0. 背景:为什么需要这条通路
G1 以 Region 为最小回收单元,做增量回收时只挑出一部分 Region(收集集,CSet)进行疏散(evacuation)。它面临一个根本问题:疏散一个 Region 里的对象时,必须找到所有"从外面指向这些对象的指针",否则搬完家后会有悬空引用。
最直白的做法是堆全扫描,但那会让暂停时间与堆大小成正比,违背 G1 的低延迟目标。G1 的做法是预先为每个 Region 维护一份"谁指向我"的索引------这就是 RSet(Remembered Set)。有了它,回收时只需扫描 RSet 里登记的来源,不必碰整个堆。
但 RSet 本身也得有人维护。引用关系是在应用运行期由 mutator 建立的,如果每次建立引用都同步更新 RSet,写操作会背上沉重的同步代价。G1 的解法是把"记录"和"落库"拆开:
- 写屏障(write barrier)在引用建立时,只把"哪张卡被改脏了"记进一个线程本地的小缓冲,几乎无开销;
- 一个后台的并发精化线程池,异步地把这些脏卡重扫一遍,把其中的跨区引用写进目标 Region 的 RSet。
中间这两级结构------线程本地脏卡缓冲(DCQ)和全局脏卡队列集(DCQS)------正是本文的主角。下面先讲目的地(RSet),再讲来源(DCQ)和汇聚点(DCQS),最后讲搬运工(并发精化线程)和配套的优化(热卡管理)。
1. RSet:记忆集
1.1 两层结构
RSet 在源码里分两层。全局的 G1RemSet 是协调者,它持有并发精化线程管理器 ConcurrentG1Refine* _cg1r 和卡表 CardTableModRefBS* _ct_bs,对外提供 refine_card()、updateRS()、oops_into_collection_set_do() 等入口。真正的数据落在每个分区的 HeapRegionRemSet 上------它包含 _code_roots(JIT 编译代码里指向本区的引用)和 _other_regions(即 OtherRegionsTable,记录来自其它分区的引用)。
1.2 三级精度
OtherRegionsTable 用三套结构记录"哪个来源分区引用了我",而且引用越多,精度越粗(是降级,不是升级):
| 级别 | 结构 | 触发条件 | 记录内容 |
|---|---|---|---|
| 最细 | SparsePRT(稀疏哈希表) |
引用少时 | 精确记录"哪个分区的哪张卡" |
| 中 | PerRegionTable(细粒度位图) |
超过 G1HRRSUseSparseTable 时建立 |
每个来源分区一张位图 |
| 最粗 | _coarse_map(单比特位图) |
超过 _max_fine_entries 时 delete_region_table 粗化 |
只记"哪个分区有引用",不记具体卡 |
精度降级是为了控制内存与维护成本:一个被大量分区引用的热点分区,没必要为它保留精确的卡级索引,粗粒度位图足以在回收时把"需要扫描的来源分区"圈出来。
写入的落库点是 g1RemSet.inline.hpp 中的 par_write_ref:当 from != to(跨分区引用)时,调用 to->rem_set()->add_reference(p, tid)。add_reference(heapRegionRemSet.cpp:425)先用 FromCardCache(线程 × 分区维度的去重缓存)去重,再按上面三级路径落库。
1.3 RSet 不决定"能不能回收"
RSet 真正服务的是回收之后的指针修正(pointer fix-up)。对象从 young 区搬到 survivor/old 的新地址后,原来指向它的外部指针都会悬空,必须改成新地址。这些指针藏在 old 区的对象里,不在 GC roots 上,图遍历够不到,要改它们就得去 old 区里把包含这些指针的内存找出来。
RSet 的价值,正是把"有/没有"升级成"哪个分区的哪张卡",把扫描范围从全堆缩到 RSet 里那若干张卡。 注意写屏障的对称性:当 old 区的对象写了指向 young 区的引用时,登记动作发生在 young 区 RSet 上(记录"来源是 old 区的某张卡"),因为回收的是 young 区、需要扫描的是"谁指向 young 区",信息挂在被回收方最顺手。
2. DCQ:线程本地脏卡队列
DirtyCardQueue 继承自 PtrQueue,是每个线程私有、无锁 的环形缓冲。核心字段(来自 PtrQueue)是 _buf(缓冲)、_index(当前空位)、_sz(缓冲大小)、_active(DCQ 构造即 active=true,始终激活)。写入通过 enqueue() 填入卡指针;当 _index==0(缓冲写满)时调用 handle_zero_index------把满缓冲交给所属 DCQS 的 completed 链表,再从空闲表或堆里取一块同样大小的新缓冲继续写。
要强调的是,DCQ 只存脏卡地址,不重扫引用、不碰 RSet,它是写屏障的高吞吐暂存。卡本身的"脏"由卡表负责,DCQ 只是把"哪些卡脏了"这件事排队,等待后台线程处理。
2.1 DCQ 有两种归属性,但只有一个类
DirtyCardQueue 只有一个类,但有两处实例化:
| 归属 | 字段/来源 | 使用方 |
|---|---|---|
| 线程私有 DCQ | JavaThread::_dirty_card_queue |
每个 Java 线程(mutator) |
| 全局共享 DCQ | DirtyCardQueueSet::_shared_dirty_card_queue |
所有非 Java 线程(GC 线程、VM 线程等) |
非 Java 线程数量少、写入不像业务线程那样高频,没必要每人一份缓冲,于是 JVM 只给它们准备一个常驻的共享 DCQ(构造时 perm=true),大家往这一个缓冲里 enqueue。
这里有个命名陷阱:JavaThread 名下同时挂着 _dirty_card_queue(单数 ,一个缓冲,即线程私有 DCQ)和 _dirty_card_queue_set(静态 set ,其实是全局的 DCQS 管理器)。带 queue 单数的是缓冲,带 set 的是管理器,两者角色完全不同,但都挂在 JavaThread 名下。
3. DCQS:脏卡队列集
DirtyCardQueueSet 继承自 PtrQueueSet,是全局共享 的缓冲池与调度器。它持有两条链表:_completed_buffers_head/tail(已填满、待消费)和 _buf_free_list(空闲缓冲,供 DCQ 复用),以及计数器 _n_completed_buffers(已完成缓冲的个数)。
所有线程私有 DCQ 的 _qset 都指向那个全局 DCQS;DCQS 内部又嵌了一个共享 DCQ 收非 Java 线程的卡。两种 DCQ 行为一致:缓冲写满(_index==0)时都调 handle_zero_index,把满缓冲交给同一个全局 DCQS 的 completed 链表,再 reallocate。
4. 三个 DCQS 实例与倒手关系
全局其实有三个 DirtyCardQueueSet 实例,分工不同:
JavaThread::dirty_card_queue_set()(主队列集) ------唯一配了精化闭包和 green/yellow/red 阈值的实例,唯一被并发精化线程消费。写屏障的绝大多数脏卡走这里。G1CollectedHeap::_dirty_card_queue_set(延迟更新队列集) ------疏散期间,mutator 把对象复制到新地址、旧卡mark_card_deferred后入此队列,避免疏散过程中 RSet 不一致。G1CollectedHeap::_into_cset_dirty_card_queue_set(含 CSet 引用队列集)------只存"含有指向 CSet 引用的卡",供混合/年轻 GC 时精确扫描"谁引用了 CSet"。
三者的缓冲倒手关系是理解这套机制的关键:
oops_into_collection_set_do用 into_cset 队列集跑完updateRS+scanRS;- 疏散失败 时,
_g1->dirty_card_queue_set().merge_bufferlists(&into_cset_dcqs),把 into_cset 的缓冲并回延迟更新队列集,下次重试; - 疏散成功 时,
into_cset_dirty_card_queue_set().clear()直接清空。
三个实例共用同一份空闲缓冲链表 (fl_owner 指向主队列集),缓冲本身不重复分配。
5. DCQS 的大小管理
DCQS 的"大小"要从三个层面理解,各有管理机制:
单缓冲容量(_sz) ------一次性固定。DirtyCardQueueSet::initialize 调一次 set_buffer_size(G1UpdateBufferSize) 设定,默认 256 (可用 -XX:G1UpdateBufferSize 调整),实际内存块 = 256 * oopSize + BufferNode 头。所有缓冲同尺寸、只设一次,写满时从空闲表/堆里取同样大小的新缓冲,绝不临时膨胀。
已完成缓冲链表长度(_n_completed_buffers) ------由阈值与反压共同约束。主队列集的 _process_completed_threshold 设成 yellow_zone()、_max_completed_queue 设成 red_zone();两个 GC 专用队列集则是 -1/-1(只在 GC 暂停期填充、由 GC 同步 drain,不需要运行期弹性)。当 Java 线程要入队、而队列已到红线时,入队方不再入队,而是自己把这块缓冲处理掉(mut_process_buffer),这就是对 completed 链表的硬上限反压。
空闲缓冲复用(_buf_free_list) ------缓冲消费完不还给 malloc,而是挂回 _buf_free_list;下次 allocate_buffer 优先从空闲表取,拿不到才新分配。空闲表也不能无限涨:reduce_free_list() 在 safepoint 把空闲表砍掉一半回收,防止并发高峰后残留大量缓冲占内存。如前所述,三个 DCQS 共用同一份空闲表(通过 fl_owner)。
6. 三档阈值:green / yellow / red
三档阈值不是给缓冲设容量,而是挂在同一份数据上的"压力计"------它度量 _n_completed_buffers(已完成缓冲的个数,不是字节)落在哪一段,据此决定"谁来处理这些缓冲"。
推导 (ConcurrentG1Refine 构造函数):
green = MAX2(ParallelGCThreads, 1),除非 -XX:G1ConcRefinementGreenZone 显式设置
yellow = green * 3,且 ≥ green
red = yellow * 2,且 ≥ yellow
默认关系是 g : 3g : 6g,存进 _green_zone / _yellow_zone / _red_zone。red 区占整条轴的一半,设计意图是"尽量靠并发精化线程解决,直到实在来不及才逼 mutator 自己动手"。
落到 DCQS 字段 :只有主队列集装了这三档,对应 _process_completed_threshold = yellow、_max_completed_queue = red;两个 GC 专用队列集都是 -1/-1,不需要运行期弹性。
三档各自的行为:
- green(休息/攒批) :
[0, green)区间不处理已完成的缓冲------攒一批再扫,利用脏卡被反复写的局部性。它同时是精化线程的停手线 :apply_closure_to_completed_buffer的stop_at = green,一个 worker 最多把队列压回到 green 就停,把剩余留给其它线程。 - yellow(唤醒并发精化) :入队时若
_n_completed_buffers >= _process_completed_threshold,置_process_completed = true并notify()唤醒 0 号精化线程。这个_process_completed标志就是 0 号 worker 的"激活态"。 - red(mutator 反压) :Java 线程自己要入队、队列已到红线时,不再入队而是
mut_process_buffer自己消费,给 completed 链表加了硬上限。 _completed_queue_padding(临时抬压):疏散暂停结束时若队列仍 ≥ yellow,就给 padding 设定当前长度,临时抬高红线逼精化线程在暂停后继续把积压压到 green 之下;队列回落到 yellow 以内后清零。它是个瞬态偏置,不是固定参数。
运行时可重算 :G1UseAdaptiveConcRefinement 开启时,策略会根据"update RS 是否达标"动态改 green 值并级联重算 yellow/red,再把关卡 set_process_completed_threshold(g + sigma, 封顶 yellow) 下发给屏障体系;reinitialize_threads() 只刷新阈值、不重建线程。
7. 并发消费模型:链表取头
多个精化线程处理 DCQS 时,completed 缓冲是一条单向链表 (BufferNode),由 _completed_buffers_head/tail 管理,FIFO(生产端挂 tail,消费端从 head 取)。
关键点是节点在认领时就从链表摘除 ,且摘除在持 _cbl_mon 锁的极短临界区内完成;真正的卡片扫描在锁释放之后才进行。所以不是"先扫完再摘链",而是"先摘链再扫"。
这里要澄清一个常见误解:worker 之间没有共享的元素序号或静态分段 。各 worker 不预先分配"第几到第几号归我",而是各自循环调用 get_completed_buffer,在持锁瞬间抢当前 head,抢到谁就处理谁,处理完再回来抢下一个。锁 _cbl_mon 只保护"弹头"这几个指针操作,保证同一节点不被两个 worker 同时拿到;共享的只有那个整数计数 _n_completed_buffers,每次递减都在锁内完成。这是 work-stealing 式动态分工,不是静态分区。
在前文的唤醒点/休眠点/step 公式中,它们度量的不是链表下标,而是那个全局整数计数 n = _n_completed_buffers。step = (yellow - green) / (worker_num + 1) 算出来的,是"队列长度每增加多少个缓冲,就多唤醒一个 worker"的间隔;阈值[i] = green + i·step 是"队列长度涨到这个计数值就唤醒第 i 个 worker"的水位;休眠点 = 阈值[i] - step 是"长度跌回上一格就让该 worker 睡"。两者相差一步形成滞后带(hysteresis),避免队列在阈值附近抖动时线程反复拉起/休眠。阈值只调控"几个 worker 醒着"(并发度),不调控"哪个缓冲归谁"(分工仍是持锁 pop head)。
8. 并发精化线程(非采样线程)
ConcurrentG1Refine 创建的线程总数是 worker_thread_num() + 1,前 N 个是脏卡精化 worker,最后一个被复用为采样线程(见《G1 并发Refine线程中的采样线程》)。本文只讲 worker。
8.1 功能
worker 的本职是把脏卡翻译成 RSet 更新项:从主 DCQS 的 completed 链表取一个缓冲,对每张脏卡调 refine_card,由它重扫卡内引用并经 par_write_ref 写进目标分区的 RSet。把 RSet 维护成本从 GC 暂停剥离、分摊到应用运行期,是压低疏散暂停的根本手段------精化得越及时,GC 时要补做的 RSet 工作越少。
8.2 设计
三区阈值压力计:复用上文的 green/yellow/red 模型作为队列压力的度量。
每线程激活/降级阈值:每个 worker 在 green 到 yellow 之间有一档自己的阈值,并带一个滞后带:
step = (yellow - green) / (worker_num + 1)
唤醒点[i] = min(step*(i+1) + green, yellow)
休眠点[i] = max(唤醒点[i] - step, green)
队列从 green 往 yellow 涨时,worker 0 先在 green 附近醒来,超过 worker 1 的阈值则前驱 activate() 拉起 worker 1,......直到 yellow 处所有 worker 全开。
毛毛虫激活链 :所有 worker 串成单链表(_next)。run() 主循环里,前驱处理时若发现队列长度已超 _next 的阈值且后继还没 active,就 activate() 唤醒后继;后继自己发现队列已落到自身休眠点以下,就 deactivate() 回去睡。线程数随负载一节一节展开/收起。
几个关键细节:
- 0 号线程没有自己的
_active标志,而是复用全局DirtyCardQ_CBL_mon,其激活态直接等于dcqs.process_completed_buffers();其余 worker 各有独立Monitor与_active,避免唤醒时互相惊群。 - 每轮只压到 green 就停(
stop_at = green),防止单线程独吞全部缓冲。 - 精化全程包在
SuspendibleThreadSetJoiner里,GC 进 safepoint 可随时打断,不拖长暂停。 - 热卡缓存由
ConcurrentG1Refine持有,对反复变脏的热点卡做延迟/限流(见 §9)。
8.3 管理
创建 :线程数 = G1ConcRefinementThreads 个 worker + 1 个采样线程。链表从尾到头逆序 new,保证每个节点的 _next 指向编号次大的 worker,数组下标即 worker_id。每个线程构造里立即 create_and_start() 拉起,0 号线程的 _monitor 指向 DirtyCardQ_CBL_mon,其余新建独立 Monitor。worker_id_offset 给并行 worker 在 DCQS 的并行 id 命名空间里划出偏移,避免和 GC 工作线程的 id 冲突。
运行 :run() 主循环先 wait_for_universe_init(),随后进入 while (!_should_terminate)------未激活时睡在 _monitor 上;醒来后包一层 SuspendibleThreadSetJoiner,循环 apply_closure_to_completed_buffer 直到队列压到 green,期间按阈值逐级激活后继、自身落到休眠点以下则降级。GC 进 safepoint 可随时打断这一层。
终止与重启 :ConcurrentG1Refine::stop() 遍历所有线程调 stop();每个 stop() 在 Terminator_lock 下置 _should_terminate = true 并 notify 让自己的 _monitor 醒来退出循环,再等 _has_terminated 置位才返回------保证 stop() 返回时线程已干净退出。自适应调参后 reinitialize_threads() 重算 step 并刷新各线程阈值,无需重建线程。
9. 热卡管理
所谓"热表"在源码里并非单一结构,而是两件东西协作:G1CardCounts(判定谁是热卡)和 G1HotCardCache(把热卡延迟精炼)。
9.1 G1CardCounts:判定谁是热卡
G1CardCounts 持有一张 _card_counts(jubyte*,堆里每张卡一个字节 ),下标由卡指针减去卡表基地址得到。统计逻辑在 add_card_count:读出旧计数,若小于 G1ConcRSHotCardLimit 则自增并封顶于该上限,返回旧值。is_hot 即"计数 ≥ 上限"。
关键参数:G1ConcRSHotCardLimit 默认 4 。于是一张卡必须先被正常精炼 4 次,从第 5 次起才被认定热卡------语义是"近期被反复写、反复精炼,干脆先别扫了,攒着"。
9.2 G1HotCardCache:延迟热卡
一旦 is_hot 为真,该卡不再立即精炼,而是进入环缓存。缓存是 _hot_cache_size = 1 << G1ConcRSLogCacheSize = 1024 槽 (默认)的环形结构;_hot_cache_idx 是单调递增的原子计数器,新卡用 idx & mask 取槽、Atomic::cmpxchg_ptr CAS 入槽。各槽独立、写入用 CAS,线程间无锁竞争。
insert 的返回值决定该卡命运:
- 槽内为空/替换成功(缓存成功)→ 返回
NULL,本轮不再精炼; - 槽内已被别的卡占了(极小概率竞态)→ 退回当前卡,由调用方立即精炼。
当环写满,新写入自然回卷覆盖最旧的卡------最旧的那张被逐出,返回给插入线程立即精炼。这层"evicting cache"意味着:若同时有超过 1024 张热卡,最旧的会被提前逐出补精炼;缓存越大延迟能力越强,但 1024 已能覆盖绝大多数热点场景。
9.3 为什么把热卡留在缓存里不精炼
延迟精炼会让这张卡在卡表里继续保持 dirty。而写屏障的第一句就是"卡已 dirty 吗?是就直接返回"。于是热点卡延迟期间,该卡内存再有写操作会命中"已 dirty"分支,整个屏障剩余逻辑(取 DCQ、enqueue)全部跳过------高频写的热点卡,写屏障开销从"每次都 enqueue 一串"降到"几乎零"。这层优化和 RSet 不矛盾:热卡的 RSet 更新只是被推迟(到逐出或 GC 排空时再做),并没有丢掉。
9.4 生命周期管理
热卡缓存的正确性靠 GC 暂停前的排空兜底。疏散暂停流程会 set_use_cache(false) 暂停并发精化(新卡直接走正常精炼),drain() 把环里每张卡由 GC 工作线程并行精炼,reset_hot_cache() 清空环,再 set_use_cache(true) 重新开启。region 被回收/重分配时 reset_card_counts 清掉它的卡计数;safepoint 时 clear_all() 全堆清零;region 提交监听器确保新提交 region 的计数区被清零,不会读到脏值。总开关 _use_cache 由 G1ConcRSLogCacheSize > 0 决定,设为 0 则整套热卡优化关闭。
10. 完整数据流
把前述环节串起来,一条跨区引用从产生到落库的过程是:
mutator 写 obj.field = other_region_obj
│ 写屏障:卡已 dirty → 直接返回(去重);否则置卡 dirty
▼
Java 线程:jt->dirty_card_queue().enqueue(byte) ← DCQ(线程本地,无锁)
非 Java 线程:_dcqs.shared_dirty_card_queue().enqueue(byte)
│ DCQ 缓冲满(_index==0) → handle_zero_index → 满缓冲挂入 DCQS.completed 链表
▼ ← DCQS(全局池)
ConcurrentG1RefineThread 按 green/yellow/red 阈值起线程
apply_closure_to_completed_buffer → refine_card(card_ptr,...)
│ 重扫卡内所有引用,找到跨区引用
▼
G1RemSet::par_write_ref → to->rem_set()->add_reference(p, tid)
│ FromCardCache 去重 → coarse / PerRegionTable / SparsePRT 三级落库
▼ ← RSet(目标分区"谁指向我"建成)
之后年轻/混合 GC 时,oops_into_collection_set_do 直接读 RSet 拿到 CSet 的外部引用来源,只扫描这些来源 Region------这就是整套机制存在的意义。热点卡在上述链路中通过 G1CardCounts + G1HotCardCache 被延迟精炼,缓解写屏障与 RSet 维护开销。
关于
refine_card内部每一步的处理逻辑与闭包链,见后续文章。