分代 GC 基于"弱分代假设(Weak Generational Hypothesis)":大多数对象在生命很短后就死亡。通过将堆分为年轻代(Young Generation)和老年代(Old Generation),GC 可以频繁快速地回收短命对象,极少打扰长命对象。本文覆盖 Eden/Survivor 空间的工作机制、晋升规则、跨代引用处理,以及各类边界异常。
相关文章:三色标记与写屏障 · G1 GC(Region-based) · ZGC(染色指针与负载屏障)
目录
| 章节 | 说明 |
|---|---|
| 弱分代假设 | 为什么分代有效 |
| 堆的结构 | Eden / S0 / S1 / Old / Metaspace |
| Minor GC 流程 | Eden 满时的年轻代回收 |
| 对象晋升规则 | 何时从 Young 升入 Old |
| 跨代引用处理 | Card Table 与 Remembered Set |
| 完整伪代码 | Minor GC + 晋升 + 跨代引用 |
| 执行追踪 | 对象从出生到晋升的完整生命周期 |
| Major GC / Full GC | 老年代回收的触发与代价 |
| 异常场景 | 晋升失败 / 大对象 / 元空间溢出 |
| 参数速查 | JVM 关键 GC 参数 |
弱分代假设
经验性统计(IBM 和 SUN 的研究,1980s~1990s):
约 80~98% 的对象在分配后不久即死亡
只有 2~20% 的对象能"活"到一定年龄
推论:
如果把新对象和老对象分开管理:
年轻代:空间小(几十~几百 MB),GC 频繁(每秒~每几秒),速度极快
老年代:空间大(几百 MB~几 GB),GC 极少(几分钟~几十分钟),但代价高
分代 GC 的效率来源:
Minor GC 只扫描年轻代(1~5% 的堆),不碰老年代
大多数垃圾在 Minor GC 就被回收
Full GC 极少触发 → 长暂停时间极少发生
堆的结构
JVM 堆结构(Parallel GC / Serial GC 的经典布局):
┌─────────────────────────────────────────────────────────┐
│ Young Generation │
│ ┌──────────────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Eden │ │ S0 │ │ S1 │ │
│ │ (新对象分配) │ │ Survivor │ │ Survivor │ │
│ │ 约 80% Young │ │ 约 10% │ │ 约 10% │ │
│ └──────────────────┘ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Old Generation │
│ 长命对象,分配失败时晋升至此 │
│ 大小约为 Young 的 2~3 倍 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Metaspace(类元数据,JDK 8+,不在堆内,受 OS 限制) │
└─────────────────────────────────────────────────────────┘
Survivor 空间(S0/S1):
任意时刻只有一个 Survivor 处于活跃状态("From")
另一个是空的("To")
Minor GC 后 From 和 To 互换角色
Minor GC 流程
触发条件:Eden 空间耗尽(无法为新对象分配内存)
Minor GC 使用"复制算法"(Copying Collection):
存活对象 → 复制到 To Survivor 或老年代
Eden + From Survivor → 清空
流程:
1. Stop-The-World(暂停所有用户线程)
2. 扫描 GC Roots(栈、静态变量)
标记 Eden + From Survivor 中的存活对象
3. 将存活对象复制到 To Survivor(或直接晋升 Old)
age(年龄)+= 1
4. 清空 Eden 和 From Survivor
5. 交换 From 和 To 的角色(From ↔ To)
6. 恢复用户线程
复制算法的优势:
只需遍历存活对象(少),不需遍历所有对象
复制后空间紧凑,无碎片,分配速度极快(指针碰撞)
内存利用率:Eden + 1 个 Survivor(约 90% Young 可用)
对象晋升规则
规则 1:年龄阈值晋升
每经历一次 Minor GC 存活,对象 age += 1
age 存储在对象头(Mark Word)的 4 bit 中(最大 15)
if obj.age >= MaxTenuringThreshold(默认 15):
晋升到 Old Generation
// 参数:-XX:MaxTenuringThreshold=15
规则 2:动态年龄判断
不总是等到 age=15 才晋升,JVM 会动态计算:
if Σ(age <= N 的对象大小) > Survivor * 0.5:
所有 age >= N 的对象直接晋升 Old
// 防止 Survivor 空间过于拥挤
直觉:Survivor 的一半被 age <= N 的对象占满了
说明 age > N 的对象应该"毕业"了,不要再占年轻代空间
规则 3:大对象直接进 Old
超过 PretenureSizeThreshold 的对象:
跳过 Eden,直接分配到 Old Generation
// 避免 Eden → Survivor → Old 的多次复制开销
// 参数:-XX:PretenureSizeThreshold=1MB
注意:G1 GC 没有此参数,用 Region 大小的一半作为阈值
规则 4:To Survivor 空间不足
Minor GC 复制时,To Survivor 剩余空间不足:
→ 将部分存活对象直接晋升 Old(提前晋升)
→ 若 Old 也不够 → 触发 Full GC
这是导致 Full GC 的常见原因之一
跨代引用处理
问题:老年代对象引用了年轻代对象(跨代引用),Minor GC 只扫描年轻代,怎么找到这些引用?
朴素做法:扫描整个老年代找对老年代引用 → 太慢(Old 很大)
解法:Card Table(卡表)
Card Table:
将老年代堆划分为一系列"卡片",每个卡片约 512 字节
每个卡片对应 Card Table 中的一个 byte(dirty bit)
当老年代中某个对象的引用字段被修改时(写屏障触发):
对应卡片 byte = dirty(1)
Minor GC 时:
只扫描 Card Table 中标记为 dirty 的卡片
对这些卡片中的对象检查是否有对年轻代的引用
找到跨代引用 → 作为 Minor GC 的额外根
效果:
只扫描有变化的老年代卡片(通常远少于整个老年代)
将 Minor GC 的扫描范围限制在年轻代 + dirty 卡片
写屏障触发卡表更新
# 每次写引用时(src.field = new_ref)
def write_barrier_card_table(src, field, new_ref):
src.field = new_ref
if is_in_old_gen(src) and is_in_young_gen(new_ref):
# 老年代对象引用了年轻代对象 → 标记卡片
card_index = (address(src) - old_gen_start) >> 9 # / 512
card_table[card_index] = DIRTY
完整伪代码
def minor_gc():
STW_begin()
# 收集所有根(栈 + 静态 + dirty 卡片中的跨代引用)
roots = []
roots.extend(scan_gc_roots()) # 栈变量等
roots.extend(scan_dirty_cards()) # 跨代引用
reset_dirty_cards() # 清除 dirty 标记
to_space = get_empty_survivor() # "To" Survivor
old_gen = get_old_gen()
age_histo = {} # 年龄直方图
# 复制存活对象
def copy_object(obj):
if obj.is_forwarded():
return obj.forwarding_ptr # 已被复制,返回新地址
age = obj.age + 1
# 判断晋升到 Old 还是 Survivor
if (age >= MaxTenuringThreshold
or to_space.remaining() < obj.size()):
dest = old_gen.allocate(obj.size())
if dest is None:
raise PromotionFailure() # Old 也满了 → 触发 Full GC
else:
dest = to_space.allocate(obj.size())
# 复制对象头 + 数据
copy(obj, dest)
dest.age = age
obj.forwarding_ptr = dest # 设置转发指针,防止重复处理
age_histo[age] = age_histo.get(age, 0) + obj.size()
# 递归扫描子对象
for child_ref in dest.fields:
if child_ref is not None and is_in_young_gen(child_ref):
new_child = copy_object(child_ref)
update_ref(dest, child_ref, new_child)
return dest
for root in roots:
if root.ref is not None and is_in_young_gen(root.ref):
new_ref = copy_object(root.ref)
root.ref = new_ref
# 清空 Eden 和 From Survivor(复制算法:直接重置指针)
eden.reset()
from_survivor.reset()
# 交换 From/To
swap(from_survivor, to_survivor)
# 动态年龄判断:调整 MaxTenuringThreshold
cumulative = 0
for age in sorted(age_histo.keys()):
cumulative += age_histo[age]
if cumulative > to_survivor.capacity() * 0.5:
MaxTenuringThreshold = min(age, MaxTenuringThreshold)
break
STW_end()
执行追踪
Eden=8MB,S0/S1=1MB,MaxTenuringThreshold=4
T1: 分配对象 A(1MB), B(0.5MB), C(2MB), D(0.5MB)... Eden 使用 8MB
Eden: [A(age0), B(age0), C(age0), D(age0)...]
T2: Minor GC #1(Eden 满)
存活对象:A, C(B, D 已无引用)
A(age0) → 复制到 S0,age=1;C(age0) → 复制到 S0,age=1
Eden 清空,From=S0,To=S1(空)
T3: 继续分配,Minor GC #2
A 存活:S0 → S1,age=2;C 存活:S0 → S1,age=2
T4: Minor GC #3,A/C age=3;Minor GC #4,A/C age=4
age=4 == MaxTenuringThreshold=4
→ A 和 C 晋升到 Old Generation!
T5: Old Generation: [A, C](长命对象)
Young Generation 继续处理新对象,不影响 A/C
T6: 如果 Old Generation 积累了大量垃圾,触发 Full GC(Major GC)
Full GC 暂停时间通常 = Minor GC × 10~100 倍
Major GC / Full GC
触发条件(JVM HotSpot):
1. 老年代使用率 > CMSInitiatingOccupancyFraction(CMS,默认 92%)
2. 晋升失败(Promotion Failure):Minor GC 时 Old 空间不足
3. 显式调用 System.gc()(不建议)
4. Metaspace 空间不足
5. 堆内存碎片无法满足大对象分配(Parallel GC)
Full GC 的代价:
暂停时间:几秒~几十秒(JVM 大堆场景)
回收算法:通常是标记-压缩(Mark-Compact),消除碎片但更慢
减少 Full GC 的方法:
1. 增大 Old Gen(-Xmx)
2. 降低晋升速率(增大 MaxTenuringThreshold)
3. 使用 G1/ZGC 代替 Parallel GC(更短暂停)
4. 减少大对象分配(避免直接进 Old)
异常场景
晋升失败(Promotion Failure)
Minor GC 时,To Survivor 满 → 晋升到 Old
Old Gen 也满 → Promotion Failure
→ 触发 Full GC(此时 Full GC 在 Minor GC 中途触发,代价更高)
特征:GC 日志中看到 "Promotion failed" 或 "concurrent mode failure"
解法:
增大 Old Gen 空间
降低对象晋升速率(优化代码,减少长命对象)
使用 G1 代替 Parallel GC(G1 有更好的晋升失败处理)
Survivor 空间溢出(Survivor 占比过小)
默认 Eden:S0:S1 = 8:1:1
若对象存活率高(如批量处理场景),Survivor 容纳不下
→ 大量对象提前晋升 Old → Old 快速填满 → 频繁 Full GC
诊断:-XX:+PrintTenuringDistribution 查看各年龄段对象分布
解法:-XX:SurvivorRatio=4(调为 4:1:1)或直接增大 Young Gen
Metaspace 溢出(类加载器泄漏)
现象:java.lang.OutOfMemoryError: Metaspace
原因:动态类加载(反射、CGLib、Groovy)不断创建新类,旧类没有被卸载
类卸载条件:ClassLoader 无引用 + 对应 Class 无引用 + 对应实例无引用
→ 三个条件同时满足极难,导致 Metaspace 持续增长
解法:
排查 ClassLoader 泄漏(通常是框架 Bug 或滥用反射)
设置 -XX:MaxMetaspaceSize 作为安全阀,触发 Full GC 清理
参数速查
| 参数 | 默认值 | 作用 |
|---|---|---|
-Xms / -Xmx |
JVM 决定 | 堆初始/最大大小(建议相同避免扩缩容) |
-Xmn |
约 1/3 堆 | Young Generation 大小 |
-XX:SurvivorRatio |
8 | Eden:S0:S1 = 8:1:1 |
-XX:MaxTenuringThreshold |
15 | 对象晋升 Old 的年龄阈值 |
-XX:PretenureSizeThreshold |
0(不限) | 超过此大小直接进 Old |
-XX:+PrintGCDetails |
false | 打印 GC 详情 |
-XX:+PrintTenuringDistribution |
false | 打印年龄分布 |
-XX:+UseParallelGC |
JDK8 默认 | 使用 Parallel GC |
-XX:+UseG1GC |
JDK9+ 默认 | 使用 G1 GC |
参考资料
- Wilson, P.R. (1992). Uniprocessor Garbage Collection Techniques
- Oracle JVM GC Tuning Guide: https://docs.oracle.com/en/java/javase/17/gctuning/
- 《深入理解 Java 虚拟机》第 3 章:垃圾收集器与内存分配策略
评论 (0)
发表评论