Lesson 24 · JVM 原理与调优
JVM 堆内存:年轻代三区与老年代的生命周期
开场:为什么 JVM 堆要分代?
面试官开口第一问:
如果你只回答"为了 GC 效率",面试官会继续追问:凭什么年轻代的 GC 就可以更快?
答案藏在一个经验假设里——弱分代假说(Weak Generational Hypothesis):
这个假说有大量统计支撑:超过 90% 的对象活不过第一次 GC。既然绝大多数对象集中在某个区域且迅速死亡,那就把它们单独划出来,用一套快速、高频的回收策略去处理——这就是年轻代。
而少数"长寿"对象放到老年代,用慢速、低频的策略回收。两套策略各司其职,整体 GC 效率远高于"一刀切"。
这篇文章把 JVM 堆的分代结构拆成 7 站,从全景布局到对象生命周期,从 GC 类型到调优参数,逐站讲透。
发车之前先问自己三个问题:
- Eden 和两个 Survivor 区的默认比例是多少?
- 对象经历多少次 GC 后才会晋升到老年代?
- Minor GC 和 Full GC 分别在什么场景下触发?
带着问题,我们开始。
堆内存全景:Eden + S0 + S1 + Old Gen
JVM 堆(Heap)分为两大区域:年轻代(Young Generation)和老年代(Old Generation)。年轻代内部再细分为三个区:Eden、Survivor 0(From)和 Survivor 1(To)。
几个关键数字要记住:
| 参数 | 默认值 | 说明 |
|---|---|---|
| Young : Old | 1 : 2 | 由 -XX:NewRatio 控制,默认 NewRatio=2 |
| Eden : Survivor | 8 : 1 : 1 | 由 -XX:SurvivorRatio 控制,默认 SurvivorRatio=8 |
| TLAB(线程本地分配缓存) | 约 1% Eden | 每个线程独享的小缓冲区,避免锁竞争 |
因为年轻代用的是 复制算法(Copying Collector)。每次 Minor GC 时,Eden 和一个 Survivor(From)中的存活对象会被复制到另一个 Survivor(To),然后清空 Eden 和 From。之后 S0 和 S1 角色互换。如果只有一个 Survivor,碎片化会非常严重。
对象的生命周期:从出生到晋升
一个普通对象在 JVM 堆中的旅程是这样的:
每次 Minor GC 存活后,对象的 年龄(age)+1。当 age 达到 -XX:MaxTenuringThreshold(默认 15)时,对象晋升到老年代。
用一个流程图来展示完整的对象旅行路线:
对象头(Object Header)中有一个 4 bit 的分代年龄字段,最大值 15。这就是为什么 MaxTenuringThreshold 不能超过 15——不是 JVM 限制,而是对象头的物理限制。
特殊晋升:大对象直通 & 动态年龄判定
除了按年龄正常晋升,还有两条"快速通道"让对象提前进入老年代。
通道一:大对象直接进入老年代
如果一个对象特别大(比如一个很大的数组),在 Eden 和 Survivor 之间来回复制的代价太高。JVM 提供了参数让它直接在老年代分配:
通道二:动态年龄判定(Dynamic Aging)
即使对象年龄没到 15,也可能被提前晋升。规则是:
这是一个自适应策略。当某一年龄段的存活对象特别多、Survivor 快装不下时,JVM 会把这批对象"批量晋升",避免 Survivor 溢出。
存活对象无法全部放入 Survivor,JVM 会通过分配担保(Handle Promotion)机制,把放不下的对象直接放到老年代。如果老年代也放不下,就会触发 Full GC。
| 晋升路径 | 触发条件 | 目的 |
|---|---|---|
| 正常晋升 | age >= MaxTenuringThreshold | 常规生命周期 |
| 大对象直通 | 对象大小 > PretenureSizeThreshold | 减少复制开销 |
| 动态年龄判定 | 同年龄对象总大小 > Survivor 空间 / 2 | 防止 Survivor 溢出 |
| 分配担保 | Survivor 空间不足 | 兜底机制,转入老年代 |
Minor GC vs Major GC vs Full GC
面试官最常问的区分题:
Minor GC(Young GC)
只回收年轻代(Eden + Survivor)。触发条件很简单:Eden 空间满了。
- 速度很快(通常几十毫秒)
- 频率很高(应用运行期间频繁发生)
- 使用复制算法,将存活对象从 Eden/From 复制到 To
Major GC(老年代 GC)
回收老年代。通常伴随一次 Minor GC,因为老年代 GC 前往往需要先清理年轻代腾出空间。
Full GC
回收整个堆(年轻代 + 老年代)+ 方法区/元空间。触发条件:
- 老年代空间不足
- 元空间(Metaspace)不足
- 调用
System.gc()(建议,非强制) - Minor GC 后存活对象无法放入老年代(分配担保失败)
- CMS 的 concurrent mode failure
Major GC 和 Full GC 不是同一个概念。Major GC 只回收老年代,Full GC 回收整个堆。但在很多工具和日志中,"Full GC" 和 "Major GC" 经常混用——面试时说清楚区别即可。
调优参数:控制堆的分代结构
理解了分代原理,调优就是调参数。核心参数如下:
| 参数 | 作用 | 默认值 | 示例 |
|---|---|---|---|
-Xmn | 设置年轻代大小 | 堆的 1/3 | -Xmn512m |
-XX:NewRatio | 老年代 / 年轻代 比例 | 2 | -XX:NewRatio=3 |
-XX:SurvivorRatio | Eden / Survivor 比例 | 8 | -XX:SurvivorRatio=6 |
-XX:MaxTenuringThreshold | 晋升老年代的年龄阈值 | 15 | -XX:MaxTenuringThreshold=10 |
-XX:PretenureSizeThreshold | 大对象直通老年代的阈值 | 0(不限制) | -XX:PretenureSizeThreshold=1m |
-Xms / -Xmx | 堆初始大小 / 堆最大大小 | 物理内存的 1/64 / 1/4 | -Xms2g -Xmx2g |
调优建议
看场景。
- 年轻代太大:Minor GC 单次耗时增加(要扫描的区域更大),但频率降低。
- 年轻代太小:Minor GC 频率很高,存活对象频繁复制到 Survivor 甚至提前晋升到老年代,导致老年代快速填满。
经验法则:年轻代占堆的 1/3 ~ 1/2,然后根据 GC 日志微调。
总结:速查图 + GC 调优清单
用一张速查图回顾全部知识点:
GC 调优清单
GC 调优 5 步走:
- 固定堆大小:
-Xms = -Xmx,避免动态扩缩 - 观察 GC 日志:
-XX:+PrintGCDetails -Xloggc:gc.log(JDK 8),-Xlog:gc*(JDK 9+) - 定位瓶颈:Minor GC 太频繁?单次太慢?Full GC 太多?
- 针对性调参:频率高 → 加大年轻代;晋升快 → 调阈值或 Survivor 大小;Full GC 多 → 增大堆或优化代码
- 选择收集器:低延迟选 G1/ZGC,高吞吐选 Parallel GC,小内存选 Serial GC
堆分代的本质是利用"大部分对象朝生夕灭"的统计规律,把高频回收集中在年轻代(Minor GC),降低整体 GC 开销。对象从 Eden 出生,在 Survivor 区"历练",age 达标后晋升到老年代——理解这条生命线,GC 调优就有了方向。