Skip to content

gc-algorithms-demo ​

配套理论主文档:JVM / ART 垃圾回收算法与 GC 演进深度剖析。本 lab 用纯 JDK(零三方依赖)把文档里的弱分代假说、复制算法、大对象直入老年代、标记-整理、循环引用孤岛回收全部变成可在终端亲眼观察的 GC 行为。

1. 实验目标与工程要点 ​

一个类、三次场景、两条命令,分别对应文档的三组判断:

场景验证点对应文档口径
场景 1:30 万个 1KB 朝生夕死对象弱分代假说 + 年轻代复制算法(Eden 整块清空、Survivor 极小、几乎不晋升)「常见场景 2:避免在 onDraw 或高频 Loop 中分配内存」
场景 2:40 个 2MB 大对象用完即弃大对象绕过年轻代直接进老年代(Serial PretenureSizeThreshold / G1 Humongous Region 双口径)「常见场景 1:大对象直接进入老年代 / Large Object Space」
场景 3:显式 System.gc() 对照Full GC 标记-整理前后老年代用量回落;互相引用的孤岛被可达性分析回收(引用计数做不到)「底层机制 §1 可达性分析与 GC Roots」「与相近概念对比表」

关键参数 / 开关:

参数作用本 lab 取值
-Xms / -Xmx堆初始 / 最大容量(固定堆让日志数字可复现)256m / 256m
-Xmn固定新生代大小(仅对分代收集器有意义,故只在 Serial 组使用)96m(Eden ≈ 76.8MB,两个 Survivor 各 ≈ 9.6MB)
-XX:+UseSerialGC使用 Serial 收集器:年轻代复制算法 + 老年代标记-整理的教科书实现仅 Serial 组
-XX:PretenureSizeThreshold=1048576超过 1MB 的对象直接在老年代分配(只对 Serial 生效,G1 忽略之)仅 Serial 组
-XX:G1HeapRegionSize=1m把 G1 region 钉在 1MB,使 2MB 数组必然成为 Humongous(>region 一半)仅 G1 对照组
-Xlog:gc*JDK 17 统一日志,输出每次 GC 的 cause、代内明细与各阶段耗时两组都用

2. 深入原理与生活浅显比喻 ​

2.1 生活浅显比喻:幼儿园与成人宿舍 ​

  • Eden = 幼儿园大班教室:绝大多数「孩子」(新对象)放学后(方法返回)就再也不来了。每次大扫除(Minor GC)老师只把还需要的少数孩子搬到隔壁备用教室(To Survivor),整个旧教室一次清空——这就是复制算法:死亡越多,要搬的越少。
  • 大对象 = 成年人:身高超过门槛的人不进幼儿园排队,直接分配成人宿舍(老年代 / ART 的 Large Object Space)。让 2MB 的数组挤进 Eden 复制流水线纯属浪费搬运工。
  • System.gc() = 全校点名大扫除:所有区域统一标记谁还活着,再把活人全部挪到宿舍楼一头、空房连成一片——这就是标记-整理,顺带消灭碎片。两个互相拉手但没有任何老师(GC Root)认识的孩子,点名时自然无人认领,一起被请出校园——引用计数永远数不清这种「互相拉手」,可达性分析一眼看穿。

2.2 底层原理分析 ​

  • 弱分代假说:绝大多数对象朝生夕死。所以把堆分成年轻代 / 老年代,年轻代用复制算法高频回收,老年代低频做标记类回收。
  • 复制算法:Eden + From Survivor 的存活对象拷贝到 To Survivor,之后 Eden 与 From 整块清空;代价是浪费一块 Survivor 空间,收益是没有碎片、且死亡比例越高拷贝成本越低。
  • 标记-整理(Mark-Compact) :先从 GC Roots 标记存活,再把存活对象向内存一端滑动压实。Serial 老年代与 G1 Full GC 都走这条路;CMS 只做标记-清除所以留碎片,需要另找机会整理。
  • 可达性分析 vs 引用计数:从 GC Roots 出发沿引用链搜索,搜不到的就是垃圾;因此 A↔B 互相引用但无外部入口的「孤岛」可被正常回收。
  • 大对象直入老年代的两套实现:
    • Serial:超过 PretenureSizeThreshold 的分配请求绕过 DefNew,直接尝试 Tenured Gen;
    • G1:大于 region 一半的对象是 Humongous,直接占用一组连续 Old Region;G1 还会在后续 Young GC 里急切回收已死亡的 Humongous region(本 lab 日志里 Humongous regions: 114->3 就是证据),这也是它「Region 整理、无碎片」口径的一部分。

2.3 方案对比矩阵 ​

方案通俗比喻底层原理保证特性吞吐性能运行开销适用场景
Serial一个保洁员包场年轻代复制 + 老年代标记-整理,全程单线程 STW行为最简单、日志最教科书低(多核浪费)单线程停顿较长小内存容器、教学验证
Parallel多个保洁员同时扫同上但多线程并行回收高吞吐优先高停顿仍明显后台批处理
CMS边营业边打扫标记-清除为主,并发标记降低停顿中高内存碎片 + 并发 CPU 占用,需另找机会整理早期 Java Web 服务(JDK14 已移除)
G1分区网格化管理Region 化分代 + Humongous 直入 Old Region + 可预期停顿目标停顿可设 MaxGCPauseMillis中高记忆集 / 屏障维护成本中大堆服务端(JDK17 默认)
ART CC(对照浅写)读屏障协助搬家并发拷贝(Moving GC)+ 读屏障修正引用减少碎片与长暂停,具体取决于版本设备移动端取舍不同读屏障常驻开销Android 运行时

3. Android / 移动端实战场景与误配事故 ​

3.1 实战场景 ​

  • 高频分配(对应场景 1) :onDraw / onBindViewHolder 里每帧 new Rect()、new Paint()、自动装箱,等价于本 lab 的 300MB 分配风暴——ART 侧表现就是频繁 Young/Sticky CC 与丢帧。正确做法是把可复用对象提升为字段或用对象池。
  • 大对象路径(对应场景 2) :未降采样的大 Bitmap、一次性读全量响应体到 byte[],都会走 LOS / 大对象直入老年代路径:既占掉大片连续空间,又因为「死得慢」拖长老年代回收间隔。工程上应先采样降尺寸(如 Glide override())、流式解析。
  • 手动 System.gc()(对应场景 3) :业务代码里手动调用通常是想「救急」,实际换来的是一次全堆标记-整理级 STW。Android 上更不该依赖它——ART 对显式 GC 的处理随版本变化。

3.2 常见误配与事故注意事项 ​

误配后果说明
业务代码里调 System.gc()人为制造 Full GC 卡顿本 lab 场景 3 日志里那次 Pause Full 就是一次完整四阶段停顿
-Xmn 设得过大老年代被挤压,对象还没来得及晋升就 Full GC / OOM-Xmn 只对分代收集器有意义;G1 更应该交给自适应调优
以为 PretenureSizeThreshold 到处生效G1 / Parallel 下该参数被忽略,调了个寂寞它只对 Serial(及历史上的 ParNew)生效;G1 用 Humongous 口径
忽略 Humongous 连续性代价region 内部碎片化,提前触发并发周期2MB 数组在 1MB region 下占 3 个 region 却只用约 2MB
用引用计数直觉判断「循环引用泄漏」误报泄漏或漏判JVM / ART 都是可达性分析:成环不是问题,失去 Root 入口才是回收条件

3.3 排障思路 ​

拿到 -Xlog:gc*(Android 侧对应 Logcat / Perfetto 的 GC 轨迹)后按 cause 字符串分流:

  1. Allocation Failure / G1 Evacuation Pause 密集出现 → 分配速率过高,找热点高频分配代码(对应场景 1);
  2. G1 Humongous Allocation 出现 → 有超大对象反复分配,查大数组 / 未采样 Bitmap / 全量 IO 缓冲(对应场景 2);
  3. cause 为 System.gc() → 有人手动触发,先删调用点再看(对应场景 3);
  4. Pause Full 且回收后老年代仍然高位 → 存活数据真的大,按内存泄漏排查(见 Android / Java 内存泄漏定位、LeakCanary 原理与 Profiler 实战),而不是急着加大堆。

4. 实验源码与运行验证 ​

关键源码 ​

文件说明
GcAlgorithmsDemo.java三场景 + MXBean 自检断言(PASS 时退出码 0)

关键实现速览 ​

三个场景的核心就是三段分配模式,其余都是测量与断言:

java
// 场景 1:朝生夕死。引用写入静态字段 sink,防止 JIT 逃逸分析把不逃逸的分配整体优化掉
for (int i = 0; i < 300_000; i++) {
    byte[] junk = new byte[1024];
    junk[0] = (byte) i;
    sink = junk;
}

// 场景 2:2MB 大对象。Serial 下超 PretenureSizeThreshold(1m)、G1 下超 region(1m) 一半,
// 两条路都绕过 Eden 直接落在老年代连续空间(对照 ART Large Object Space)
for (int i = 0; i < 40; i++) {
    byte[] big = new byte[2 * 1024 * 1024];
    big[big.length - 1] = 1;
    sink = big;
}

// 场景 3:互相引用的孤岛。方法返回后环上再无任何 GC Root 入口,
// 引用计数救不了它,可达性分析让它随 Full GC 一起回收
Island a = new Island(null);
Island b = new Island(a);
a.peer = b;

完整源码入口:GcAlgorithmsDemo.java

运行方式 ​

bash
cd labs/java/memory-lifecycle/gc-algorithms-demo
javac -d out src/GcAlgorithmsDemo.java

# 组 A(主推):Serial 教科书模式 —— 复制算法 / PretenureSizeThreshold / 标记-整理全部可见
java -Xms256m -Xmx256m -Xmn96m -XX:+UseSerialGC \
     -XX:PretenureSizeThreshold=1048576 \
     "-Xlog:gc*" \
     -cp out GcAlgorithmsDemo

# 组 B(对照):默认 G1 —— 观察 Humongous 直入 Old Region 与 G1 的 Full GC 压缩
java -Xms256m -Xmx256m -XX:G1HeapRegionSize=1m \
     "-Xlog:gc*" \
     -cp out GcAlgorithmsDemo

zsh 提示:zsh 会把 gc* 当通配符展开,务必像上面一样给 "-Xlog:gc*" 加引号;bash 不加引号也可运行。

约定:编译产物统一进 out/(已被 .gitignore 忽略),src/ 只放源码。程序内置 MXBean 断言自检:全部通过打印 PASS: 全部检查通过 并以退出码 0 结束;任一失败打印 FAIL: ... 并以退出码 1 结束。

以下「可能输出」均为本机 JDK 17(OpenJDK 17.0.14)实跑摘录;时间戳、毫秒与 MB 数字在不同机器上会有出入,结构不会变。

输出对照:先看代码,再看控制台输出 ​

场景 1:短命对象高频分配 —— 弱分代假说 + 复制算法 ​

对应源码 scenario1ShortLivedChurn()(关键片段见上方速览)。运行时穿插出现的 GC 日志:

text
[0.070s][info][gc,start    ] GC(0) Pause Young (Allocation Failure)
[0.071s][info][gc,heap     ] GC(0) DefNew: 78720K(88512K)->691K(88512K) Eden: 78720K(78720K)->0K(78720K) From: 0K(9792K)->691K(9792K)
[0.071s][info][gc,heap     ] GC(0) Tenured: 0K(163840K)->0K(163840K)
[0.071s][info][gc          ] GC(0) Pause Young (Allocation Failure) 76M->0M(246M) 0.891ms

程序自身的统计行(可能输出):

text
[demo] 场景1 共分配约 292 MB 短命对象, Minor GC 次数 0 -> 3 (新增 3 次)
[demo] 补一次 Minor GC 后 Eden 已用 1 MB、老年代 0 MB —— 短命垃圾几乎全部死在年轻代, 未晋升
[ok] 场景1: 高频分配应触发至少 2 次 Minor GC
[ok] 场景1: 朝生夕死对象几乎不应晋升老年代(增量 <= 4MB)

预期现象:292MB 分配总量只换来个位数 Minor GC;每次 GC 后 Eden 归零、From Survivor 只有几百 KB、Tenured 保持 0。G1 组对应行是 Pause Young (Normal) (G1 Evacuation Pause) 与 Eden regions: 152->0(152)、Survivor regions: 1->1(20)、Old regions: 0->0。

观察重点(印证了哪种算法):

  • cause 是纯分配压力(Allocation Failure / G1 Evacuation Pause)——「onDraw 里 new Rect()」的代价模型;
  • Eden: 78720K->0K 是复制算法的整块清空特征:不逐个释放,只把约 0.7MB 存活者拷进 From Survivor;
  • 300 万 KB 的垃圾全部死在年轻代、Tenured 始终为 0——弱分代假说的直接证据。

场景 2:大对象直接进老年代 —— Serial PretenureSizeThreshold vs G1 Humongous ​

对应源码 scenario2LargeObjects()。Serial 组全程没有任何 Minor GC 干扰,程序统计行(可能输出):

text
[demo] 场景2 共分配 40 个 2MB 大对象(约 80 MB), 老年代已用 0 MB -> 80 MB
[demo] 场景2 结束后 Eden 已用量仅 1 MB —— 大对象基本没有经过年轻代
[ok] 场景2(Serial): 大对象应直接推高老年代用量(增量 >= 48MB)
[ok] 场景2: 大对象路径不应显著占用 Eden(<= 16MB)

G1 组则能看到大对象压力触发专属 cause,以及 Humongous region 被急切回收(可能输出):

text
[0.133s][info][gc,start    ] GC(5) Pause Young (Concurrent Start) (G1 Humongous Allocation)
[0.133s][info][gc,heap     ] GC(5) Eden regions: 1->0(152)
[0.133s][info][gc,heap     ] GC(5) Humongous regions: 114->3
[0.133s][info][gc          ] GC(5) Pause Young (Concurrent Start) (G1 Humongous Allocation) 115M->4M(256M) 0.646ms

预期现象:两组里 Eden 都几乎不动、80MB 大对象全部落在老年代一侧的连续空间;区别是 Serial 让它们安静躺在 Tenured 里等 Full GC,而 G1 在下一次 Young GC 就把已死亡的 Humongous region 急切回收掉(114->3),所以瞬时 Old 用量不会停在高位。

观察重点(印证了哪种口径):

  • Serial 组:PretenureSizeThreshold=1048576 生效——2MB 绕过 Eden 直入 Tenured,这是文档「常见场景 1」在 JVM 侧的对应物;
  • G1 组:cause 字符串 G1 Humongous Allocation 说明「大对象分配压力」本身就是一个独立 GC 触发原因;Humongous regions: 114->3 印证 Region 化管理下的急切回收与「无碎片」口径;
  • 对照 ART:大 Bitmap 走 Large Object Space 时同理——既绕开新生代流水线,又占连续空间,回收节奏和普通对象不同。

场景 3:显式 System.gc() 对照 —— 标记-整理 + 循环引用孤岛回收 ​

对应源码 scenario3ExplicitGc()。Serial 组日志完整展示标记-整理四阶段(可能输出):

text
[0.111s][info][gc,start    ] GC(5) Pause Full (System.gc())
[0.112s][info][gc,phases,start] GC(5) Phase 1: Mark live objects
[0.112s][info][gc,phases      ] GC(5) Phase 2: Compute new object addresses
[0.112s][info][gc,phases,start] GC(5) Phase 3: Adjust pointers
[0.112s][info][gc,phases,start] GC(5) Phase 4: Move objects
[0.112s][info][gc,heap        ] GC(5) Tenured: 82944K(163840K)->2765K(163840K)
[0.112s][info][gc             ] GC(5) Pause Full (System.gc()) 83M->2M(246M) 1.367ms

程序统计行(可能输出):

text
[demo] System.gc() 前老年代已用 81 MB (含场景2遗留的死大对象 + 约 2MB 孤岛载荷)
[demo] System.gc() 后老年代已用 2 MB —— 死亡对象连同其碎片位置一起被压缩清理
[ok] 场景3(Serial): Full GC 后老年代应明显回落(释放 >= 32MB)

预期现象:cause 明确写着 System.gc()——这次停顿是人为制造的;四阶段(标记存活 → 计算新地址 → 修正指针 → 移动对象)就是教科书标记-整理;81MB 回落到 2MB,说明场景 2 遗留的死大对象连同互相引用的 Island 孤岛一起被回收了。G1 组对应 Phase 2: Prepare for compaction / Phase 4: Compact heap。

观察重点(印证了哪些机制):

  • 标记-整理:Move objects / Compact heap 阶段把存活对象滑向一端——这就是 CMS 需要另做 Compact 而 G1/Serial 自带的能力;
  • 可达性分析 vs 引用计数:Island 的 peer 字段互相成环,但方法返回后没有任何 GC Root 能到达它,照样被回收——引用计数算法做不到这一点(对应文档 §底层机制 1 的孤岛示意);
  • STW 成本:这次全堆四阶段停顿是业务代码手动 System.gc() 换来的,Android 上同理不要手写显式 GC「救急」。

收尾自检 ​

text
PASS: 全部检查通过

任一断言失败时会打印 FAIL: 有 N 项检查未通过... 并以退出码 1 结束,便于 CI 直接复跑验证。


5. 对应知识库文档 ​

站点构建时间:2026/8/24 23:43:17