G1 垃圾回收:脏卡队列、记忆集与并发精化线程机制

本文整理 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_entriesdelete_region_table 粗化 只记"哪个分区有引用",不记具体卡

精度降级是为了控制内存与维护成本:一个被大量分区引用的热点分区,没必要为它保留精确的卡级索引,粗粒度位图足以在回收时把"需要扫描的来源分区"圈出来。

写入的落库点是 g1RemSet.inline.hpp 中的 par_write_ref:当 from != to(跨分区引用)时,调用 to->rem_set()->add_reference(p, tid)add_referenceheapRegionRemSet.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 实例,分工不同:

  1. JavaThread::dirty_card_queue_set()(主队列集) ------唯一配了精化闭包和 green/yellow/red 阈值的实例,唯一被并发精化线程消费。写屏障的绝大多数脏卡走这里。
  2. G1CollectedHeap::_dirty_card_queue_set(延迟更新队列集) ------疏散期间,mutator 把对象复制到新地址、旧卡 mark_card_deferred 后入此队列,避免疏散过程中 RSet 不一致。
  3. 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_bufferstop_at = green,一个 worker 最多把队列压回到 green 就停,把剩余留给其它线程。
  • yellow(唤醒并发精化) :入队时若 _n_completed_buffers >= _process_completed_threshold,置 _process_completed = truenotify() 唤醒 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_buffersstep = (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_countsjubyte*,堆里每张卡一个字节 ),下标由卡指针减去卡表基地址得到。统计逻辑在 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_cacheG1ConcRSLogCacheSize > 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 内部每一步的处理逻辑与闭包链,见后续文章。

相关推荐
gugucoding2 小时前
47. 【Java】Java内存模型(JMM)与可见性
java·开发语言
m0_380743872 小时前
从零调用 Claude 教程
开发语言·python·node.js
起床学FPGA2 小时前
AI回答问题之后,会自动跳到最下面的问题
开发语言·javascript·ecmascript
zww89491112 小时前
校园跑腿系统开发实战指南:从需求分析到上线部署全流程解析
java
Fluxart.ai3 小时前
电商商品图审核怎么自动化?规则引擎、人工复核与发布门禁
java·前端·自动化
x_x-F3 小时前
网络数据从网卡到用户态:一场基于内存所有权转移的接力赛
开发语言·网络·php
名字还没想好☜3 小时前
Java 线上内存泄漏排查实战:jmap 导堆、MAT 找 GC Roots 与四类常见泄漏
java·开发语言·jvm·内存泄漏
码匠许师傅3 小时前
【C++ 面试真题】聊聊 C++ 的多继承与虚继承
开发语言·c++·面试
我不是疯子是傻子4 小时前
Qt CAN通信周期发送抖动?实测定时器精度校准与时间戳补偿方案
开发语言·数据库·qt