分代收集是现代主流 GC 的核心设计哲学,建立在一个关键经验观察上:大多数对象"朝生夕死"。本文梳理分代假说的理论基础、分代堆的结构设计,以及 Minor GC 和 Major GC 的触发机制。
目录
| 章节 | 说明 |
|---|---|
| 弱分代假说 | 统计规律与理论依据 |
| JVM 分代堆结构 | Eden、Survivor、Old、Metaspace |
| 对象的晋升路径 | 从 Eden 到 Old 的完整流程 |
| 跨代引用问题 | 卡表与记忆集 |
| Minor GC vs Major GC | 触发条件与影响 |
弱分代假说
核心观察
1984 年 David Ungar 在研究 Smalltalk 程序时发现:
弱分代假说(Weak Generational Hypothesis): 绝大多数对象都在年轻时死亡。
统计数据:
- 典型 Java 程序中,98% 的对象在第一次 Minor GC 时就已经死亡
- 只有约 2% 的对象能"熬过"第一次 GC 进入老年代
- 长期存活的对象(如缓存、连接池)只占堆的一小部分
这个观察的深层原因:大量临时对象(方法调用时创建的局部对象、循环体内的中间结果、日志调试对象)在方法返回后就没有任何引用了。
分代假说的推论
如果大多数对象很快死亡,那么:
- 年轻代 GC 频繁但快速: 存活对象少,复制开销小
- 老年代 GC 稀少: 晋升率低,不需要频繁扫描
- 按代分配收集策略,整体效率远高于全堆 GC
JVM 分代堆结构
逻辑结构
| 区域 | 作用 | GC 算法 |
|---|---|---|
| Eden | 新对象分配(大多数对象在此诞生) | 复制收集 |
| Survivor 0/1 | 存活过一次 Minor GC 的对象 | 复制收集 |
| Old Generation | 多次 GC 存活的长期对象 | 标记-整理 |
| Metaspace | 类元数据(JDK 8+ 替代 PermGen) | 独立管理 |
为什么有两个 Survivor 区
复制收集要求 To Space 始终为空,才能保证存活对象被复制到连续内存、没有碎片。
为什么不是两个 Eden?
两个 Eden 无法保持"始终有一块空区域":第一次 GC 后 Eden1 的存活对象进了 Eden2,第二次 GC 时 Eden2 满了,要复制回 Eden1,但 Eden1 里还有上次的存活对象——无法整体清空,碎片问题无法解决。
两个 Survivor 交替使用解决了这个问题:每次 Minor GC 后 To Space 一定是空的,下次 GC 时它仍然可以作为干净的目标区域接收存活对象。
Minor GC 过程:Eden + S0(From)中的存活对象复制到 S1(To)→ 清空 Eden 和 S0 → S0/S1 角色互换。
为什么 Survivor 不需要很大?
HotSpot 默认 Eden : S0 : S1 = 8 : 1 : 1,因为 98% 的对象在 Eden 就死亡,只有约 2% 能存活一次 GC 进入 Survivor。如果是两个 Eden 各占 50%,每次用一半来接收约 2% 的存活对象,内存利用率极低。
对象的晋升路径
完整生命周期
- 对象创建 → Eden 区(TLAB 快速分配)
- Eden 满 → 触发 Minor GC,存活对象复制到当前 To Space(Survivor 之一),age = 1
- 再次 Minor GC → Eden + 当前 From Space 中存活对象复制到当前 To Space,age++;每次 GC 后 S0/S1 角色互换
- age 达到阈值(默认 15)→ 晋升到 Old Generation
- Old Generation 满 → 触发 Major GC / Full GC
年龄阈值与动态调整
-XX:MaxTenuringThreshold=15 # 默认年龄阈值
动态年龄判定(HotSpot 优化):如果 Survivor 中年龄 N 的对象总大小 > Survivor 空间的 50%,则年龄 ≥ N 的对象直接晋升,不等到阈值。
大对象直接进入老年代
-XX:PretenureSizeThreshold=3145728 # 3MB 以上直接进 Old
大对象复制代价高,且通常存活时间较长(典型:大数组、大字符串),直接分配到老年代可以避免反复复制。
跨代引用问题
问题描述
分代 GC 在做 Minor GC 时只扫描年轻代,但老年代的对象可能引用年轻代对象:
如果只扫描 Young Gen,LongLivedObj 对 YoungObj 的引用就被忽略了,YoungObj 可能被错误回收。
解决方案:卡表(Card Table)
将老年代划分为若干 512 字节的"卡",如果某张卡内的对象引用了年轻代对象,就把这张卡标记为"脏"(dirty)。卡表本身是 JVM 堆外维护的 byte 数组,通过对象地址右移 9 位(÷ 512)即可定位到对应卡号:
Minor GC 时:只扫描年轻代 + 所有 dirty 卡,跳过干净的卡,大量节省时间。
记忆集(Remembered Set) 是卡表的抽象表示,G1 中每个 Region 维护一个 Remembered Set,记录哪些外部 Region 引用了自己。
Minor GC vs Major GC
| Minor GC | Major GC | Full GC | |
|---|---|---|---|
| 回收区域 | Young Gen | Old Gen | Young + Old + Metaspace |
| 触发条件 | Eden 满 | Old Gen 满 | 显式 System.gc() 或空间担保失败 |
| STW 时间 | 短(毫秒级) | 长(秒级,取决于 GC 算法) | 最长 |
| 频率 | 高(几秒一次) | 低(分钟到小时) | 尽量避免 |
空间担保(Space Guarantee)
Minor GC 前 JVM 检查:老年代剩余空间是否大于历次晋升的平均大小?
- 老年代剩余 < 历次平均晋升量 → 担保失败,直接触发 Full GC(保守策略)
- 若允许担保失败(
-XX:+HandlePromotionFailure)→ 先做 Minor GC,若晋升量仍超过老年代剩余再做 Full GC
Full GC 触发条件(OpenJDK HotSpot,JDK 8-21)
Minor GC 只回收年轻代;Full GC 回收整个堆(Young + Old + Metaspace)。触发条件因收集器而异。
公共触发路径
分配失败 → Young GC → 空间担保失败 ──→ Full GC
↑
Metaspace 满 ────────────────────────→ Full GC
System.gc() ─────────────────────────→ Full GC(-XX:+DisableExplicitGC 可禁用)
并发 GC 失败(CMS/G1/ZGC/Shenandoah)→ 退化为 Full GC
各收集器触发条件
| 收集器 | 触发场景 | 关键参数 | HotSpot 源码(src/hotspot/share/gc/) |
|---|---|---|---|
| Serial / Parallel GC | Old Gen 分配失败 | -Xmx |
parallel/psParallelCompact.cpp |
| Metaspace 满 | -XX:MetaspaceSize(触发扩容)-XX:MaxMetaspaceSize(上限,满则 Full GC) |
shared/metaspace.cpp |
|
| Young GC 空间担保失败(promotion failed) | -XX:+HandlePromotionFailure(JDK 6u24+ 默认 true) |
parallel/psScavenge.cpp |
|
| CMS | Old Gen 使用率超阈值(触发并发 GC,未到 Full GC) | -XX:CMSInitiatingOccupancyFraction(默认 92%)-XX:+UseCMSInitiatingOccupancyOnly |
cms/concurrentMarkSweepGeneration.cpp |
| Concurrent Mode Failure:并发标记期间 Old Gen 耗尽,退化为 Serial Old Full GC | 调低 -XX:CMSInitiatingOccupancyFraction 预留空间 |
同上 | |
| Promotion Failed:晋升时 Old Gen 空间不足 | — | 同上 | |
| 碎片过多无法分配大对象 | -XX:CMSFullGCsBeforeCompaction(默认 0,每次压缩) |
同上 | |
| G1 | Evacuation Failure:疏散失败,退化为 Full GC | -XX:G1ReservePercent(预留空间,默认 10%) |
g1/g1CollectedHeap.cpp |
| Humongous 对象分配频繁导致 Mixed GC 来不及 | -XX:G1HeapRegionSize(调大减少 Humongous 比例) |
g1/g1Policy.cpp |
|
| 并发标记速度跟不上分配速度 | -XX:InitiatingHeapOccupancyPercent(IHOP,默认 45%) |
g1/g1Policy.cpp |
|
| ZGC | 分配速率过快,并发 GC 来不及(触发阻塞 GC,非传统 Full GC) | -XX:ZCollectionInterval-XX:ZAllocationSpikeTolerance |
z/zDriver.cpp |
| Shenandoah | Degenerated GC:并发期间分配压力过大,退化为 STW | -XX:ShenandoahGCHeuristics(adaptive/compact/aggressive) |
shenandoah/shenandoahControlThread.cpp |
| Full GC:Degenerated GC 仍失败后的最后兜底 | — | 同上 | |
| 所有收集器 | System.gc() |
-XX:+DisableExplicitGC(禁用)-XX:+ExplicitGCInvokesConcurrent(改为并发) |
shared/gcCause.hpp |
| Heap dump / JVMTI 请求 | jmap -dump:... |
services/heapDumper.cpp |
判断 Full GC 的实用方法
# GC 日志中 Full GC 关键字
-Xlog:gc*:file=gc.log # JDK 9+,统一日志
-XX:+PrintGCDetails # JDK 8,旧式
# 触发原因会出现在日志中,例如:
# [GC cause: Allocation Failure]
# [GC cause: Metadata GC Threshold]
# [GC cause: System.gc()]
参考资料
- David Ungar 1984 年论文:"Generation Scavenging"
- 《深入理解 Java 虚拟机》第 3 章 — 周志明
- OpenJDK 源码:
src/hotspot/share/gc/(各收集器触发逻辑)- JEP 333(ZGC)、JEP 189(Shenandoah)、JEP 173(G1 成为默认)官方设计文档
- CMS 收集器
- G1 收集器
- ZGC 与 Shenandoah
- GC 调优实践
评论 (0)