G1 GC 对数组与大对象(Humongous)的处理

1. 背景:数组在 GC 里为什么是个特殊角色

数组的物理特征决定了它和普通对象不一样。一个普通对象通常几十到几百字节,而一个数组可以轻松到 MB 级------byte[] 缓冲区、Object[] 缓存、String[] 列表,体量往往比单对象大几个数量级。在 GC 眼里,"对象有多大"直接决定回收动作的成本:复制要搬多少字节、扫描要查多少个引用、记账要占多少位,全都随大小上升。

更关键的是 G1 的堆模型。它把整个堆切成一批大小相等的 region(1MB 到 32MB,默认按堆大小取约 2048 个),对象大小相对 region 的比例,决定了它走哪条分配路径、哪条回收路径。数组恰恰是最容易撞上"大小阈值"的对象类型------它既可能大到跨多个 region,也可能只是个带着几万个引用的普通对象。所以 G1 对数组的处理,核心是在回答一个问题:一个对象大到什么程度,就值得为它单独开一套机制?

数组内容还要再分两类,这关系到后面几乎所有细节:

  • 引用数组 (如 Object[]String[]):每个元素都是 oop(对象指针),GC 必须逐个检查、必要时疏散这些被引用的对象。
  • 原始类型数组 (如 int[]byte[]):元素里没有引用,GC 只要把整块内存搬走即可,根本不用碰元素。

后面讲到的"数组特殊处理",几乎全部指引用数组。原始数组除了"可能很大"这一点,对 GC 没有额外负担。

2. 一条分界线:什么是大对象(Humongous)

G1 把对象分成两类的硬指标是大小。判定在 G1CollectedHeap::isHumongous:对象的字数严格大于半个 regionHeapRegion::GrainWords / 2)就算大对象(g1CollectedHeap.cpp:1880 设阈值,hpp:1435 做判定)。注意是"严格大于"------等于半 region 的仍算普通对象,这条线要干净,否则 TLAB 和大对象路径会打架(源码注释里也点明了这是为了让 TLAB 上限正好卡在这条线上)。

为什么要划在半个 region,而不是三分之一、也不是一个完整 region?

原因在于 TLAB。G1 的普通对象分配走 TLAB,而 TLAB 的最大尺寸就设在 humongous 阈值上,也就是半 region。这样两条路径天然互不干扰:任何能塞进 TLAB 的分配,都不可能误判成 humongous;任何超过半 region 的,都明确走专门的巨型分配路径。同时,半 region 这条线保证了一个 humongous 对象至少独占一个完整 region------不会出现"半个巨型对象加一点别的东西"塞在同一 region 的尴尬局面,region 作为分配和回收基本单元的语义才干净。

这条线一旦划下,对象的待遇就彻底分叉:

  • 普通对象(≤ 半 region):走年轻代常规分配(TLAB → Eden),疏散时按年轻代对象处理。
  • 大对象 / humongous(> 半 region):走专门的巨型分配路径,而且直接落在老年代。

后面每一节,都是在讲这两类待遇为什么不同、代价各在哪里。

3. 巨型对象的分配:直接落老年代

普通对象超线之后,分配路径完全不同。调用链是 humongous_obj_allocate(g1CollectedHeap.cpp:738):先尝试从空闲表拿连续 region,拿不到就 expand_at 扩容堆,再不行就触发 GC 重试。这里有个决定性细节------分配出来的 region 一律标记为老年代:

cpp 复制代码
// g1CollectedHeap.cpp:750
HeapRegion* hr = new_region(word_size, true /* is_old */, false /* do_expand */);

也就是说,一个超过半 region 的数组,一出生就在老年代,根本不进年轻代。这样设计有几层考虑:

第一,年轻代回收靠"疏散拷贝",巨型对象搬起来太贵。 年轻代 GC 的核心动作是把存活对象从 from 区搬到 to 区。对于几 MB 的数组,这个拷贝本身就要花可观时间;更糟的是它还会挤占 PLAB、拉长整轮暂停。而 Young GC 只收年轻代、不会动老年代对象,所以把巨型对象放年轻代,收益为零、成本实打实。

第二,巨型对象的生命周期特征更像老年代对象。 它要么长期存活(一个大缓存、一个大缓冲区),要么一次性大块死掉(请求结束、缓存清空)。直接丢进老年代,让它脱离年轻代"每轮都搬"的节奏,交给并发标记 + 混合 GC + 必要时 Full GC 去处理,更符合它的真实存活模式。

第三,连续多 region 分配必须单独处理。 humongous 对象可以跨多个 region,分配器得找一段连续的空闲 region,这和普通对象塞进 TLAB 完全不是一回事,所以从源头就走独立路径,不进 TLAB 也不进 PLAB。

代价同样明显:巨型 region 一旦分配就占着连续空间,region 内部无法再塞别的对象;分配失败只能靠扩容堆或触发 GC 来兜底。

4. 普通数组的疏散:拷贝后逐元素扫描

没过线的数组,在 evacuation 里就是个普通对象,没有任何特殊逻辑。它先被拷贝到 to-space(可能是 survivor,也可能是 old),拷贝完成后,GC 遍历它的字段和元素,把每个被引用的对象入队,由那套 BFS 疏散循环继续往下搬------这和本系列讲年轻对象晋升时提到的"拷贝后扫描子引用"是同一套机制。

引用数组和原始数组在这里分道扬镳:引用数组的每个元素都是 oop,必须逐个扫描、可能被疏散;原始数组的元素不含引用,整块搬完就结束,连元素都不用看。所以"数组要逐个扫元素"这件事,只对引用数组成立。

这一节的结论很简单:大小没过线的数组,和普通对象待遇完全一致。 G1 对数组的专门逻辑,都集中在"大"这个维度上------要么大到跨 threshold,要么大到元素太多。

5. 大引用数组的分块并行扫描

对象数组可以非常大。一个装着几十万、上百万个引用的 Object[],疏散它时要扫描的元素数量极其可观。如果让"负责拷贝这个数组的那个线程"一口气把所有元素扫完,它会长时间独占,而其他线程早就干完没事干------结果整轮暂停时间被最长的那个数组绑架,并行度形同虚设。这不是 G1 独有的问题,ParNew、ParallelScavenge 面对同样的困境,解法也一致。

G1 的解法是分块 + 工作窃取 。判定点在 copy_to_survivor_space:如果 obj->is_objArray() 且数组长度 ≥ ParGCArrayScanChunk(默认 50,globals.hpp:1574),就不一次扫完,而是把活留到后面分步做(g1ParScanThreadState.cpp:303)。

具体怎么分,是这段设计最精巧的地方:

  • 拷贝完成后,把 to-space 对象的 length 字段临时置 0,当作"还没开始扫"的标记;同时给 from-space 的指针打上一个"部分数组掩码"(partial array mask),压回工作队列。真实的数组长度仍然留在 from-space 对象里,不会丢。
  • 之后由 do_oop_partial_array 增量处理(g1ParScanThreadState.inline.hpp:62)。它每次取一块(默认 50 个元素)扫描,并用 to-space 的 length 字段复用成"下一块的起始下标"。如果扫完这块还剩很多(超过 2 倍 chunk),就把 to-space 的 length 更新成当前块末尾、把 from-space 指针再压回队列,让别的空闲线程偷走继续扫;最后一块才把 length 还原成真实长度,保证万一发生 evac failure 时堆仍然可解析。

整个机制没有为"扫到哪了"额外申请任何数据结构,而是借用了两个现成的地方:对象头里的 length 字段 (to-space 存 next_index,from-space 存真实 length)和指针上的低位掩码(区分普通引用与部分数组引用)。零额外内存、天然可窃取------这正是并行 GC 里处理大数组的标准套路。

至于 chunk 默认为什么是 50:太小,队列压力和窃取次数上来了;太大,单块仍可能卡住一个线程。50 是个经验中点,一般不用动。

6. 记忆集 RSet 与写屏障:大引用数组的隐性代价

引用数组里的元素若是跨 region 的引用,按 card table 的粒度记入脏卡,并在目标 region 的 RSet 里登记------混合 GC 扫描时,靠 RSet 反向找到"谁引用了我要回收的 region"。card 的粒度通常 512 字节(由 card_shift 决定,cardTableModRefBS.hpp:271)。

这里藏着大引用数组最容易被忽视的代价。一张卡覆盖若干个数组下标------512 字节里大约能放 64 到 128 个引用(取决于是否开压缩指针)。只要这几十个下标里有任意一个指向别的 region,整张卡就进 RSet。数组越长、引用分布越散,进 RSet 的卡就越多。一个本身没大到 humongous 的引用数组,可能就因为"带着大量跨 region 引用",把目标 region 的 RSet 撑得很大。

后果是双重的:负责把脏卡加工进 RSet 的 refinement 线程负担加重;混合 GC 时这些 region 的 RSet 扫描成本上升,可能拖长暂停。这是 G1 处理大引用数组最常被诟病的地方------它不像 humongous 那样走特殊路径,却因为 RSet 膨胀悄悄付出代价。从设计上看,region 越大、单卡覆盖的下标越多,同等引用下 RSet 条目相对越少;但 region 大又有别的代价,下一节展开。

7. 巨型对象的回收:为什么慢、何时收

巨型对象分配时直接进老年代,这决定了它的回收节奏和年轻对象完全不同。

第一件要弄清的事:JDK8u 里 humongous 对象不会在 Young GC 被回收。 Young GC 只处理年轻代 region,而巨型对象在老年代,根本不在年轻代 CSet 里。所以一个巨型数组哪怕已经没人引用,只要它所在的 region 没被单独处理,就会一直占着。

整块死亡的巨型 region,要等到并发标记周期的 cleanup 阶段才释放。 机制是一张候选表 _humongous_reclaim_candidates:并发标记期间,如果发现某个巨型 region 仍被引用,就调用 set_humongous_is_live 把它从候选里划掉;等 cleanup 时还留在候选表里的,就是可以整体释放的死亡巨型 region(g1CollectedHeap.inline.hpp:356)。存活的巨型对象则要等混合 GC 或 Full GC 才有机会搬走或释放。

困境在于巨型对象"要么整块死、要么整块活"。 它跨多个连续 region,GC 的回收粒度是整个 region,做不到"只释放数组里没人用的那部分"。只要还有一处引用,整块连续 region 就占着不释放------哪怕数组内部 99% 已经没用了。这带来外部碎片和老年代占用压力,且只能等下一轮标记 cleanup 或 Full GC 才有机会缓解。

为什么不让它走年轻代疏散?一是它本就不在年轻代;二是巨型对象移动成本高,纳入每次 Young GC 的 CSet 会严重拖慢年轻代回收,得不偿失;三是它跨多 region,疏散需要连续的 to-space,难以保证。所以设计上主动把它隔离出年轻代回收路径,用"延迟回收"换年轻代回收的稳定。

版本差异要提一句:JDK9 才引入 G1ReclaimDeadHumongousObjectsAtYoungGC,允许在 Young GC 顺手回收死亡巨型对象;jdk8u 没有这个开关,死亡巨型只能等 cleanup 收。

8. 控制参数与设计取舍

能直接调的旋钮不多,但每一个都牵动"数组会不会变巨型"和"巨型怎么回收"。

G1HeapRegionSize(默认 0,按堆大小 ergonomic,实际范围 1--32MB,目标约 2048 个 region):这是最影响数组命运的旋钮。region 越大 → 半 region 阈值越大 → 越不容易把数组判成 humongous → 数组走年轻代常规路径、能被 Young GC 回收、RSet 也相对更省。但 region 数变少后,回收粒度变粗、单 region 内碎片更难处理,混合 GC 的选择也变粗糙。选 region 大小,本质是在"少 humongous、细粒度回收"和"region 数少、管理开销小"之间取舍。

ParGCArrayScanChunk(默认 50):大引用数组并行扫描的分块粒度。调大,单块更大、窃取次数少,但单块占线程更久;调小,粒度更细、负载更均衡,但工作队列压力和掩码操作变多。经验默认值,一般不动。

InitiatingHeapOccupancyPercent / IHOP(默认 45):触发并发标记的老年代占用阈值。因为死亡 humongous 要等并发标记结束的 cleanup 才释放,IHOP 设太高会让标记启动偏晚,死亡巨型就占着更久。想让巨型对象回收更及时,IHOP 不宜过高。

G1MixedGCLiveThresholdPercent(默认 65):混合 GC 选 region 的存活率门槛,间接影响含 humongous 的老年 region 是否进入混合回收。

UseCompressedOops(默认开,间接相关):开压缩指针时每个引用 4 字节,关掉 8 字节。它影响两件事------多大数组会跨 humongous 阈值,以及单张卡覆盖多少个下标(进 RSet 的密度)。关掉压缩指针,同样大小的引用数组会更早撞上 humongous 线,RSet 条目也相对更密。

9. 小结

G1 对数组的专门处理,全部围绕"对象大小"这个一阶维度展开,可以归纳成两条主干路径:

  • 超半 region 的数组 → 按巨型对象丢进老年代:绕开年轻代那套每轮疏散的节奏,靠并发标记 cleanup 或 Full GC 释放;代价是死亡巨型回收慢、易占连续空间。
  • 大引用数组 → 用 ParGCArrayScanChunk 分块、靠工作窃取并行扫描:避免单个超大数组把 GC 线程独占、把暂停时间拉长。

再往外看,还有一条隐性代价链:引用数组的跨 region 引用按 card 粒度进 RSet,大引用数组会把 RSet 撑大,反过来拖慢 refinement 和混合 GC。这套机制的设计主线很清楚------用 region 阈值把对象分流,用分块扫描保住并行度,用 RSet 精确记录跨 region 引用但承受大数组带来的膨胀,用延迟回收换年轻代回收的稳定。调参时真正要盯的,就是 G1HeapRegionSize:它决定了你的数组到底走哪条路、付出什么代价。

相关推荐
我想走路带风1 小时前
LRU和最长前缀和(计算机网络算法)
计算机网络·算法
M78佐菲1 小时前
Linux学习笔记:进程
linux·笔记·学习·算法
liangshanbo12151 小时前
虚拟列表深度面试题整理
java·开发语言·前端
gugucoding2 小时前
59. 【Java】Spring Boot 入门:第一个 Web 应用
java·开发语言·spring boot
10mAh3 小时前
【Java】HashMap 的 put 到底做了什么?——冲突、扩容与树化实测
java·开发语言·hash
徐小夕3 小时前
表格、文档、甘特、大屏、表单一站打通:pxcharts超级表格4.0正式上线!
前端·算法·github
我命由我123453 小时前
人脸识别 - 勒克斯(光线照射到物体表面上的明亮程度)
android·java·学习·java-ee·人脸识别·学习方法·android runtime
TechLee4 小时前
跨语言加解密总对不上?这个纯 Go 神库让 AES/RSA 与 PHP、Java 100% 互通
java·后端·算法