JAVA · Vol.III · DAY 24 · JVM 原理与调优
JVM 堆内存:年轻代三区与老年代的生命周期
开场:为什么 JVM 堆要分代?
面试官很喜欢用这个问题开场,因为答案直接决定了他对你 GC 理解深度的判断:
如果你只回答"为了 GC 效率",面试官一定会继续追问:凭什么年轻代的 GC 就可以更快?依据是什么统计规律?答不上来,话题就到此为止了。所以我们先建立正确的认知框架,再回答问题。
先搭框架:GC 的三个根本问题
整个 JVM 垃圾回收的知识体系,都可以挂在三个根本问题上。先记住这个框架,后面所有卷三的知识点都不会乱:
| 根本问题 | 回答什么 | 对应知识点 |
|---|---|---|
| 回收什么 | 哪些对象可以回收 | 可达性分析、GC Roots、四种引用(见「对象存活判定」「GC Roots 详解」) |
| 何时回收 | 什么时候触发一次 GC | Eden 满、老年代满、元空间满、IHOP 水位、System.gc()(本篇 + 「GC 日志分析」) |
| 怎么回收 | 用什么算法和收集器回收 | 复制 / 标记-清除 / 标记-整理、Serial → G1 → ZGC(见「垃圾收集器全览」) |
"为什么分代"本质上是回答"何时回收 + 怎么回收"的设计动机:把堆按对象存活时间切成不同的区域,让占绝大多数的"短命对象"能被一套又快又专的机制快速处理掉。
分代依赖的两个统计假设
HotSpot 的分代设计完全建立在这两个经验假设之上——面试时能说出名字和区别,就是加分项:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是"朝生夕灭"的——刚分配出来,很快就再也没有引用指向它们了。
- 强分代假说(Strong Generational Hypothesis):熬过了前几轮 GC 的对象,未来继续长期存活的概率会更高。
第一个假设是年轻代存在的理由:统计上绝大多数对象活不过第一轮 GC,那么回收这"绝大多数"就不该每次都扫描整个堆,而应该有一块专用区域、一个高频快速的收集器(Minor GC)专门处理。第二个假设是老年代存在的理由:熬过年轻代的对象被证明"活得久",回收它们可以慢一点、频率低一点——因为它们下轮还活着的概率高,如果每次都急着回收(比如频繁整理),反而浪费 CPU。
一个帮助内化的类比:医院分急诊科和住院部。急诊科(年轻代)处理"来得急、走得也快"的病例,流程快、频次高、吞吐量必须大;住院部(老年代)处理长期病人,动作慢、频次低。如果医院只设一个大厅让所有病人走同一条流程,那才是灾难。分代就是 JVM 的"分诊制度"。
分代的本质是按存活时间分组,给不同组配不同的 GC 策略:高频、短耗时的回收工作集中在年轻代完成,长寿对象留在老年代低频处理。是用"分组的复杂度"换"整体 GC 效率"。
这篇文章把 JVM 堆的分代结构拆成 8 站:全景布局 → 分配规则 → 晋升规则 → Minor GC 拆解 → Full/Mixed GC → 调优参数 → 面试模板。读的时候带着三个问题:
- Eden 和两个 Survivor 区默认什么比例?对象"Eden 都装不下"时去哪?
- 除了"年龄到 15",对象进老年代还有哪几条路?"分配担保"的完整判定逻辑是什么?
- 到了 G1 时代,"年轻代 / 老年代"还是物理分区吗?
堆内存全景:Eden + S0 + S1 + Old Gen
先画地图。HotSpot 的堆(Heap)分为两大区域:年轻代(Young Generation)和老年代(Old Generation);年轻代内部再细分为三个区:Eden 和两个 Survivor 区(一个当 From,一个当 To,角色互换使用)。
三个必须记住的默认值
| 参数 | 默认值 | 含义 | 控制参数 |
|---|---|---|---|
| Eden : S0 : S1 | 8 : 1 : 1 | 年轻代内部三区比例,每次 Minor GC 只有 1/10 空间装存活对象 | -XX:SurvivorRatio(默认 8,指 Eden:Survivor) |
| Young : Old | 1 : 2 | 年轻代约占堆的 1/3 | -XX:NewRatio(默认 2,指 Old:Young) |
| 晋升年龄阈值 | 15 次 Minor GC | 对象在 Survivor 区每存活一次年龄 +1,到阈值晋升 | -XX:MaxTenuringThreshold |
因为年轻代用的是复制算法(Copying):每次 Minor GC 时,把 Eden + 当前 From 区中存活的对象,原样复制到 To 区,然后一次性清空 Eden 和 From。复制算法要求目标区必须是空的(否则没法区分"复制过来的对象"和"本来就在的对象")。两个 Survivor 轮流充当 From 和 To,就保证了每次总有一个空区可用。代价是浪费 1/10 的年轻代空间——所以 SurvivorRatio 默认给到 8:反正存活对象只占少数,10% 足够装了。
分代是物理分区吗?——G1 时代的真相
在 Serial、Parallel、CMS 这些"经典"收集器里,年轻代和老年代确实是堆里两块连续的物理内存。但到了 G1,情况变了:
Eden / Survivor / Old 只是 Region 的「逻辑集合」,互不连续——同一个 Region 甚至可以在不同 GC 周期扮演不同角色。
这是 Oracle 官方文档的原话:"the eden, survivor, and old generations are logical sets of these regions and are not contiguous"。所以面试时说"堆分成年轻代老年代"没错,但加分回答是:分代是一种分配与回收策略,不一定要物理连续——G1 用 Region 化打破了物理边界,但"对象从年轻态走向老年态"的生命周期概念依然保留(Region 也有年龄)。
记住三组数字:8:1:1(年轻代内部)、1:2(年轻:老年)、15(年龄阈值)。再记住一个观念:分代是逻辑概念,G1 之后 Region 可以跨代复用。
分配规则:对象在哪里出生?
对象创建的第一步是内存分配。JVM 决定"把对象放在哪"的规则,比你想象的多——不只是"新对象进 Eden"这么简单。
规则一:新对象优先进 Eden
绝大多数对象走这条路:在 Eden 区分配内存(实际发生在每个线程的 TLAB——线程本地分配缓存里,TLAB 的机制在「对象创建全过程」一篇里拆,这里只需知道:TLAB 是 Eden 里划给每个线程的私有小块,避免多线程抢同一个分配指针加锁)。Eden 满了,触发一次 Minor GC。
规则二:Eden 装不下,直接去老年代
如果一个对象比 Eden 的剩余空闲空间还大(注意:不是比 Eden 总空间大,JVM 会先看 Eden 当前剩多少),就没有资格在年轻代"历练"了——JVM 直接在老年代找一块连续空间分配。如果老年代也放不下,就先触发一次 Full GC 腾空间,再试。
"比 Eden 大才进老年代"是常见误解。准确说法是比 Eden 的剩余空间大。所以同一个对象,有时在 Eden 出生,有时直接进老年代——取决于当时 Eden 还剩多少。这也是 GC 日志里偶发"大对象"现象的来源之一。
规则三:大对象直通老年代(PretenureSizeThreshold)
有些对象天生就是大块头(典型如大数组 byte[10 * 1024 * 1024]、大视频字节流)。如果让它先在 Eden 出生、再在 Survivor 之间反复复制,复制成本可能比直接分配还高。于是 HotSpot 提供了直通机制:
// 分配对象 size 时:
if (size <= eden_free) // ① Eden 剩得下 → 正常在 Eden 分配
allocate_in_eden(size);
else if (size <= old_free) // ② Eden 剩不下,老年代放得下 → 直接老年代
allocate_in_old(size);
else { // ③ 两边都放不下 → 先 Full GC 再试,仍失败则 OOM
full_gc();
retry_or_oom(size);
}
对于 Serial / ParNew 收集器,还可以显式指定:-XX:PretenureSizeThreshold——超过该大小的对象不走规则二的"看剩余空间"逻辑,直接进老年代。它存在的目的正是省掉大对象在年轻代的复制开销。
没有。该参数只对 Serial 和 ParNew 有效(文档明确说明)。G1 有自己的一套大对象机制——Humongous 对象:对象大小超过 Region 的 一半,就叫 Humongous,JVM 会把它直接分配到一块或多块连续的 Humongous Region 里(不经过普通 Eden)。如果连续 Region 不够,G1 会先触发一次 Young GC(顺便整理 Humongous Region)再试。所以 G1 调优里你关注的是 -XX:G1HeapRegionSize 而不是 PretenureSizeThreshold——这也是为什么大量分配超大对象时,要先把 Region 调大。
规则四:大数组的"灰色地带"
一个容易忽略的细节:数组在 HotSpot 里有特殊待遇。即使没有设置 PretenureSizeThreshold,一个总长度很大的数组(比如 new long[100000],约 800KB)如果在 Eden 剩余空间里放不下,也会直接落到老年代——因为数组是"潜在大对象"里最常见的一种,HotSpot 对它的分配策略更保守。写批量处理代码时,如果一次性 new 超大数组,老年代会突然鼓起来,GC 日志里能看到对应的 allocation——这不是 bug,是分配规则在起作用。
四条分配规则按优先级:① Eden 剩余空间够 → Eden;② 不够但老年代够 → 直接老年代;③ 超过 PretenureSizeThreshold(仅 Serial/ParNew)→ 直接老年代;④ G1 下超过 Region 一半 → Humongous Region。写代码时"少 new 超大对象、少在循环里 new 大数组",就是在这个决策树的边缘打转。
晋升规则:对象什么时候进老年代?
对象在年轻代的"履历"由一个数字记录:年龄(age)。每熬过一次 Minor GC,年龄 +1;年龄存的是字节(byte),所以理论上限是 255。HotSpot 默认认为:年龄达到 15 的对象,晋升老年代。
15 = 2⁴ - 1,是个经验值,背后的逻辑是:强分代假说下,对象存活曲线是"指数衰减"的——绝大多数对象在第 1 轮就死,能活过 3~5 轮的已经是少数,活过 15 轮的更是极少数。阈值设太低,年轻对象会过早进入老年代,让老年代"消化不良"(回收变慢);设太高,Survivor 会堆积太多对象,复制开销变大。15 是让"Survivor 装得下"和"老年代别太快满"两个约束的折中。另外年龄是 byte,15 也是默认取的一个"安全小值"。
通道二:动态年龄判定(Dynamic Aging)
年龄只是"名义"条件。HotSpot(Parallel Scavenge 实现)还有一个更聪明的自适应规则:
则该年龄及以上的对象全部直接晋升老年代(不再等年龄到 15)。
举个例子:Survivor 只剩 10MB,而年龄为 3 的对象加起来有 8MB——如果还按年龄规则让它们继续在 S0/S1 之间复制,这次 Minor GC 大概率装不下。JVM 就"批量升级":把年龄 ≥ 3 的对象全部送进老年代。这就是 GC 日志里常说的 average promotion size(平均晋升大小)统计的由来——JVM 会持续跟踪每轮 Minor GC 实际晋升了多大一块,供后续决策参考。
存活对象放不下时,JVM 把放不下的那部分直接放进老年代(分配担保机制,下一节细讲)。如果老年代也放不下——先触发一次 Full GC;Full GC 之后还是放不下,就只能 OutOfMemoryError: Java heap space 了。这就是"Minor GC 之后紧接着 Full GC"的典型链路。
通道三:分配担保(Allocation Failure / Promotion Guarantee)
这是最容易被忽略、也最能体现深度的一条规则。场景是:Eden 满了,Minor GC 即将触发。此时 JVM 要回答一个问题:这次 GC 之后,存活对象(Eden + From 里活着的)总共多大?To 区装得下吗?装不下的部分得有去处——这个"去处保证"就叫分配担保。
ParNew 收集器的完整判定逻辑是两步:
// 设:young_live = Eden + From 中存活对象总大小
// to_space = 另一个 Survivor 区的大小
// old_free = 老年代当前最大连续空闲空间
if (old_free > young_live) {
// ① 老年代的连续空闲空间 比 全部存活对象还大
// → 担保成立:放心做 Minor GC,To 装不下的部分直接放老年代
proceed_minor_gc();
} else {
// ② 担保不成立,还要看历史:比较 old_free 与「平均晋升大小」
if (old_free > avg_promotion_size) {
// 历史每轮晋升量不大,这次大概率也放得下 → 冒险做 Minor GC
// (风险:这次偏偏晋升多了 → Promotion Failed → 还是 Full GC)
proceed_minor_gc();
} else {
// ③ 连历史平均都放不下 → 保险起见,先做一次 Full GC 腾老年代
// 然后再重试 Minor GC
full_gc();
retry_minor_gc();
}
}
理解这段逻辑,三个常见现象就有解释了:
- "为什么 Minor GC 之前会先来一次 Full GC?"——分配担保失败,JVM 主动先清老年代(GC 日志里能看到
Full GC (Ergonomics)或Promotion Failed的 cause)。 - "为什么老年代占用率还没满,Full GC 就发生了?"——上面第 ③ 种情况:不是老年代"满了",而是"预判装不下即将晋升的对象"。
- "为什么调大年轻代后 Full GC 反而多了?"——年轻代越大,单次 Minor GC 的存活总量越大,担保判定越容易失败(第 ① 步
old_free > young_live更难成立)。
四条晋升路径汇总
| 晋升路径 | 触发条件 | 设计目的 | 相关参数 |
|---|---|---|---|
| ① 正常晋升(年龄) | age ≥ 阈值 | 常规生命周期管理 | -XX:MaxTenuringThreshold=15 |
| ② 动态年龄判定 | 同年龄对象总量 > Survivor/2 | 自适应:防止 Survivor 溢出 | 无(JVM 自动) |
| ③ 大对象直通 | size > 阈值 / > Eden 剩余 / > Region/2(G1) | 省掉大对象在年轻代的复制开销 | -XX:PretenureSizeThreshold / -XX:G1HeapRegionSize |
| ④ 分配担保溢出 | Minor GC 后 To 装不下 | 兜底:溢出部分进老年代,装不下再 Full GC | 无(JVM 自动) |
大多数候选人只会说"年龄到 15 就晋升"。把动态年龄判定和分配担保的两步判定讲出来,基本就锁定了 GC 部分的高分——这两条恰好是"为什么我的服务 Full GC 频繁"这类线上问题的理论根源。
Minor GC 完整拆解:一次 Young GC 到底做了什么?
前面讲了"对象去哪",这一站拆开引擎盖:Eden 满了之后,JVM 内部一步步发生了什么。以 ParNew(经典的并行复制收集器)为例,一次 Minor GC 分五步:
- STW(Stop-The-World):暂停所有 Java 线程。原因:标记和复制期间,引用关系不能变,否则会出现"正在遍历的对象被新建/删除"的混乱。这是所有 Minor GC 无法避免的代价。
- 根扫描:从 GC Roots 出发,找出年轻代里的存活对象。注意这里有一个关键优化——不需要完整扫描老年代。老年代对象可能引用了年轻代对象(比如一个长生命周期容器里存了短命对象),但 HotSpot 用卡表(Card Table)+ 写屏障(Write Barrier)机制,只在"老年代指向年轻代的引用发生过写操作"的内存卡(每 512 字节一张卡)上打脏标记,根扫描时只处理脏卡。G1 里对应的结构是 RSet(Remembered Set)。这正是"为什么 Minor GC 只扫年轻代却不会漏掉老年代的引用"的答案——写屏障是并发 GC 的基石,「对象存活判定」一篇会展开讲。
- 标记存活对象:用三色标记法(白色=不可达、灰色=自身可达但子引用未处理完、黑色=处理完)从根出发遍历。年轻代用 STW 标记,不存在并发修改问题,所以不需要 SATB 之类的补偿机制。
- 复制存活对象:把存活对象按内存序重新排布到 To 区(复制过程中顺便完成内存整理——对象紧凑排列,无碎片);同时 age+1,满足晋升条件的对象改放到老年代(走上一节的分配担保逻辑)。
- 清空与角色互换:Eden 和 From 区的"指针"一移即空(零成本,不需要逐个释放),S0 和 S1 互换 From/To 身份。下一轮 Minor GC 的存活对象会复制到另一个区。
年轻代用的是指针碰撞(Bump the Pointer)分配方式:Eden 里维护一个"当前分配位置"指针,分配对象就是指针前移。所有对象从底部连续堆上去,那么"清空"只需要把指针拨回底部——不需要遍历、不需要释放任何字节。这也是为什么年轻代没有碎片:对象总是连续分配的。(空闲列表 Free List 分配方式则相反,留给「对象创建全过程」展开。)
Minor GC 的典型耗时在 10~100ms 量级(取决于存活对象大小和根扫描范围),频率取决于 Eden 大小和对象分配速度——这两者就是后面调优要拧的两个旋钮。
Minor GC = STW + 根扫描(靠卡表/写屏障避免全扫老年代)+ 三色标记 + 复制到 To(顺带整理)+ 指针清零 + From/To 互换。记住"卡表/写屏障"这个词——它是面试里区分"背过"和"真懂"的分水岭。
Full GC 与 Mixed GC:老年代什么时候被回收?
年轻代的回收快而频繁,老年代的回收则慢而危险。先把术语理清楚——"Major GC" 和 "Full GC" 在很多材料里混用,但严谨的说法是:
| 术语 | 回收范围 | 典型含义 |
|---|---|---|
| Young GC(Minor GC) | 年轻代 | Eden 满触发,复制算法,10~100ms |
| Mixed GC | 年轻代 + 部分老年代 Region | G1 特有:并发标记周期后,挑"垃圾多"的老年代 Region 一起回收 |
| Full GC | 整个堆 + 元空间 | STW 全量回收,几百 ms 到几十秒,应尽量避免 |
| Major GC(旧术语) | 老年代 | 老文献中指"针对老年代的 GC",现代日志里一般直接用 Full GC 表述 |
Full GC 的六大触发场景
| 触发场景 | 机制说明 | 日志中的 Cause |
|---|---|---|
| 老年代空间不足 | 晋升/大对象把老年代填满,分配失败 | Allocation Failure |
| 元空间不足 | 类元数据装不下 Metaspace(加载类过多/动态代理泄漏) | Metadata GC Threshold |
| 分配担保失败 | Minor GC 前预判老年代装不下晋升对象(上一节的两步判定) | Ergonomics / Promotion Failed |
| 显式调用 System.gc() | 代码或库(常见于 Netty 释放直接内存)主动触发 | System.gc() |
| CMS concurrent mode failure | CMS 并发回收期间老年代被塞满,退化为 Serial Old 串行 Full GC | Concurrent Mode Failure |
| JVM 自适应调整(Ergonomics) | Parallel/G1 根据历史数据主动提前 GC | Ergonomics |
System.gc() 只是"建议",JVM 可以忽略。生产上常见两种配置:① 直接 -XX:+DisableExplicitGC 禁用显式 GC——但 Netty 释放 Direct Memory 依赖显式 GC 触发 Cleaner(虚引用回调),禁用后直接内存可能持续累积直到 Direct buffer memory OOM,所以"禁用"要慎重;② 不禁用时,G1/ZGC 下建议配 -XX:+ExplicitGCInvokesConcurrent,让显式 GC 走并发路径(G1 的并发标记周期)而不是 STW Full GC。这个细节在 Netty 服务的线上排障里经常出现,说出来很加分。
为什么 Full GC 这么可怕?
三个原因叠加:① 全程 STW,且要扫描所有 GC Roots(包括完整老年代根);② 回收整个堆,耗时与堆大小成正比(几十 GB 的堆,一次 STW 可达秒级);③ Full GC 之后如果回收不了多少空间(说明是泄漏或堆太小),很快又会触发下一次——形成"GC 风暴",CPU 几乎全耗在 GC 上,业务线程饿死。
G1 的 Mixed GC:不做 Full GC 的理想
G1 的设计目标就是"让 Full GC 尽量不发生"。它的做法是把老年代回收化整为零:
- 并发标记周期(Concurrent Marking Cycle):老年代占用率到达
-XX:InitiatingHeapOccupancyPercent(默认 45%,JDK 9+ 可自适应)时启动。分四阶段:初始标记(STW,挂在 Young GC 尾部,只标根对象)→ 并发标记(与应用并发,从根出发标记全部老年代对象,用 SATB 快照保证一致性)→ 最终标记(STW,处理并发期间的引用变更)→ 筛选清理(按 Region 垃圾比例排序)。 - Mixed GC:并发标记结束后,接下来的若干次 Young GC 变成 Mixed GC——每次除了回收全部年轻代,再回收一批"垃圾占比最高"的老年代 Region。
- 回收多少由暂停时间目标决定:
-XX:MaxGCPauseMillis(默认 200ms)是 G1 的"软实时"目标。G1 内部维护每个 Region 的回收效率预测(可回收字节数 ÷ 预测耗时),每次 Mixed GC 在暂停预算内按性价比挑 Region。相关旋钮:-XX:G1MixedGCCountTarget(默认 8,分 8 次 Mixed GC 消化完标记的老年代垃圾)、-XX:G1HeapWastePercent(默认 5%,可回收垃圾低于堆的 5% 就停止 Mixed GC)。
两种结局:① 老年代被填满 → 触发 Full GC(STW,G1 的 Full GC 用的是单线程标记-整理,非常慢——所以 G1 时代出现 Full GC 基本等于事故);② 要回收的对象太多、目标 Region 放不下存活对象 → Evacuation Failure(疏散失败),日志里出现 To-space exhausted,直接升级 Full GC。这两条是「GC 日志分析」一篇的重点识别项,也是"为什么 G1 服务偶尔卡几秒"的最常见答案。
Full GC = 全堆 STW + 完整根扫描 + 低回收效率风险,是调优的"头号敌人"。G1 用"并发标记 + Mixed GC 化整为零"的策略把 Full GC 变成极端情况。记住三个 G1 参数:InitiatingHeapOccupancyPercent(什么时候开始标记)、MaxGCPauseMillis(每次停多久)、G1MixedGCCountTarget(分几次消化)。
调优参数:控制分代结构的旋钮与方法论
理解了分代原理,调优就是拧参数。先列出控制堆分代结构的核心参数:
| 参数 | 作用 | 默认值 | 示例 |
|---|---|---|---|
-Xms / -Xmx | 堆初始 / 最大大小 | 物理内存的 1/64 / 1/4 | -Xms4g -Xmx4g(生产环境两者必须相等) |
-Xmn | 直接指定年轻代大小(初始=最大) | 由 NewRatio 推算 | -Xmn2g |
-XX:NewRatio | 老年代 : 年轻代 比例 | 2(年轻代占 1/3) | -XX:NewRatio=1(各占一半) |
-XX:SurvivorRatio | Eden : 单个 Survivor 比例 | 8 | -XX:SurvivorRatio=6(Survivor 变大) |
-XX:MaxTenuringThreshold | 晋升年龄阈值 | 15 | -XX:MaxTenuringThreshold=6 |
-XX:PretenureSizeThreshold | 大对象直通阈值(仅 Serial/ParNew) | 0(关闭) | -XX:PretenureSizeThreshold=512k |
-XX:MaxGCPauseMillis | G1 暂停时间目标(软约束) | 200ms | -XX:MaxGCPauseMillis=50 |
-XX:InitiatingHeapOccupancyPercent | G1 启动并发标记的堆占用水位 | 45%(JDK9+ 自适应) | -XX:InitiatingHeapOccupancyPercent=35 |
参数之间的关系与冲突(面试爱问)
-Xmn与-XX:NewRatio二选一:同时指定时-Xmn优先生效,NewRatio 被忽略。写启动脚本时别两个都写,否则参数"看起来生效了其实没生效"。-Xms = -Xmx是铁律:堆动态扩容/缩容本身需要 GC 且缩容要整理内存,生产环境固定堆大小,把"扩堆 STW"这个变量从线上环境里彻底移除。- SurvivorRatio 调小的连锁反应:Survivor 变大 → 存活对象能多待几轮 → 晋升变慢,老年代压力减小;但 Eden 变小 → Minor GC 频率上升。两个方向互相制约,没有免费午餐。
三种典型场景的启动模板
# 固定堆 = 容器内存的一半,给堆外(Metaspace/Direct/线程栈)留余量
-Xms4g -Xmx4g
# G1 是 JDK 9+ 默认收集器,显式写出便于日志识别
-XX:+UseG1GC -XX:MaxGCPauseMillis=100
# 显式 GC(Netty 释放 DirectMemory 依赖)走并发路径
-XX:+ExplicitGCInvokesConcurrent
# GC 日志 + OOM 自动转储(排障三件套,见「OOM 排查实战」)
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=50m
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/
# 吞吐优先:Parallel GC(JDK 8 的默认服务端收集器)
-Xms8g -Xmx8g
-XX:+UseParallelGC
# 大批量短命对象:年轻代给足(约占堆一半)
-XX:NewRatio=1
# 关注吞吐而非停顿:GC 时间占比上限
-XX:GCTimeRatio=19 # GC 时间 ≤ 总时间的 1/20
# ZGC:停顿与堆大小无关(亚毫秒级),适合大堆 + 严格延迟 SLA
-Xms16g -Xmx16g
-XX:+UseZGC
-XX:SoftMaxHeapSize=12g # 软上限:尽量把堆压在这个值以下,减少 GC 频率
调优方法论:五步走,拒绝拍脑袋
GC 调优五步法(所有场景通用):
- 固定基线:
-Xms=-Xmx,选定收集器,GC 日志 + OOM 转储全部打开。 - 观察日志:先跑一段时间,用「GC 日志分析」一篇的方法读日志——Young GC 频率/耗时、Full GC 频率、晋升速度、老年代占用曲线。
- 定位瓶颈:三类症状对应不同旋钮——Young GC 太频繁 → 加大年轻代;单次停顿太长 → 收集器目标(MaxGCPauseMillis)或减少存活对象;Full GC 多 / 老年代涨得快 → 晋升问题(Survivor 太小、大对象、泄漏)。
- 单变量调整:一次只动一个参数,跑同样的负载,对比日志。多变量一起调 = 无法归因。
- 回归验证:压测验证 P99 延迟和吞吐,监控大盘确认无回归后发布。
① 一看 Young GC 频繁就把 -Xmn 拉满——年轻代过大后单次 Minor GC 更慢,且分配担保更容易失败,Full GC 反而更多。② G1 环境还去设 PretenureSizeThreshold——无效参数,G1 看的是 Region 大小。③ MaxGCPauseMillis 设 5ms 追求极致——目标定得越狠,G1 每次回收的 Region 越少,老年代越容易积压,最后以 Full GC 收场。暂停目标是"软约束",不是"硬保证"。
容器化部署的特别注意
JDK 8u191+ / JDK 10+ 能正确识别 cgroup 内存限制(Container Support),此时不写 -Xmx,JVM 默认用容器限制的 25%(-XX:MaxRAMPercentage=25.0)。生产建议显式写出:容器 8G 给 JVM 4G 堆(-XX:MaxRAMPercentage=50.0),剩下的留给 Metaspace、Direct Memory、线程栈(每个线程 -Xss 默认 1MB 堆外内存)和 OS 缓存。JDK 8 老版本在容器里会看到宿主机物理内存——这是"容器里 JVM 把宿主机内存吃满"的经典事故根源。
总结:知识地图 + 面试模板 + 高频追问
知识地图
本文全部知识点的结构(能默画出来就算内化):
- 为什么分代:弱分代假说(短命对象占绝大多数)+ 强分代假说(熬过 GC 的更长寿)→ 按存活时间分组,不同组不同策略。
- 结构:Eden:S0:S1 = 8:1:1(SurvivorRatio=8),Young:Old = 1:2(NewRatio=2);G1 下代是 Region 的逻辑集合,非物理连续。
- 四条分配规则:Eden 剩余够 → Eden;不够但老年代够 → 老年代;PretenureSizeThreshold(Serial/ParNew)→ 老年代;G1 超 Region 一半 → Humongous Region。
- 四条晋升路径:年龄 ≥15;动态年龄判定(同年龄 > Survivor/2);大对象直通;分配担保溢出(ParNew 两步判定:老年代连续空间 > 存活总量 → 放心 GC;否则比平均晋升量 → 仍不够先 Full GC)。
- Minor GC 五步:STW → 根扫描(卡表/写屏障/RSet 避免全扫老年代)→ 三色标记 → 复制到 To(顺带整理,age+1)→ 清空 + From/To 互换。
- Full GC 六场景:老年代满 / 元空间满 / 担保失败 / System.gc() / CMS 并发失败 / Ergonomics。G1 用并发标记 + Mixed GC(MaxGCPauseMillis 预测模型)把 Full GC 变成极端情况。
- 调优:五步法(基线 → 日志 → 定位 → 单变量 → 回归);Xms=Xmx 铁律;Xmn 与 NewRatio 互斥。
面试回答模板
"堆分代基于弱分代假说——绝大多数对象朝生夕灭。年轻代用 Eden + 双 Survivor(8:1:1),Minor GC 用复制算法,快但 STW;长寿对象按年龄或动态判定晋升老年代(约 2/3 堆),用更慢的策略回收。G1 之后年轻代/老年代变成 Region 的逻辑集合,用 Mixed GC 化整为零,目标是避免 Full GC。"
30 秒版 + 三个"为什么":① 为什么双 Survivor——复制算法需要空的目标区,From/To 互换,代价是浪费 1/10 空间;② 为什么晋升不只看年龄——动态年龄判定防止 Survivor 溢出,分配担保(ParNew 两步判定)防止"Minor GC 后老年代装不下",这就是"Minor GC 前先来 Full GC"的日志现象来源;③ 为什么 Minor GC 不扫老年代——卡表 + 写屏障只记录"老年代→年轻代"引用变化过的卡,根扫描只处理脏卡。G1 对应结构是 RSet。最后落一句:所以调优核心是控制晋升速度和年轻代大小,目标是 Young GC 快而稳、Full GC 不发生。
高频追问速答
| 追问 | 答案要点 |
|---|---|
| 为什么是 8:1:1? | 弱分代假说下存活对象只占少数,10% Survivor 足够装;Survivor 越大 Eden 越小,Minor GC 越频繁,8 是经验平衡点 |
| 为什么年龄阈值是 15? | 2⁴-1 的经验值:存活曲线指数衰减,熬过 15 轮的是极少数;太小→老年代消化不良,太大→Survivor 复制压力大;age 是 byte,上限 255 |
| G1 还分代吗? | 分。Region 化后 Eden/Survivor/Old 是逻辑集合,不物理连续;Region 也有年龄,生命周期概念保留(Oracle 官方原话 logical sets of these regions) |
| 为什么 Minor GC 后紧跟 Full GC? | 分配担保失败:老年代连续空间装不下本次存活总量,且也不大于平均晋升量 → JVM 先 Full GC 腾老年代再重试 |
| 年轻代越大越好吗? | 不是。越大 → Minor GC 频率降但单次更慢,存活总量越大,分配担保越容易失败;经验上占堆 1/3~1/2,以 GC 日志为准 |
| Survivor 区为什么经常是空的? | 每轮 Minor GC 只有"上轮 From + Eden 的存活者"被复制过来,存活率低时占用很小;日志里 To 空间利用率长期 <50% 通常意味着 Survivor 偏大或对象死得快 |
堆分代是利用"大部分对象朝生夕灭"的统计规律,把高频回收集中在年轻代、让长寿对象在老年代慢速消化的分组策略。对象从 Eden 出生,在 Survivor 历练,经四条路径之一进入老年代;Minor GC 靠复制 + 卡表/写屏障快进快出,Full GC 是必须避免的兜底。掌握这条"对象生命线",GC 调优就有了坐标系——下一篇我们沿着 new 指令,把对象出生那一瞬间 JVM 的每一步拆开看(「对象创建全过程」)。
Comments · 评论