JVM 分代垃圾回收机制详解
深入理解
JVM分代模型的设计原理、执行流程和优化策略。
你有没有遇到过这样的场景:线上应用跑着跑着突然卡顿几秒,监控面板上 GC 时间飙升,用户开始投诉响应变慢?你赶紧登上服务器看日志,满屏的 Full GC 让人心跳加速。
这时候你会意识到,理解 JVM 的垃圾回收机制不是面试八股文,而是真正能帮你定位问题、拯救线上事故的硬技能。今天我们就来把分代垃圾回收这件事彻底聊透。
为什么要分代?从三个假说说起
JVM 的设计者们不是拍脑袋决定把堆内存分成几块的。分代回收的背后,有三个经过大量实践验证的假说作为理论支撑。
1. 弱分代假说(Weak Generational Hypothesis)
绝大多数对象都是朝生夕灭的
→ 新创建的对象很快就会变成垃圾
→ 新生代应该频繁回收
想想你写的代码:一个方法里创建的局部变量、临时的 StringBuilder、循环中的中间对象——这些东西用完就扔,生命周期极短。
2. 强分代假说(Strong Generational Hypothesis)
熬过越多次GC的对象越难以回收
→ 存活时间长的对象继续存活的概率大
→ 老年代可以较少回收
而那些被缓存持有的对象、Spring 容器中的 Bean、全局配置数据,一旦活过了最初的几轮 GC,大概率会一直活下去。
3. 跨代引用假说(Intergenerational Reference Hypothesis)
跨代引用相对于同代引用占极少数
→ 老年代对象很少引用新生代对象
→ 可以通过记忆集等技术优化扫描
这个假说让我们在回收新生代时不必扫描整个老年代,大幅提升了 GC 效率。
[!NOTE] 这三个假说不是理论推导,而是从大量 Java 应用的运行数据中归纳出来的经验规律。研究表明,绝大多数应用中 90% 以上的对象在第一次 GC 时就已经死亡。
关于对象存活率的统计数据,可参见相关研究1。
正是基于这三个假说,JVM 才有了”对不同生命周期的对象采用不同回收策略”的设计思路。
graph LR
A[弱分代假说] -->|多数对象朝生夕灭| B[新生代频繁回收]
C[强分代假说] -->|长寿对象持续存活| D[老年代低频回收]
E[跨代引用假说] -->|跨代引用极少| F[记忆集优化扫描]
B --> G[分代回收策略]
D --> G
F --> G
堆内存长什么样?
理解了为什么要分代,接下来看看 JVM 堆到底是怎么划分的。
内存布局
堆内存(Heap)
├── 新生代(Young Generation)
│ ├── Eden区(伊甸园区)
│ ├── Survivor0区(幸存者0区,简称S0)
│ └── Survivor1区(幸存者1区,简称S1)
└── 老年代(Old Generation/Tenured Generation)
默认比例配置
新生代内部的默认空间比例为:
Eden:S0:S1=8:1:1
新生代与老年代的默认比例为:
Young:Old=1:2
可通过 -XX:NewRatio 调整。
为什么 Eden 占了新生代的 80%?因为根据弱分代假说,绝大多数对象在 Eden 区就会被回收掉,真正能存活下来进入 Survivor 区的只是少数,所以 Survivor 不需要太大的空间。
堆内存核心参数
| 参数 | 说明 | 示例 |
|---|---|---|
-Xms / -Xmx | 初始 / 最大堆大小 | -Xms4g -Xmx4g |
-Xmn | 新生代大小 | -Xmn1g |
-XX:NewRatio | 老年代与新生代的比值 | -XX:NewRatio=3(老:新=3:1) |
-XX:SurvivorRatio | Eden 与单个 Survivor 的比值 | -XX:SurvivorRatio=8(Eden:S0=8:1) |
[!TIP] 生产环境建议将
-Xms和-Xmx设为相同值,避免堆内存动态扩缩带来的性能波动。这是一个简单但非常有效的优化。
一个对象的一生
了解了内存结构,我们来跟踪一个对象从出生到最终归宿的完整旅程。
flowchart TD
A[新对象创建] --> B{Eden 区空间足够?}
B -->|是| C[分配到 Eden 区]
B -->|否| D[触发 Minor GC]
D --> E{对象存活?}
E -->|否| F[回收]
E -->|是| G{Survivor 区放得下?}
G -->|是| H[复制到 Survivor 区\nage + 1]
G -->|否| I[直接晋升老年代]
H --> J{age >= 阈值?}
J -->|是| K[晋升到老年代]
J -->|否| L[继续在 Survivor 区\n等待下次 GC]
L --> D
1. 对象创建与 Eden 区分配
String name = "张三"; // 分配在Eden区
List<String> list = new ArrayList<>(); // 分配在Eden区
所有新对象默认都在 Eden 区出生。这里就像一个新生儿病房,热闹但流动性极大。
2. Minor GC 触发条件
当 Eden 区空间不足时,触发 Minor GC(新生代垃圾回收)
3. Minor GC 执行过程
第一次Minor GC:
Eden区:[对象A][对象B][垃圾1][垃圾2][对象C]
S0区: [空]
S1区: [空]
GC后:
Eden区:[空]
S0区: [对象A(age=1)][对象B(age=1)][对象C(age=1)]
S1区: [空]
注意,存活下来的对象被复制到了 S0 区,同时每个对象多了一个 age 标记。这个年龄计数器,将决定对象未来的命运。
4. 对象年龄(Age)机制
// 每经历一次Minor GC,存活对象年龄+1
对象年龄 = 经历的Minor GC次数
if (对象年龄 >= MaxTenuringThreshold) {
promoteToOldGeneration(对象);
}
默认的晋升年龄阈值为 MaxTenuringThreshold=15,即对象经历 15 次 Minor GC 后仍然存活,就会被晋升到老年代。
Survivor 区的精妙设计
为什么需要两个 Survivor 区?一个不行吗?这其实是复制算法的经典应用,目的是避免内存碎片。
From Space 和 To Space 概念
任意时刻,两个Survivor区中:
- 一个作为From Space(当前使用)
- 一个作为To Space(空闲备用)
Minor GC时:
1. 扫描Eden区 + From Space的存活对象
2. 将存活对象复制到To Space
3. 清空Eden区和From Space
4. 交换From Space和To Space的角色
这种乒乓式的设计,保证了每次 GC 后 To Space 里的对象都是连续排列的,彻底消除了内存碎片问题。
[!IMPORTANT] 任意时刻总有一个 Survivor 区是空的。如果你通过监控工具发现两个 Survivor 区都有数据,说明 GC 可能存在异常,值得排查。
具体执行示例
第二次Minor GC前:
Eden区:[对象D][对象E][垃圾3][垃圾4]
S0区: [对象A(age=1)][对象B(age=1)][对象C(age=1)] ← From Space
S1区: [空] ← To Space
第二次Minor GC后:
Eden区:[空]
S0区: [空] ← 新的To Space
S1区: [对象A(age=2)][对象B(age=2)][对象C(age=2)][对象D(age=1)][对象E(age=1)] ← 新的From Space
看到了吗?S0 和 S1 的角色发生了互换。之前的老对象年龄 +1,新存活的对象年龄从 1 开始。
什么时候进入老年代?
对象不会永远待在新生代。以下四种情况会触发晋升:
1. 年龄达到阈值
-XX:MaxTenuringThreshold=15 # 默认值15
经历了 15 次 Minor GC 还活着?那你大概率是个长期存活的对象,去老年代吧。
2. 大对象直接分配
-XX:PretenureSizeThreshold=1048576 # 大于1MB直接进入老年代
体积太大的对象在 Survivor 区之间来回复制太浪费性能,不如直接安排到老年代。
3. Survivor 区空间不足
if (ToSpace剩余空间 < 存活对象大小) {
promoteToOldGeneration(存活对象);
}
4. 动态年龄判断
如果相同年龄所有对象大小总和超过 Survivor 空间的一半,则年龄大于等于该年龄的对象直接晋升。即当累计大小满足:
∑i=1ageObjectSize(i)≥2SurvivorCapacity
则年龄 ≥age 的对象全部晋升到老年代。
动态年龄判断伪代码
public int calculateDynamicAge() {
int targetSize = survivorSize / 2;
int cumulativeSize = 0;
for (int age = 1; age <= MaxTenuringThreshold; age++) {
cumulativeSize += getObjectSizeByAge(age);
if (cumulativeSize >= targetSize) {
return age;
}
}
return MaxTenuringThreshold;
}
这个动态年龄判断非常聪明——它不会死板地等到 15 岁才晋升,而是根据 Survivor 区的实际使用情况灵活调整。
晋升相关参数
| 参数 | 说明 | 默认值 |
|---|---|---|
-XX:MaxTenuringThreshold | 最大晋升年龄 | 15 |
-XX:PretenureSizeThreshold | 大对象直接进入老年代的阈值 | 0(不启用) |
-XX:TargetSurvivorRatio | Survivor 区目标使用率,影响动态年龄判断 | 50 |
三种 GC 类型对比
不同的 GC 类型,对应用的影响差异巨大。搞清楚它们的区别,是性能调优的基础。
| 特性 | Minor GC | Major GC | Full GC |
|---|---|---|---|
| 回收范围 | 新生代(Eden + Survivor) | 老年代 | 新生代 + 老年代 + 元空间 |
| 触发条件 | Eden 区空间不足 | 老年代空间不足 | 老年代不足 / 元空间不足 / System.gc() / 分配担保失败 |
| 回收算法 | 复制算法 | 标记-清除 / 标记-整理 | 视收集器而定 |
| STW 时间 | 短(通常 < 50ms) | 较长 | 最长(可达数秒) |
| 执行频率 | 高 | 低 | 极低(理想情况) |
| 影响程度 | 几乎无感 | 有感知 | 明显卡顿 |
[!WARNING] Full GC 是线上事故的常见元凶。如果你在 GC 日志中发现 Full GC 频率超过每分钟一次,需要立即排查。常见原因包括:内存泄漏、大对象频繁分配、元空间不足、代码中误调
System.gc()。
空间分配担保:Minor GC 前的安全检查
在执行 Minor GC 之前,JVM 其实会先做一次”风险评估”。毕竟,万一这次 Minor GC 后存活的对象太多,Survivor 区放不下,全都要晋升到老年代,而老年代也放不下呢?
flowchart TD
A[Minor GC 前] --> B{老年代空间 > 新生代所有对象?}
B -->|是| C[担保成功 → 执行 Minor GC]
B -->|否| D{允许冒险担保?}
D -->|否| E[直接 Full GC]
D -->|是| F{老年代空间 > 历次晋升平均值?}
F -->|是| G[冒险执行 Minor GC]
F -->|否| E
G --> H{实际晋升是否成功?}
H -->|是| I[Minor GC 完成]
H -->|否| J[Promotion Failed → Full GC]
担保检查伪代码
public boolean checkAllocationGuarantee() {
long oldGenMaxFreeSpace = oldGeneration.getLargestFreeSpace();
long youngGenTotalSpace = youngGeneration.getTotalSpace();
if (oldGenMaxFreeSpace > youngGenTotalSpace) {
return true; // 担保成功
}
// 用历史数据赌一把
if (HandlePromotionFailure &&
oldGenMaxFreeSpace > getAveragePromotedSize()) {
return true; // 冒险担保
}
return false; // 担保失败,需要Full GC
}
注意第二个判断条件:即使老年代空间不够装下所有新生代对象,JVM 还会用历史数据赌一把——如果过去每次晋升的对象平均大小比当前老年代剩余空间小,就冒险继续 Minor GC。赌输了?那就触发 Full GC。
从永久代到元空间:一次重要的进化
如果你经历过 JDK 7 到 JDK 8 的迁移,一定对 PermGen 的消失印象深刻。
JVM内存结构完整图景
JVM内存结构
├── 堆内存(Heap)
│ ├── 新生代(Young Generation)
│ └── 老年代(Old Generation)
├── 非堆内存(Non-Heap)
│ ├── 方法区(Method Area)
│ │ ├── 永久代(PermGen)- JDK 7及以前
│ │ └── 元空间(Metaspace)- JDK 8及以后
│ ├── 程序计数器(PC Register)
│ ├── 本地方法栈(Native Method Stack)
│ └── 虚拟机栈(VM Stack)
└── 直接内存(Direct Memory)
永久代 vs 元空间
| 对比维度 | 永久代(PermGen) | 元空间(Metaspace) |
|---|---|---|
| JDK 版本 | JDK 7 及以前 | JDK 8 及以后 |
| 存储位置 | JVM 堆内存中 | 本地内存(Native Memory) |
| 大小限制 | 固定大小,需手动设置 | 默认无上限,可动态扩展 |
| OOM 风险 | 高,PermGen space 错误常见 | 低,但仍需合理配置上限 |
| 回收效率 | 低,回收条件苛刻 | 高,自动触发回收 |
| 存储内容 | 类元数据 + 字符串常量池 + 静态变量 | 仅类元数据(字符串池和静态变量已移至堆中) |
永久代最大的痛点是什么?大小固定。你设小了,动态代理、热部署这些场景分分钟给你一个 java.lang.OutOfMemoryError: PermGen space。
元空间核心参数
| 参数 | 说明 | 建议值 |
|---|---|---|
-XX:MetaspaceSize | 初始元空间大小(也是首次触发 GC 的阈值) | 128m–256m |
-XX:MaxMetaspaceSize | 最大元空间大小 | 256m–512m |
-XX:CompressedClassSpaceSize | 压缩类空间大小 | 默认 1g,一般不需调整 |
-XX:MinMetaspaceFreeRatio | GC 后最小空闲比例 | 40 |
-XX:MaxMetaspaceFreeRatio | GC 后最大空闲比例 | 70 |
[!TIP] 虽然元空间默认不限大小,但生产环境强烈建议设置
-XX:MaxMetaspaceSize。否则一旦出现类加载泄漏(比如热部署场景),元空间会无限增长直到吃光系统内存,到时候排查起来更痛苦。
Survivor 区满了怎么办?
这是很多人会忽略的一个细节,但在实际生产中经常碰到。
问题场景:To Space 空间不足
Minor GC执行过程中:
Eden区:[对象A][对象B][对象C][垃圾...]
From Space(S0):[对象D(age=2)][对象E(age=3)][对象F(age=1)]
To Space(S1):[空] ← 容量不足以容纳所有存活对象
存活对象总大小 > To Space可用空间
JVM处理策略
JVM 不会因为 Survivor 放不下就崩溃,它有两套应对方案:
策略1:直接晋升到老年代 —— 所有放不下的对象一律晋升。
策略2:分批处理 —— 部分对象复制到 To Space,剩余的晋升到老年代。
Eden区存活对象:[A][B][C][D][E]
From Space对象:[F(age=2)][G(age=3)]
处理结果:
To Space:[A][B][F(age=3)] ← 放得下的对象
老年代: [C][D][E][G] ← 放不下的对象直接晋升
连锁反应
但这种”提前晋升”并不是没有代价的:
- 可能触发
老年代GC:大量对象晋升导致老年代压力 - 分配担保检查:检查老年代是否有足够空间
- 性能影响:频繁的提前晋升影响GC效率
[!WARNING] 如果你在 GC 日志中频繁看到
Promotion Failed,那就该认真审视 Survivor 区的大小配置了。通常的解决思路是:增大新生代(-Xmn)或调整 Survivor 比例(-XX:SurvivorRatio),让 Survivor 有足够空间容纳存活对象。
实战调优:从参数到代码
理论讲完了,来点实际的。以下是几个在生产中验证过的调优方向。
1. Survivor区大小调整
# 增大Survivor区比例
-XX:SurvivorRatio=6 # Eden:Survivor = 6:1:1 (默认8:1:1)
# 或者直接设置新生代大小
-Xmn2g # 设置新生代为2GB
2. 对象晋升策略调整
# 降低晋升年龄阈值,让对象更早晋升
-XX:MaxTenuringThreshold=3 # 默认15,改为3
# 设置大对象阈值
-XX:PretenureSizeThreshold=1048576 # 大于1MB直接进老年代
3. 代码层面优化
参数调优能做的有限,很多时候问题出在代码本身。来看一个典型的反面教材:
// 10万个对象同时存活,最终全部晋升到老年代
public List<String> processData() {
List<String> results = new ArrayList<>();
for (int i = 0; i < 100000; i++) {
String data = heavyComputation(i);
results.add(data);
}
return results;
}
这段代码的问题在于:10 万个对象同时存活,它们会从 Eden 晋升到 Survivor,再从 Survivor 晋升到老年代,最终导致 Full GC。
// 分批处理,每批1000个对象,用完即回收
public void processDataInBatches() {
int batchSize = 1000;
for (int i = 0; i < 100000; i += batchSize) {
List<String> batch = new ArrayList<>();
for (int j = i; j < i + batchSize && j < 100000; j++) {
batch.add(heavyComputation(j));
}
processBatch(batch);
}
}
分批处理后,每批只有 1000 个对象同时存活,它们在 Minor GC 时就能被回收,根本不会进入老年代。
监控与诊断
调优不能靠猜,你需要数据。
1. GC日志分析
开启 GC 日志是排查问题的第一步:
# JDK 8
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:gc.log
# JDK 9+(统一日志框架)
-Xlog:gc*:file=gc.log:time,uptime,level,tags
日志解读示例
# 正常的Minor GC:新生代回收了大部分对象
[GC (Allocation Failure) [PSYoungGen: 8192K->1024K(9216K)] 8192K->2048K(19456K), 0.0123456 secs]
# 异常信号:新生代清零,但老年代暴增——大量对象被直接晋升
[GC (Allocation Failure) [PSYoungGen: 8192K->0K(9216K)] [ParOldGen: 2048K->7168K(10240K)] 8192K->7168K(19456K), 0.0234567 secs]
[!NOTE] 第二条日志中,新生代回收后变成了 0K,但老年代从 2048K 暴增到 7168K——这说明大量对象被直接晋升到了老年代,是一个值得关注的信号。如果这种情况频繁出现,需要检查是否存在大对象分配或 Survivor 区过小的问题。
2. 常用监控命令
# 实时监控GC状况(每秒刷新)
jstat -gc <pid> 1s
# 查看类加载信息
jcmd <pid> VM.classloader_stats
# 生成堆转储文件用于离线分析
jmap -dump:format=b,file=heap.hprof <pid>
GC 调优检查清单
在进行 GC 调优之前,按照以下清单逐项排查:
- 确认
-Xms和-Xmx设为相同值,避免堆内存动态伸缩 - 开启 GC 日志(JDK 8 用
-XX:+PrintGCDetails,JDK 9+ 用-Xlog:gc*) - 分析 GC 日志,确认 Full GC 频率是否在合理范围内(低于每分钟一次)
- 检查 Minor GC 后的 Survivor 区使用率,判断是否需要调整
-XX:SurvivorRatio - 确认是否存在大对象频繁分配,考虑设置
-XX:PretenureSizeThreshold - 检查元空间配置,确保设置了
-XX:MaxMetaspaceSize上限 - 排查代码中是否有
System.gc()的显式调用 - 使用
jmap或 MAT 分析堆转储,排除内存泄漏
写在最后
回顾一下整篇文章的核心脉络:
- 分代的本质是根据对象生命周期特征采用不同回收策略——短命对象频繁回收,长寿对象少管
- Survivor 区的双区设计通过复制算法消除了内存碎片,是空间换时间的经典权衡
- 晋升机制不是一刀切的,年龄、大小、空间状况、动态判断四管齐下
- Survivor 区满了不会触发额外 GC,而是改变分配策略,但可能引发连锁反应
- 元空间替代永久代解决了固定大小的历史包袱,但仍需合理配置
实际工作中,我的建议是:先通过 GC 日志定位问题,再有针对性地调整参数,最后回到代码层面做根本性的优化。盲目调参数往往事倍功半,而好的代码设计才是最好的 GC 调优。更多细节可参考 Oracle 官方 GC 调优指南。
Footnotes
-
该数据来源于 Sun Microsystems 对大量 Java 应用的运行时统计分析,详见《The Garbage Collection Handbook》及 Oracle 官方文档中对弱分代假说的阐述。 ↩