Java 面试笔记III · 01 / 14

Lesson 24 · JVM 原理与调优

JVM 堆内存:年轻代三区与老年代的生命周期

中级·⭐ 必问·#JVM·#内存·#核心

第 1 站

开场:为什么 JVM 堆要分代?

面试官开口第一问:

"JVM 堆为什么要分成年轻代和老年代?一整块不好吗?"

如果你只回答"为了 GC 效率",面试官会继续追问:凭什么年轻代的 GC 就可以更快?

答案藏在一个经验假设里——弱分代假说(Weak Generational Hypothesis)

绝大多数对象都是"朝生夕灭"的——分配后很快就会死亡,只有极少数对象能长期存活。

这个假说有大量统计支撑:超过 90% 的对象活不过第一次 GC。既然绝大多数对象集中在某个区域且迅速死亡,那就把它们单独划出来,用一套快速、高频的回收策略去处理——这就是年轻代。

而少数"长寿"对象放到老年代,用慢速、低频的策略回收。两套策略各司其职,整体 GC 效率远高于"一刀切"。

一句话分代的本质是按存活时间分组,对不同组使用不同的 GC 策略,让大部分回收工作在年轻代快速完成。

这篇文章把 JVM 堆的分代结构拆成 7 站,从全景布局到对象生命周期,从 GC 类型到调优参数,逐站讲透。

发车之前先问自己三个问题:

  • Eden 和两个 Survivor 区的默认比例是多少?
  • 对象经历多少次 GC 后才会晋升到老年代?
  • Minor GC 和 Full GC 分别在什么场景下触发?

带着问题,我们开始。

第 2 站

堆内存全景:Eden + S0 + S1 + Old Gen

JVM 堆(Heap)分为两大区域:年轻代(Young Generation)老年代(Old Generation)。年轻代内部再细分为三个区:EdenSurvivor 0(From)Survivor 1(To)

JVM Heap(堆) 年轻代 Young Generation Eden 80% 年轻代 新对象在这里分配 S0 From 10% S0 From · 10% S1 To · 10% 复制交换 老年代 Old Generation 长期存活的对象 约占堆的 2/3 默认 young:old = 1:2 默认比例: Eden : S0 : S1 = 8 : 1 : 1  |  Young : Old = 1 : 2 新生代分配区 幸存者区 老年代
图 1 JVM 堆内存分代结构全景——Eden 占年轻代 80%,两个 Survivor 各占 10%,老年代约为堆的 2/3

几个关键数字要记住:

参数默认值说明
Young : Old1 : 2-XX:NewRatio 控制,默认 NewRatio=2
Eden : Survivor8 : 1 : 1-XX:SurvivorRatio 控制,默认 SurvivorRatio=8
TLAB(线程本地分配缓存)约 1% Eden每个线程独享的小缓冲区,避免锁竞争
为什么有两个 Survivor 区,而不是一个?

因为年轻代用的是 复制算法(Copying Collector)。每次 Minor GC 时,Eden 和一个 Survivor(From)中的存活对象会被复制到另一个 Survivor(To),然后清空 Eden 和 From。之后 S0 和 S1 角色互换。如果只有一个 Survivor,碎片化会非常严重。

第 3 站

对象的生命周期:从出生到晋升

一个普通对象在 JVM 堆中的旅程是这样的:

对象生命周期时间线
阶段 1 new 对象 分配在 Eden 区
阶段 2 第一次 Minor GC 存活对象复制到 S0,age = 1
阶段 3 第二次 Minor GC 从 S0 复制到 S1,age = 2
阶段 4 第三次 Minor GC 从 S1 复制到 S0,age = 3
...
阶段 N age >= 15 晋升到老年代(Promotion)

每次 Minor GC 存活后,对象的 年龄(age)+1。当 age 达到 -XX:MaxTenuringThreshold(默认 15)时,对象晋升到老年代。

晋升条件:obj.age >= MaxTenuringThreshold(默认 15)

用一个流程图来展示完整的对象旅行路线:

Eden new 对象 GC S0 / S1 age 1→14 每次 GC age++ age≥15 晋升 Old Gen 长期存活 90%+ 对象死亡 Eden 直接清空 大对象直接进 Old PretenureSizeThreshold
图 2 对象从 Eden 出生,经历多次 Minor GC 后 age 递增,达到阈值后晋升到老年代
面试加分

对象头(Object Header)中有一个 4 bit 的分代年龄字段,最大值 15。这就是为什么 MaxTenuringThreshold 不能超过 15——不是 JVM 限制,而是对象头的物理限制。

第 4 站

特殊晋升:大对象直通 & 动态年龄判定

除了按年龄正常晋升,还有两条"快速通道"让对象提前进入老年代。

通道一:大对象直接进入老年代

如果一个对象特别大(比如一个很大的数组),在 Eden 和 Survivor 之间来回复制的代价太高。JVM 提供了参数让它直接在老年代分配

大对象直通参数
-XX:PretenureSizeThreshold = 1048576 // 超过 1MB 的对象直接进老年代
// 注意:该参数仅对 Serial 和 ParNew 收集器有效
// G1 收集器不使用此参数,G1 有自己的 Humongous Region 机制

通道二:动态年龄判定(Dynamic Aging)

即使对象年龄没到 15,也可能被提前晋升。规则是:

如果 Survivor 区中相同年龄的对象总大小 > Survivor 剩余空间的一半,则该年龄及以上的对象直接晋升到老年代。

这是一个自适应策略。当某一年龄段的存活对象特别多、Survivor 快装不下时,JVM 会把这批对象"批量晋升",避免 Survivor 溢出。

如果 Survivor 区不够大,会发生什么?

存活对象无法全部放入 Survivor,JVM 会通过分配担保(Handle Promotion)机制,把放不下的对象直接放到老年代。如果老年代也放不下,就会触发 Full GC

晋升路径触发条件目的
正常晋升age >= MaxTenuringThreshold常规生命周期
大对象直通对象大小 > PretenureSizeThreshold减少复制开销
动态年龄判定同年龄对象总大小 > Survivor 空间 / 2防止 Survivor 溢出
分配担保Survivor 空间不足兜底机制,转入老年代
第 5 站

Minor GC vs Major GC vs Full GC

面试官最常问的区分题:

"Minor GC 和 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
Minor GC 回收:年轻代 触发:Eden 满 耗时:10~50ms 频率:高 Major GC 回收:老年代 通常伴随 Minor GC 耗时:100ms~数秒 频率:低 Full GC 回收:整个堆 + 元空间 触发:老年代/元空间满 耗时:数百ms~数十秒 频率:应尽量避免 典型 GC 触发链条 对象分配 → Eden 满 → Minor GC → 存活对象进 Survivor Survivor 装不下 → 分配担保 → 进入老年代 → 老年代满 → Full GC Full GC 仍无法回收足够空间 → OutOfMemoryError: Java heap space
图 3 三种 GC 类型的回收范围、触发条件和典型触发链条
易混淆

Major GC 和 Full GC 不是同一个概念。Major GC 只回收老年代,Full GC 回收整个堆。但在很多工具和日志中,"Full GC" 和 "Major GC" 经常混用——面试时说清楚区别即可。

第 6 站

调优参数:控制堆的分代结构

理解了分代原理,调优就是调参数。核心参数如下:

参数作用默认值示例
-Xmn设置年轻代大小堆的 1/3-Xmn512m
-XX:NewRatio老年代 / 年轻代 比例2-XX:NewRatio=3
-XX:SurvivorRatioEden / 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

调优建议

实战调优建议
// 1. 固定堆大小,避免动态扩缩带来的 STW
-Xms=4g -Xmx=4g
// 2. 年轻代占堆的 1/3 ~ 1/2(NewRatio=2 表示 old:young=2:1,即年轻代占 1/3)
-XX:NewRatio=2
// 3. 如果 Minor GC 后 Survivor 区经常放不下,尝试:
// ① 增大 SurvivorRatio(让 Eden 更大、Survivor 更小)→ 适合短命对象多
// ② 减小 SurvivorRatio(让 Survivor 更大)→ 适合中等寿命对象多
-XX:SurvivorRatio=8
// 4. 如果对象晋升太快(老年代增长快),降低 MaxTenuringThreshold
// 如果 Full GC 太频繁,提高阈值让对象在年轻代多待一会
-XX:MaxTenuringThreshold=15
// 5. G1 收集器下不需要手动设 PretenureSizeThreshold
// G1 用 Humongous Region 自动处理大对象
年轻代设大一点好还是小一点好?

看场景。

  • 年轻代太大:Minor GC 单次耗时增加(要扫描的区域更大),但频率降低。
  • 年轻代太小:Minor GC 频率很高,存活对象频繁复制到 Survivor 甚至提前晋升到老年代,导致老年代快速填满。

经验法则:年轻代占堆的 1/3 ~ 1/2,然后根据 GC 日志微调。

调优核心公式:观察 GC 日志 → 找到瓶颈(频率高?单次慢?晋升多?)→ 针对性调参数
第 7 站

总结:速查图 + GC 调优清单

用一张速查图回顾全部知识点:

JVM 堆分代结构速查 Eden 新对象分配 80% 年轻代 S0 From 10% S1 To 10% Old Generation 长期存活对象 · 约占堆 2/3 晋升 四条晋升路径 ① 正常晋升:age >= MaxTenuringThreshold(默认 15)→ 晋升老年代 ② 大对象直通:对象大小 > PretenureSizeThreshold → 直接进老年代 ③ 动态年龄判定:同年龄对象总大小 > Survivor/2 → 批量晋升 ④ 分配担保:Survivor 空间不足 → 溢出对象直入老年代 三种 GC 对比 Minor GC 年轻代 · Eden 满触发 快速 · 高频 Major GC 老年代 · 伴随 Minor GC 较慢 · 低频 Full GC 整堆 + 元空间 最慢 · 应尽量避免
图 4 JVM 堆分代结构速查:四条晋升路径 + 三种 GC 类型

GC 调优清单

GC 调优 5 步走:

  1. 固定堆大小-Xms = -Xmx,避免动态扩缩
  2. 观察 GC 日志-XX:+PrintGCDetails -Xloggc:gc.log(JDK 8),-Xlog:gc*(JDK 9+)
  3. 定位瓶颈:Minor GC 太频繁?单次太慢?Full GC 太多?
  4. 针对性调参:频率高 → 加大年轻代;晋升快 → 调阈值或 Survivor 大小;Full GC 多 → 增大堆或优化代码
  5. 选择收集器:低延迟选 G1/ZGC,高吞吐选 Parallel GC,小内存选 Serial GC
核心记忆

堆分代的本质是利用"大部分对象朝生夕灭"的统计规律,把高频回收集中在年轻代(Minor GC),降低整体 GC 开销。对象从 Eden 出生,在 Survivor 区"历练",age 达标后晋升到老年代——理解这条生命线,GC 调优就有了方向。