ARTICLE DETAIL

资讯详情

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

JVM G1 回收器 RSet 与 PRT 深度解析

JVM G1 回收器 RSet 与 PRT 深度解析

源码版本:OpenJDK 8u HotSpot(src/share/vm/gc_implementation/g1/
关键词:G1、Remembered Set、Per Region Table、写屏障、Card Table、粗化(Coarsening)


1. 背景:为什么 G1 需要 RSet

G1(Garbage-First)是一款以Region为回收基本单位、追求可预测停顿的低延迟收集器。它把堆切成大量大小相等的HeapRegion(默认约 2048 个,每个 1MB~32MB),回收时不再像传统分代收集器那样整代扫描,而是只挑选若干region组成Collection Set(CSet)进行增量回收。

这就带来一个核心难题:增量回收某个 region 时,如何在不扫描整个堆的前提下,找到"谁还在引用我"?

如果 A region 里的对象被 B、C、D 等多个 region 引用,回收 A 时就必须扫描这些"外部引用者",否则会把仍被引用的对象误回收。但全堆扫描违背了 G1 "局部回收"的初衷。

解决办法是Remembered Set(记忆集合,RSet):每个 region 维护一个 RSet,记录"有哪些来自其他 region 的指针指向了我"。回收时,只要扫描本 region 的 RSet 指向的少量 card,就能精确找到外部引用,而不必遍历整个堆。

G1 的 RSet 并不记录"对象级"引用,而是记录到card(卡片)粒度——card 是堆上一段固定大小(默认 512 字节)的内存块,由 Card Table 管理。ptr >> card_shift即可把一个对象地址映射到它所属的 card。


2. 引用变化如何通知 RSet:写屏障

"对象的引用发生变化(赋值)"如果不被感知,RSet 就会失效。G1 通过写屏障(Write Barrier)拦截每一次引用字段的写入:

G1RemSet::write_ref_field / par_write_ref └─ g1RemSet.inline.hpp:69-72 HeapRegion* to = _g1->heap_region_containing(obj); if (from != to) { to->rem_set()->add_reference(p, tid); // 把"p 指向 obj"记入 obj 所在 region 的 RSet }

要点:

  • 被引用对象obj所在的 region 是"被指向方(to)",RSet 记在 to 上。
  • from != to说明这是一次跨 region的引用,才需要进 RSet(region 内部引用不影响增量回收,无需记录)。
  • tid是触发写入的线程 id,用于并发与 FromCardCache 去重。

3. 类层级:HeapRegion → HeapRegionRemSet → OtherRegionsTable

HeapRegion (heapRegion.hpp:211 _rem_set; 491 rem_set()) └─ HeapRegionRemSet (heapRegionRemSet.hpp:229, 内含 OtherRegionsTable _other_regions) └─ OtherRegionsTable (heapRegionRemSet.hpp:120, RSet 主体,即下文"PRT 容器") ├─ _sparse_table (SparsePRT,稀疏) ├─ _fine_grain_regions (PerRegionTable** 开放哈希表,细粒度 —— 源码真正的 PRT) └─ _coarse_map (BitMap,粗粒度)

术语澄清(容易读混)
常见资料把OtherRegionsTable称为 “PRT(Per Region Table)”。但在 Oracle 这份源码里,注释(heapRegionRemSet.hpp:100-104)明确写道:"_fine_grain_entries array is an open hash table of PerRegionTables (PRTs)"——也就是说,源码中真正的 PRT =PerRegionTable,而OtherRegionsTable是"PRT 的集合/管理器"。本文用源码口径:PRT =PerRegionTable

HeapRegionRemSet本身是个薄壳,所有实际记录工作都转交给OtherRegionsTableheapRegionRemSet.hpp:307-314)。


4. OtherRegionsTable 的三级存储

OtherRegionsTable要回答的唯一问题是:“哪些(来自其他 region 的)card 含有指向我(owner region)的指针?”它用三级粒度来回答,本质是在"记录精度"与"内存占用"之间做权衡:引用越稀疏越省空间,引用越密集越追求精确。

4.1 Coarse(粗粒度)——_coarse_map

  • 结构:一整张BitMapheapRegionRemSet.hpp:128),位数 = 整个堆的 region 总数。
  • 存储的值:只有1 bit = 一个 region 索引。第i位为 1 表示"regioni可能包含指向我的指针",不记录是哪个 card
  • 写入
    • 命中即跳过:if (_coarse_map.at(from_hrm_ind)) return;(heapRegionRemSet.cpp:457)。
    • 置位动作发生在 fine 表满、淘汰 PRT 时:delete_region_table()把被淘汰 PRT 对应的 region 在_coarse_map中置 1(cpp:622-625),称为"粗化(coarsen)"。
  • 性能
    • ✅ 查询 O(1)(一次 bit 测试),内存极小(N 个 region 只花 N bit),是所有方案里最省空间的兜底。
    • ❌ 代价最大:GC 扫描时因为不知道具体 card,必须扫描整个引用者 region 的所有对象/card,扫描成本最高。
    • _n_coarse_entries统计粗化数量。

4.2 Fine(细粒度)—— PerRegionTable(源码真正的 PRT)

  • 结构_fine_grain_regionsPerRegionTable**数组(heapRegionRemSet.hpp:132),即开放哈希表 + 冲突链。长度_max_fine_entries(cpp:284-286,= 2^log2(G1RSetRegionEntries),默认基数 256,再乘region_size_log_mb+1)。哈希:ind = from_hrm_ind & _mod_max_fine_entries_mask;冲突用_collision_list_next串成链。
  • 每个PerRegionTable存什么(heapRegionRemSet.cpp:41):
    • _hr:引用者 region 指针;
    • _bmBitMap,长度 =CardsPerRegion(一个 region 内的 card 总数),每一位对应引用者 region 内的一个 card
    • 存储的值:把"该引用者 region 内,哪些 card 含指向 owner 的指针"编码进 bit 位。add_reference_work(cpp:89)把from转成相对该 regionbottom()的 card 偏移(hw_offset >> (card_shift - LogHeapWordSize)),再add_card_work置位_bm对应 bit(并行用par_at_put+ 原子自增_occupied)。
  • 写入/查询prt->add_reference(from)置位;contains_reference直接return _bm.at(card_ind)
  • 性能
    • ✅ 最精确:GC 只扫被置位的少数 card,而非整个 region,扫描成本最低
    • 内存:每个 PRT 固定带一张CardsPerRegion位的 BitMap,本身不便宜,但数量有硬上限(默认 256 量级),总体有界。
    • 上限与淘汰(粗化):fine 表满(_n_fine_entries == _max_fine_entries)触发delete_region_table()(cpp:578):在采样窗口(_fine_eviction_stride/_fine_eviction_sample_size)里挑选occupied 最大的 PRT,把它对应的 region 粗化(置 coarse bit),并立即init复用该 PRT 给新 region(cpp:509)。即"牺牲精度换内存有界"。
    • 查找无锁、改桶链才持_m;PRT 通过静态_free_list对象池复用(cpp:206-203),避免频繁 new/delete。
    • 遍历高效:_first_all_fine_prts/_last_all_fine_prts双向链表,批量操作所有 fine PRT 时不必扫整个哈希表。

4.3 Sparse(稀疏)——_sparse_table(SparsePRT)

  • 为什么存在:当一个引用者 region 只贡献了极少 card时,为它建一张整 BitMap 的 PRT 太浪费,先用紧凑结构表示。
  • 结构SparsePRT内含两张RSHashTable_cur/_next,双缓冲)(sparsePRT.hpp:216-217)。每张RSHashTable
    • _buckets:桶数组(容量为 2 的幂,用capacity_mask()取模哈希);
    • _entriesSparsePRTEntry连续数组(变长对象);
    • 桶内用SparsePRTEntry::_next_index串成冲突链。
  • SparsePRTEntry存什么(sparsePRT.hpp:45):
    • _region_ind:引用者 region 索引;
    • _cards[1]变长数组,存该 region 内、含指向 owner 指针的 card 偏移(相对 region bottom),最多cards_num()个;
    • cards_num()=MAX2(G1RSetSparseRegionEntries & ~(4-1), 4)(sparsePRT.hpp:64),默认基数G1RSetSparseRegionEntriesBase = 4,所以每个 entry 默认最多存 4 个 card,且数量恒为 4 的倍数(配合UnrollFactor = 4循环展开)。
  • 存储的值(引用者 region 索引 → 至多 4 个 card 偏移)的紧凑映射。
  • 写入/溢出add_card在 entry 的_cards里找空位放入(sparsePRT.cpp:93,带 4 路展开);若 4 个槽都满 → 返回overflow→ 上层在OtherRegionsTable::add_reference走"建/复用 PRT + 把 sparse 卡迁过去"分支(heapRegionRemSet.cpp:528-541),即sparse → fine 升级(promote)
  • 性能
    • ✅ 最省内存:只有真正有引用的 region 才占 entry,且每个 entry 仅存几个 int 级 card 偏移,而非整张 BitMap。
    • 哈希表初始容量仅 16(InitialCapacity,sparsePRT.hpp:222),利用率 >50%(occupied_entries*2 > capacity)自动expand()(sparsePRT.cpp:483)。
    • 双缓冲_cur/_next(sparsePRT.hpp:213-217 注释):GC 扫描迭代只读_cur,新增/删除走_next,从而支持unsynchronized reads/iterations(并发读不被并发改破坏),cleanup 阶段再 reconcile。
    • 循环展开(UnrollFactor=4)加速_cards的查找与拷贝。
    • ❌ 代价:查找需哈希 + 冲突链遍历,比 coarse 慢;单 entry card 上限小,超过即升级到 fine(用空间换精度)。

5. 升级路径与总体调度

引用少 ──> Sparse (每引用者 region 至多 4 个 card 偏移,极省内存) │ 某 region 存满 4 个 card 再插第 5 个 → overflow ▼ 引用增多 ──> Fine / PerRegionTable (整张 card 位图,精确,扫描最省) │ fine 表达上限 (_max_fine_entries,默认 ~256) ▼ 过多引用者 ──> Coarse (1 bit/region,牺牲扫描精度保住内存上限)
  • sparse → fine:单 entry 4 个 card 溢出触发 promote,并把已存的 sparse 卡迁入新建 PRT。
  • fine → coarse:fine 表满,淘汰 occupied 最大的 PRT 并粗化(cpp:578)。
  • 整体策略是"能省则省、逐级退让",动态平衡"记录开销"与"GC 扫描开销"。

6. 跨三级的通用加速:FromCardCache 去重

FromCardCacheheapRegionRemSet.hpp:50,每线程 × 每 owner region 缓存最近处理的 card 索引)是横跨三层的优化。add_reference一进来先查它:FromCardCache::contains_or_replace(tid, cur_hrm_ind, from_card)(cpp:444)——同一线程、同一 owner region、同一 card 刚处理过就直接返回,避免重复入表。这在高频赋值的写密集场景下显著减少重复工作。


7. 并发与内存序

OtherRegionsTable头部注释(heapRegionRemSet.hpp:106-118)明确其并发策略:

  • 查找 PRT 可以无锁find_region_table),但修改桶链必须持_m(Mutex)
  • 允许并发读到"正在被删除/复用"的 PRT,这是安全的,因为:
    1. PRT 只会在安全点真正释放,平时是被复用(cpp:113);
    2. 删除 PRT 时会先置coarse_map的 bit,所以漏记不了;复用 PRT 时即使误加 bit 也无害(cpp:116-118)。
  • add_reference_workis_in_reserved_raw防御并发复用(cpp:109);新建 PRT 用OrderAccess::release_store_ptr发布(cpp:525),保证内容可见后才对外可用。

8. 小结

粒度存储结构存的值查询内存扫描精度
Coarse1 bit/region(_coarse_mapregion 索引(可能存在)O(1)极小必须扫整个引用者 region
Fine / PRTPerRegionTable+ card 位图引用者 region 内具体 card 偏移O(1) 位测试中(数量有上限)只扫置位 card,最精确
SparseSparsePRT:(region→≤4 card) 紧凑哈希region 索引 + 至多 4 个 card 偏移哈希+链极小只扫记录的 card

G1 用这套"能省则省、逐级退让"的三级存储,在记录成本(维护 RSet 的写屏障开销、内存)与GC 扫描成本(回收时遍历 RSet 的开销)之间取得了动态平衡,这正是 RSet 能支撑 G1 增量、可预测停顿回收的关键设计。


附:关键源码位置速查

内容文件:行
HeapRegion::rem_set()heapRegion.hpp:491
HeapRegionRemSetheapRegionRemSet.hpp:229
OtherRegionsTable类 / 三级字段heapRegionRemSet.hpp:120, 128/132/149
add_reference主流程heapRegionRemSet.cpp:425
delete_region_table(粗化)heapRegionRemSet.cpp:578
PerRegionTable类 /add_reference_workheapRegionRemSet.cpp:41 / 89
SparsePRT/SparsePRTEntrysparsePRT.hpp:210 / 45
写屏障入口to->rem_set()->add_referenceg1RemSet.inline.hpp:72
FromCardCache去重heapRegionRemSet.cpp:444
返回列表