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 效率。
正是基于这三个假说,JVM 才有了”对不同生命周期的对象采用不同回收策略”的设计思路。
堆内存长什么样?
理解了为什么要分代,接下来看看 JVM 堆到底是怎么划分的。
内存布局
堆内存(Heap)
├── 新生代(Young Generation)
│ ├── Eden区(伊甸园区)
│ ├── Survivor0区(幸存者0区,简称S0)
│ └── Survivor1区(幸存者1区,简称S1)
└── 老年代(Old Generation/Tenured Generation)
默认比例配置
Eden : Survivor0 : Survivor1 = 8 : 1 : 1
新生代 : 老年代 = 1 : 2(可通过-XX:NewRatio调整)
为什么 Eden 占了新生代的 80%?因为根据弱分代假说,绝大多数对象在 Eden 区就会被回收掉,真正能存活下来进入 Survivor 区的只是少数,所以 Survivor 不需要太大的空间。
相关JVM参数
# 堆大小设置
-Xms4g -Xmx4g # 初始和最大堆大小
# 新生代设置
-Xmn1g # 新生代大小
-XX:NewRatio=3 # 老年代:新生代=3:1
-XX:SurvivorRatio=8 # Eden:Survivor=8:1
一个对象的一生
了解了内存结构,我们来跟踪一个对象从出生到最终归宿的完整旅程。
1. 对象创建与 Eden 区分配
// 新对象首先在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(对象);
}
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 里的对象都是连续排列的,彻底消除了内存碎片问题。
具体执行示例
第二次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. 年龄达到阈值
# JVM参数设置最大年龄阈值
-XX:MaxTenuringThreshold=15 # 默认值15
经历了 15 次 Minor GC 还活着?那你大概率是个长期存活的对象,去老年代吧。
2. 大对象直接分配
# 大对象阈值设置
-XX:PretenureSizeThreshold=1048576 # 大于1MB直接进入老年代
体积太大的对象在 Survivor 区之间来回复制太浪费性能,不如直接安排到老年代。
3. Survivor 区空间不足
if (ToSpace剩余空间 < 存活对象大小) {
// 直接晋升到老年代,避免复制失败
promoteToOldGeneration(存活对象);
}
4. 动态年龄判断
// 如果相同年龄所有对象大小总和 > Survivor空间的一半
// 则年龄大于等于该年龄的对象直接晋升
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=1m # 大对象阈值
-XX:TargetSurvivorRatio=50 # Survivor目标使用率
三种 GC 类型,影响各不同
不同的 GC 类型,对应用的影响差异巨大。搞清楚它们的区别,是性能调优的基础。
Minor GC(新生代 GC)
触发条件:Eden区空间不足
回收范围:新生代(Eden + Survivor区)
回收算法:复制算法(Copying Algorithm)
特点:频繁执行,STW时间短
Major GC(老年代GC)
触发条件:老年代空间不足
回收范围:老年代
回收算法:标记-清除 或 标记-整理算法
特点:执行较少,STW时间较长
Full GC(全堆GC)
触发条件:
1. 老年代空间不足
2. 永久代/元空间不足
3. System.gc()调用
4. 分配担保失败
回收范围:新生代 + 老年代 + 永久代/元空间
特点:STW时间最长,影响性能
Full GC 是我们最不想看到的。它会暂停所有应用线程(Stop The World),如果频繁发生,用户体验会直线下降。
空间分配担保:Minor GC 前的安全检查
在执行 Minor GC 之前,JVM 其实会先做一次”风险评估”。毕竟,万一这次 Minor GC 后存活的对象太多,Survivor 区放不下,全都要晋升到老年代,而老年代也放不下呢?
担保检查流程
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。
担保相关参数
# 分配担保
-XX:+HandlePromotionFailure # 启用分配担保(JDK6后默认开启)
从永久代到元空间:一次重要的进化
如果你经历过 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)
永久代(PermGen)- JDK 7及以前
存储内容
// 永久代主要存储:
1. 类的元数据信息(Class Metadata)
2. 字符串常量池(String Pool)- JDK 7移到堆中
3. 静态变量(Static Variables)- JDK 7移到堆中
4. 即时编译器编译后的代码缓存
永久代参数
# 永久代大小参数(JDK 7及以前)
-XX:PermSize=64m # 初始永久代大小
-XX:MaxPermSize=256m # 最大永久代大小
元空间(Metaspace)- JDK 8及以后
永久代最大的痛点是什么?大小固定。你设小了,动态代理、热部署这些场景分分钟给你一个 java.lang.OutOfMemoryError: PermGen space。
引入原因
永久代的问题:
❌ 大小固定,容易OOM
❌ 回收困难,效率低
❌ 调优复杂
元空间的优势:
✅ 使用本地内存,大小可动态扩展
✅ 自动触发垃圾回收
✅ 减少OOM风险
元空间参数
# 元空间大小设置(JDK 8+)
-XX:MetaspaceSize=128m # 初始元空间大小
-XX:MaxMetaspaceSize=512m # 最大元空间大小(默认无限制)
-XX:CompressedClassSpaceSize=1g # 压缩类空间大小
# 元空间回收设置
-XX:MinMetaspaceFreeRatio=40 # 最小空闲比例
-XX:MaxMetaspaceFreeRatio=70 # 最大空闲比例
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:直接晋升到老年代
if (survivingObjectsSize > toSpaceAvailableSize) {
promoteToOldGeneration(survivingObjects);
} else {
copyToToSpace(survivingObjects);
}
策略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效率
如果你在 GC 日志中频繁看到 Promotion Failed,那就该认真审视 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. 代码层面优化
参数调优能做的有限,很多时候问题出在代码本身。来看一个典型的反面教材:
问题代码
// ❌ 大量对象同时存活
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。
优化代码
// ✅ 分批处理,减少同时存活对象
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++) {
String data = heavyComputation(j);
batch.add(data);
}
processBatch(batch); // 处理完一批就释放
// batch会在下次Minor GC时被回收
}
}
分批处理后,每批只有 1000 个对象同时存活,它们在 Minor GC 时就能被回收,根本不会进入老年代。
监控与诊断
调优不能靠猜,你需要数据。
1. GC日志分析
启用GC日志
# 启用详细GC日志
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:gc.log
日志解读
# 正常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]
第二条日志里,新生代回收后变成了 0K,但老年代从 2048K 暴增到 7168K——这说明大量对象被直接晋升到了老年代,是一个值得关注的信号。
2. 监控工具使用
命令行工具
# 使用jstat监控元空间
jstat -gc <pid> 1s
# 使用jcmd查看详细信息
jcmd <pid> VM.classloader_stats
自定义监控
// 性能监控指标
public class GCMetrics {
private long minorGCCount; // Minor GC次数
private long minorGCTime; // Minor GC总时间
private long majorGCCount; // Major GC次数
private long majorGCTime; // Major GC总时间
private long promotedBytes; // 晋升字节数
private long allocationRate; // 分配速率
// 计算新生代回收效率
public double getMinorGCEfficiency() {
return (double) promotedBytes / (minorGCCount * youngGenSize);
}
}
写在最后
回顾一下整篇文章的核心脉络:
- 分代的本质是根据对象生命周期特征采用不同回收策略——短命对象频繁回收,长寿对象少管
- Survivor 区的双区设计通过复制算法消除了内存碎片,是空间换时间的经典权衡
- 晋升机制不是一刀切的,年龄、大小、空间状况、动态判断四管齐下
- Survivor 区满了不会触发额外 GC,而是改变分配策略,但可能引发连锁反应
- 元空间替代永久代解决了固定大小的历史包袱,但仍需合理配置
实际工作中,我的建议是:先通过 GC 日志定位问题,再有针对性地调整参数,最后回到代码层面做根本性的优化。盲目调参数往往事倍功半,而好的代码设计才是最好的 GC 调优。