JVM 分代垃圾回收机制详解
深入理解 JVM 分代模型的设计原理、执行流程和优化策略
文章目录13 节
- 为什么要分代?从三个假说说起
- 传统分代堆内存长什么样?
- 内存布局
- 常见比例配置
- 堆内存核心参数
- 一个对象的一生
- 1. 对象创建与 Eden 区分配
- 2. Minor GC 触发条件
- 3. Minor GC 执行过程
- 4. 对象年龄(Age)机制
- Survivor 区的精妙设计
- From Space 和 To Space 概念
- 具体执行示例
- 什么时候进入老年代?
- 晋升相关参数
- GC 日志中的几类称呼
- 传统分代收集器中的晋升空间
- 从永久代到元空间:一次重要的进化
- JVM内存结构完整图景
- 永久代 vs 元空间
- 元空间核心参数
- Survivor 区满了怎么办?
- 问题场景:To Space 空间不足
- JVM处理策略
- 连锁反应
- 实战调优:从参数到代码
- 1. Survivor区大小调整
- 2. 对象晋升策略调整
- 3. 代码层面优化
- 监控与诊断
- 1. GC日志分析
- 2. 常用监控命令
- GC 调优检查清单
- 写在最后
深入理解
JVM分代模型的设计原理、执行流程和优化策略。
你有没有遇到过这样的场景:线上应用跑着跑着突然卡顿几秒,监控面板上 GC 时间飙升,用户开始投诉响应变慢?你赶紧登上服务器看日志,满屏的 Full GC 让人心跳加速。
这时候你会意识到,理解 JVM 的垃圾回收机制不是面试八股文,而是真正能帮你定位问题、拯救线上事故的硬技能。今天我们就来把分代垃圾回收这件事彻底聊透。
重要下文关于连续新生代、Eden、两块 Survivor 和 Minor GC 的流程,主要用 Serial / Parallel 等传统分代收集器建立概念。G1 虽然也有 Eden 和 Survivor 角色,但底层按 Region 管理并动态调整年轻代;ZGC、Shenandoah 等收集器又有不同实现。调参数前先确认当前 JDK 和收集器,不要把一套固定布局套到所有 JVM 上。
为什么要分代?从三个假说说起
JVM 的设计者们不是拍脑袋决定把堆内存分成几块的。分代回收的背后,有三个经过大量实践验证的假说作为理论支撑。
1. 弱分代假说(Weak Generational Hypothesis)
绝大多数对象都是朝生夕灭的
→ 新创建的对象很快就会变成垃圾
→ 新生代应该频繁回收
想想你写的代码:一个方法里创建的局部变量、临时的 StringBuilder、循环中的中间对象——这些东西用完就扔,生命周期极短。
2. 强分代假说(Strong Generational Hypothesis)
熬过越多次GC的对象越难以回收
→ 存活时间长的对象继续存活的概率大
→ 老年代可以较少回收
而那些被缓存持有的对象、Spring 容器中的 Bean、全局配置数据,一旦活过了最初的几轮 GC,大概率会一直活下去。
3. 跨代引用假说(Intergenerational Reference Hypothesis)
跨代引用相对于同代引用占极少数
→ 老年代对象很少引用新生代对象
→ 可以通过记忆集等技术优化扫描
这个假说让我们在回收新生代时不必扫描整个老年代,大幅提升了 GC 效率。
注意这三个假说来自对大量程序运行特征的经验归纳,不是每个应用都满足的固定比例。对象存活率应以自己服务的 GC 日志和分配剖析结果为准。
正是基于这三个假说,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)
常见比例配置
对使用这些参数的传统分代收集器,-XX:SurvivorRatio=8 对应:
-XX:NewRatio=2 对应:
这些是参数含义的示例,不是所有 JDK、所有收集器都遵循的统一默认布局。G1 会根据暂停时间目标动态调整年轻代,通常不应再用 -Xmn 或 -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) |
提示对延迟敏感且内存余量明确的服务,常见做法是把
-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(通常还会经过线程本地分配缓冲区 TLAB)。大对象、逃逸分析后的优化结果以及不同收集器的策略都可能例外,所以这不是绝对规则。
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(对象);
}
对于支持 -XX:MaxTenuringThreshold 的收集器,常见上限是 15;对象也可能因为 Survivor 空间和动态年龄判断而更早晋升,实际行为要以当前收集器和 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 里的对象都是连续排列的,彻底消除了内存碎片问题。
重要“From / To 一用一空”描述的是经典复制流程中的逻辑角色。不同监控工具的采样时机和展示口径不同,G1 又是按多个 Region 统计,不能仅凭“两边都有数据”判断 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 # 最大年龄阈值示例
对象年龄达到当前收集器允许的阈值后,会进入老年代;它也可能因为空间压力更早晋升。
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 | 最大晋升年龄 | 上限和实际年龄取决于收集器 |
-XX:PretenureSizeThreshold | 大对象直接进入老年代的阈值 | 并非所有收集器都支持或采用同样行为 |
-XX:TargetSurvivorRatio | Survivor 区目标使用率,影响动态年龄判断 | 仅在对应收集器中按实测调整 |
GC 日志中的几类称呼
Minor GC、Major GC、Full GC 是常见的概念性称呼,但不同收集器和日志格式对它们的使用并不完全一致。下面的表格只帮助理解大致范围,不能作为统一的触发条件或耗时标准。
| 特性 | Minor GC | Major GC | Full GC |
|---|---|---|---|
| 常见理解 | 年轻代回收 | 老年代回收 | 整堆或更完整的回收路径 |
| 常见触发 | 年轻代分配压力 | 老年代回收需求 | 整堆或收集器退化路径,原因因实现而异 |
| 回收算法 | 取决于收集器 | 取决于收集器 | 取决于收集器 |
| STW 时间 | 与堆、存活对象和收集器有关 | 与堆、存活对象和收集器有关 | 通常风险更高,但没有统一数值 |
| 执行频率 | 按分配速率变化 | 按晋升和并发回收状态变化 | 应结合业务延迟目标观察 |
| 影响程度 | 看暂停时间和吞吐量 | 看暂停时间和吞吐量 | 需重点关联应用延迟和错误率 |
警告不要用“每分钟几次”作为通用红线。应把 GC 原因、暂停时间、吞吐量、堆增长趋势与业务延迟目标放在一起判断;如果 Full GC 持续出现并伴随长暂停或回收后占用仍然很高,再重点排查内存泄漏、大对象分配、元空间压力和显式
System.gc()。
传统分代收集器中的晋升空间
年轻代回收后,部分存活对象可能要晋升到老年代。收集器需要判断老年代是否有足够空间承接这些对象;空间不足时,可能提前发起老年代回收,或者在晋升失败后进入更重的回收路径。
flowchart TD
A[年轻代回收前] --> B[估算存活对象与晋升空间]
B --> C{老年代承接能力是否足够?}
C -->|足够| D[执行年轻代回收]
C -->|不足| E[先处理老年代压力]
D --> F{实际晋升是否成功?}
F -->|成功| G[回收完成]
F -->|失败| H[进入收集器对应的失败处理]
这里保留的是帮助理解的逻辑模型。具体判断会随 JDK 版本和收集器变化,旧资料里常见的 HandlePromotionFailure 等开关不能直接当作现代 JVM 的通用实现。
从永久代到元空间:一次重要的进化
如果你经历过 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 | 类加载泄漏或上限过低时仍可能 OOM |
| 存储内容 | 类元数据 + 字符串常量池 + 静态变量 | 仅类元数据(字符串池和静态变量已移至堆中) |
永久代最大的痛点是什么?大小固定。你设小了,动态代理、热部署这些场景分分钟给你一个 java.lang.OutOfMemoryError: PermGen space。
元空间核心参数
| 参数 | 说明 | 使用提示 |
|---|---|---|
-XX:MetaspaceSize | 影响首次元空间 GC 的高水位 | 根据类加载规模和 GC 日志评估 |
-XX:MaxMetaspaceSize | 最大元空间大小 | 留出本地内存余量后再决定是否限制 |
-XX:CompressedClassSpaceSize | 压缩类空间预留大小 | 一般先保留 JVM 默认值 |
-XX:MinMetaspaceFreeRatio | GC 后最小空闲比例 | 仅在确认元空间扩缩问题后调整 |
-XX:MaxMetaspaceFreeRatio | GC 后最大空闲比例 | 仅在确认元空间扩缩问题后调整 |
提示是否设置
-XX:MaxMetaspaceSize要看本地内存预算和类加载规模。上限可以防止类加载泄漏持续挤占系统内存,但设得过小也会增加 GC 压力,甚至提前触发 OOM;先监控,再留出安全余量。
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效率
警告如果 GC 日志频繁出现晋升失败,先确认是存活对象突增、老年代余量不足,还是回收周期没能及时完成。只有使用支持这些参数的传统分代收集器时,才考虑
-Xmn或-XX:SurvivorRatio;G1 不建议用-Xmn、-XX:NewRatio等参数固定年轻代大小。
实战调优:从参数到代码
理论讲完了,来点实际的。下面的参数示例主要用于说明排查方向,先在与生产一致的环境中验证,再决定是否使用。
1. Survivor区大小调整
以下示例只适用于采用连续年轻代布局、并支持相应参数的收集器:
# 调整 Eden 与单个 Survivor 的比例
-XX:SurvivorRatio=6 # Eden:S0:S1 = 6:1:1
# 或者直接设置新生代大小
-Xmn2g # 设置新生代为2GB
2. 对象晋升策略调整
同样要先核对当前收集器是否支持,以及该参数是否真的参与当前分配路径:
# 降低最大晋升年龄阈值,让对象更早晋升
-XX:MaxTenuringThreshold=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]
注意第二条日志中,新生代回收后变成了 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 调优之前,按照以下清单逐项排查:
- 确认当前 JDK、垃圾收集器、堆限制和业务延迟目标
- 开启 GC 日志(JDK 8 用
-XX:+PrintGCDetails,JDK 9+ 用-Xlog:gc*) - 分析 GC 原因、暂停时间和回收后占用,并与应用延迟关联
- 若收集器适用,检查 Survivor 使用率,再判断是否需要调整比例
- 确认是否存在大对象频繁分配,优先定位分配来源,再评估参数
- 监控元空间增长,按泄漏风险和内存预算判断是否需要设置上限
- 排查代码中是否有
System.gc()的显式调用 - 使用
jmap或 MAT 分析堆转储,排除内存泄漏
写在最后
回顾一下整篇文章的核心脉络:
- 分代的本质是根据对象生命周期特征采用不同回收策略——短命对象频繁回收,长寿对象少管
- Survivor 区的双区设计通过复制算法消除了内存碎片,是空间换时间的经典权衡
- 晋升机制不是一刀切的,年龄、大小、空间状况、动态判断四管齐下
- Survivor 区满了不会触发额外 GC,而是改变分配策略,但可能引发连锁反应
- 元空间替代永久代解决了固定大小的历史包袱,但仍需合理配置
实际工作中,我的建议是:先通过 GC 日志定位问题,再有针对性地调整参数,最后回到代码层面做根本性的优化。盲目调参数往往事倍功半,而好的代码设计才是最好的 GC 调优。更多细节可参考 Oracle 官方 GC 调优指南;使用 G1 时尤其要注意不要固定年轻代大小。