首页 / Java 学习笔记 / 24

JAVA · Vol.III · DAY 24 · JVM 原理与调优

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

中级必问#JVM#内存#核心
第 1 站

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

面试官很喜欢用这个问题开场,因为答案直接决定了他对你 GC 理解深度的判断:

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

如果你只回答"为了 GC 效率",面试官一定会继续追问:凭什么年轻代的 GC 就可以更快?依据是什么统计规律?答不上来,话题就到此为止了。所以我们先建立正确的认知框架,再回答问题。

先搭框架:GC 的三个根本问题

整个 JVM 垃圾回收的知识体系,都可以挂在三个根本问题上。先记住这个框架,后面所有卷三的知识点都不会乱:

根本问题回答什么对应知识点
回收什么哪些对象可以回收可达性分析、GC Roots、四种引用(见「对象存活判定」「GC Roots 详解」)
何时回收什么时候触发一次 GCEden 满、老年代满、元空间满、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 时代,"年轻代 / 老年代"还是物理分区吗?
第 2 站

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

先画地图。HotSpot 的堆(Heap)分为两大区域:年轻代(Young Generation)老年代(Old Generation);年轻代内部再细分为三个区:Eden 和两个 Survivor 区(一个当 From,一个当 To,角色互换使用)。

JVM Heap(堆)—— 对象分配的主战场,也是 GC 的主战场 年轻代 Young Generation(约 1/3 堆) Eden 占年轻代 80% 几乎所有新对象在这里出生 S0 From · 10% S1 To · 10% 每次 Minor GC From/To 互换 老年代 Old Generation(约 2/3 堆) 长期存活的对象 大对象、熬过年龄阈值的对象、Survivor 装不下的对象 回收慢、频率低(Major GC / Mixed GC / Full GC) 晋升 默认比例:Eden : S0 : S1 = 8 : 1 : 1(-XX:SurvivorRatio=8) | Young : Old = 1 : 2(-XX:NewRatio=2) 新生区(Eden) 幸存者区(Survivor) 老年代(Old Gen)
图 1 JVM 堆内存分代结构全景——Eden 占年轻代 80%,两个 Survivor 各占 10%;老年代约占堆的 2/3

三个必须记住的默认值

参数默认值含义控制参数
Eden : S0 : S18 : 1 : 1年轻代内部三区比例,每次 Minor GC 只有 1/10 空间装存活对象-XX:SurvivorRatio(默认 8,指 Eden:Survivor)
Young : Old1 : 2年轻代约占堆的 1/3-XX:NewRatio(默认 2,指 Old:Young)
晋升年龄阈值15 次 Minor GC对象在 Survivor 区每存活一次年龄 +1,到阈值晋升-XX:MaxTenuringThreshold
为什么需要两个 Survivor 区?一个不行吗?

因为年轻代用的是复制算法(Copying):每次 Minor GC 时,把 Eden + 当前 From 区中存活的对象,原样复制到 To 区,然后一次性清空 Eden 和 From。复制算法要求目标区必须是空的(否则没法区分"复制过来的对象"和"本来就在的对象")。两个 Survivor 轮流充当 From 和 To,就保证了每次总有一个空区可用。代价是浪费 1/10 的年轻代空间——所以 SurvivorRatio 默认给到 8:反正存活对象只占少数,10% 足够装了。

分代是物理分区吗?——G1 时代的真相

在 Serial、Parallel、CMS 这些"经典"收集器里,年轻代和老年代确实是堆里两块连续的物理内存。但到了 G1,情况变了:

G1 把整个堆切成约 2048 个等大的 Region(每个 1MB ~ 32MB)。
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 可以跨代复用。

第 3 站

分配规则:对象在哪里出生?

对象创建的第一步是内存分配。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 提供了直通机制:

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——超过该大小的对象不走规则二的"看剩余空间"逻辑,直接进老年代。它存在的目的正是省掉大对象在年轻代的复制开销。

G1 时代这个参数还有用吗?

没有。该参数只对 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,是分配规则在起作用。

new 对象 大小 = size size ≤ Eden 剩余空间? (或超过 Pretenure 阈值?) 是(常规路径) Eden (线程的 TLAB) Eden 满 → Minor GC size ≤ 老年代 剩余空间? Old Gen 大对象直通 / 规则二 (大数组同此路径) 否 → 先 Full GC,仍失败 → OOM: Java heap space G1 的分支:Humongous 对象 size > Region / 2 → 直接占连续 Humongous Region (不经过普通 Eden) 空间不足 → 先 Young GC 整理 相关参数:-XX:G1HeapRegionSize (范围 1MB ~ 32MB,约 2048 个 Region)
图 2 对象分配决策流程:常规路径走 Eden,装不下或天生过大走老年代,G1 则多一条 Humongous 分支
本站要点

四条分配规则按优先级:① Eden 剩余空间够 → Eden;② 不够但老年代够 → 直接老年代;③ 超过 PretenureSizeThreshold(仅 Serial/ParNew)→ 直接老年代;④ G1 下超过 Region 一半 → Humongous Region。写代码时"少 new 超大对象、少在循环里 new 大数组",就是在这个决策树的边缘打转。

第 4 站

晋升规则:对象什么时候进老年代?

对象在年轻代的"履历"由一个数字记录:年龄(age)。每熬过一次 Minor GC,年龄 +1;年龄存的是字节(byte),所以理论上限是 255。HotSpot 默认认为:年龄达到 15 的对象,晋升老年代

正常晋升:obj.age >= -XX:MaxTenuringThreshold(默认 15)→ 晋升老年代
为什么是 15?不是 10 或 20?

15 = 2⁴ - 1,是个经验值,背后的逻辑是:强分代假说下,对象存活曲线是"指数衰减"的——绝大多数对象在第 1 轮就死,能活过 3~5 轮的已经是少数,活过 15 轮的更是极少数。阈值设太低,年轻对象会过早进入老年代,让老年代"消化不良"(回收变慢);设太高,Survivor 会堆积太多对象,复制开销变大。15 是让"Survivor 装得下"和"老年代别太快满"两个约束的折中。另外年龄是 byte,15 也是默认取的一个"安全小值"。

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

年龄只是"名义"条件。HotSpot(Parallel Scavenge 实现)还有一个更聪明的自适应规则

如果 Survivor 区中「相同年龄」的对象总大小 > Survivor 剩余空间的一半,
则该年龄及以上的对象全部直接晋升老年代(不再等年龄到 15)。

举个例子:Survivor 只剩 10MB,而年龄为 3 的对象加起来有 8MB——如果还按年龄规则让它们继续在 S0/S1 之间复制,这次 Minor GC 大概率装不下。JVM 就"批量升级":把年龄 ≥ 3 的对象全部送进老年代。这就是 GC 日志里常说的 average promotion size(平均晋升大小)统计的由来——JVM 会持续跟踪每轮 Minor GC 实际晋升了多大一块,供后续决策参考。

如果 Survivor 就是装不下存活对象,怎么办?

存活对象放不下时,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 收集器的完整判定逻辑是两步:

ParNew 的分配担保判定 · Minor GC 触发前的安全检查
// 设: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 频繁"这类线上问题的理论根源。

Eden new 对象出生 Minor GC S0 / S1 每次存活 age+1 From/To 互换 存活 → age+1 ① age≥15 ② 同年龄 > Survivor/2 Old Gen 长期存活对象 绝大多数对象 在 Eden/第一轮就死亡 ③ 大对象 / ④ 分配担保溢出 直接进入老年代;老年代也放不下 → Full GC Full GC 后仍无法腾出空间 → OutOfMemoryError: Java heap space
图 3 对象的四条晋升路径:年龄晋升、动态年龄判定、大对象直通、分配担保溢出
第 5 站

Minor GC 完整拆解:一次 Young GC 到底做了什么?

前面讲了"对象去哪",这一站拆开引擎盖:Eden 满了之后,JVM 内部一步步发生了什么。以 ParNew(经典的并行复制收集器)为例,一次 Minor GC 分五步:

  1. STW(Stop-The-World):暂停所有 Java 线程。原因:标记和复制期间,引用关系不能变,否则会出现"正在遍历的对象被新建/删除"的混乱。这是所有 Minor GC 无法避免的代价。
  2. 根扫描:从 GC Roots 出发,找出年轻代里的存活对象。注意这里有一个关键优化——不需要完整扫描老年代。老年代对象可能引用了年轻代对象(比如一个长生命周期容器里存了短命对象),但 HotSpot 用卡表(Card Table)+ 写屏障(Write Barrier)机制,只在"老年代指向年轻代的引用发生过写操作"的内存卡(每 512 字节一张卡)上打脏标记,根扫描时只处理脏卡。G1 里对应的结构是 RSet(Remembered Set)。这正是"为什么 Minor GC 只扫年轻代却不会漏掉老年代的引用"的答案——写屏障是并发 GC 的基石,「对象存活判定」一篇会展开讲。
  3. 标记存活对象:用三色标记法(白色=不可达、灰色=自身可达但子引用未处理完、黑色=处理完)从根出发遍历。年轻代用 STW 标记,不存在并发修改问题,所以不需要 SATB 之类的补偿机制。
  4. 复制存活对象:把存活对象按内存序重新排布到 To 区(复制过程中顺便完成内存整理——对象紧凑排列,无碎片);同时 age+1,满足晋升条件的对象改放到老年代(走上一节的分配担保逻辑)。
  5. 清空与角色互换:Eden 和 From 区的"指针"一移即空(零成本,不需要逐个释放),S0 和 S1 互换 From/To 身份。下一轮 Minor GC 的存活对象会复制到另一个区。
GC 前(Eden 已满) 年轻代 Eden(满) ■存活 ■死亡(多数) S0(From) S1(To) Minor GC STW 标记+复制 GC 后 年轻代 Eden(清空) 指针一移即空 S0(空) S1(To) 存活对象紧凑排列,age+1 为什么年轻代用复制算法? ① 存活对象极少(弱分代假说)→ 要复制的东西本来就少,成本远低于"标记+整理" ② 复制过程天然完成内存整理 → 年轻代永远无碎片,分配就是"指针碰撞" ③ 代价:浪费 1/10 空间 + 复制本身耗时 → 所以只适合"存活率低"的年轻代,老年代用标记-整理
图 4 一次 Minor GC 前后:Eden 满 → STW 标记 → 存活对象紧凑复制到 To 区 → Eden/From 清空、S0/S1 互换
"清空 Eden"为什么是零成本?内存不是要逐个释放吗?

年轻代用的是指针碰撞(Bump the Pointer)分配方式:Eden 里维护一个"当前分配位置"指针,分配对象就是指针前移。所有对象从底部连续堆上去,那么"清空"只需要把指针拨回底部——不需要遍历、不需要释放任何字节。这也是为什么年轻代没有碎片:对象总是连续分配的。(空闲列表 Free List 分配方式则相反,留给「对象创建全过程」展开。)

Minor GC 的典型耗时在 10~100ms 量级(取决于存活对象大小和根扫描范围),频率取决于 Eden 大小和对象分配速度——这两者就是后面调优要拧的两个旋钮。

本站要点

Minor GC = STW + 根扫描(靠卡表/写屏障避免全扫老年代)+ 三色标记 + 复制到 To(顺带整理)+ 指针清零 + From/To 互换。记住"卡表/写屏障"这个词——它是面试里区分"背过"和"真懂"的分水岭。

第 6 站

Full GC 与 Mixed GC:老年代什么时候被回收?

年轻代的回收快而频繁,老年代的回收则慢而危险。先把术语理清楚——"Major GC" 和 "Full GC" 在很多材料里混用,但严谨的说法是:

术语回收范围典型含义
Young GC(Minor GC)年轻代Eden 满触发,复制算法,10~100ms
Mixed GC年轻代 + 部分老年代 RegionG1 特有:并发标记周期后,挑"垃圾多"的老年代 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 failureCMS 并发回收期间老年代被塞满,退化为 Serial Old 串行 Full GCConcurrent Mode Failure
JVM 自适应调整(Ergonomics)Parallel/G1 根据历史数据主动提前 GCErgonomics
关于 System.gc() 的冷知识

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 尽量不发生"。它的做法是把老年代回收化整为零

  1. 并发标记周期(Concurrent Marking Cycle):老年代占用率到达 -XX:InitiatingHeapOccupancyPercent(默认 45%,JDK 9+ 可自适应)时启动。分四阶段:初始标记(STW,挂在 Young GC 尾部,只标根对象)→ 并发标记(与应用并发,从根出发标记全部老年代对象,用 SATB 快照保证一致性)→ 最终标记(STW,处理并发期间的引用变更)→ 筛选清理(按 Region 垃圾比例排序)。
  2. Mixed GC:并发标记结束后,接下来的若干次 Young GC 变成 Mixed GC——每次除了回收全部年轻代,再回收一批"垃圾占比最高"的老年代 Region。
  3. 回收多少由暂停时间目标决定-XX:MaxGCPauseMillis(默认 200ms)是 G1 的"软实时"目标。G1 内部维护每个 Region 的回收效率预测(可回收字节数 ÷ 预测耗时),每次 Mixed GC 在暂停预算内按性价比挑 Region。相关旋钮:-XX:G1MixedGCCountTarget(默认 8,分 8 次 Mixed GC 消化完标记的老年代垃圾)、-XX:G1HeapWastePercent(默认 5%,可回收垃圾低于堆的 5% 就停止 Mixed GC)。
如果 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(分几次消化)。

第 7 站

调优参数:控制分代结构的旋钮与方法论

理解了分代原理,调优就是拧参数。先列出控制堆分代结构的核心参数:

参数作用默认值示例
-Xms / -Xmx堆初始 / 最大大小物理内存的 1/64 / 1/4-Xms4g -Xmx4g(生产环境两者必须相等)
-Xmn直接指定年轻代大小(初始=最大)由 NewRatio 推算-Xmn2g
-XX:NewRatio老年代 : 年轻代 比例2(年轻代占 1/3)-XX:NewRatio=1(各占一半)
-XX:SurvivorRatioEden : 单个 Survivor 比例8-XX:SurvivorRatio=6(Survivor 变大)
-XX:MaxTenuringThreshold晋升年龄阈值15-XX:MaxTenuringThreshold=6
-XX:PretenureSizeThreshold大对象直通阈值(仅 Serial/ParNew)0(关闭)-XX:PretenureSizeThreshold=512k
-XX:MaxGCPauseMillisG1 暂停时间目标(软约束)200ms-XX:MaxGCPauseMillis=50
-XX:InitiatingHeapOccupancyPercentG1 启动并发标记的堆占用水位45%(JDK9+ 自适应)-XX:InitiatingHeapOccupancyPercent=35

参数之间的关系与冲突(面试爱问)

  • -Xmn-XX:NewRatio 二选一:同时指定时 -Xmn 优先生效,NewRatio 被忽略。写启动脚本时别两个都写,否则参数"看起来生效了其实没生效"。
  • -Xms = -Xmx 是铁律:堆动态扩容/缩容本身需要 GC 且缩容要整理内存,生产环境固定堆大小,把"扩堆 STW"这个变量从线上环境里彻底移除。
  • SurvivorRatio 调小的连锁反应:Survivor 变大 → 存活对象能多待几轮 → 晋升变慢,老年代压力减小;但 Eden 变小 → Minor GC 频率上升。两个方向互相制约,没有免费午餐。

三种典型场景的启动模板

场景一:通用 Web 服务 · 8G 容器,Spring Boot 微服务,G1
# 固定堆 = 容器内存的一半,给堆外(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/
场景二:批量处理 / ETL · 吞吐优先,Parallel GC
# 吞吐优先:Parallel GC(JDK 8 的默认服务端收集器)
-Xms8g -Xmx8g
-XX:+UseParallelGC
# 大批量短命对象:年轻代给足(约占堆一半)
-XX:NewRatio=1
# 关注吞吐而非停顿:GC 时间占比上限
-XX:GCTimeRatio=19   # GC 时间 ≤ 总时间的 1/20
场景三:超低延迟服务 · 金融行情 / 实时竞价,ZGC
# ZGC:停顿与堆大小无关(亚毫秒级),适合大堆 + 严格延迟 SLA
-Xms16g -Xmx16g
-XX:+UseZGC
-XX:SoftMaxHeapSize=12g  # 软上限:尽量把堆压在这个值以下,减少 GC 频率

调优方法论:五步走,拒绝拍脑袋

GC 调优五步法(所有场景通用):

  1. 固定基线-Xms=-Xmx,选定收集器,GC 日志 + OOM 转储全部打开。
  2. 观察日志:先跑一段时间,用「GC 日志分析」一篇的方法读日志——Young GC 频率/耗时、Full GC 频率、晋升速度、老年代占用曲线。
  3. 定位瓶颈:三类症状对应不同旋钮——Young GC 太频繁 → 加大年轻代;单次停顿太长 → 收集器目标(MaxGCPauseMillis)或减少存活对象;Full GC 多 / 老年代涨得快 → 晋升问题(Survivor 太小、大对象、泄漏)。
  4. 单变量调整:一次只动一个参数,跑同样的负载,对比日志。多变量一起调 = 无法归因。
  5. 回归验证:压测验证 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 把宿主机内存吃满"的经典事故根源。

第 8 站

总结:知识地图 + 面试模板 + 高频追问

知识地图

本文全部知识点的结构(能默画出来就算内化):

  • 为什么分代:弱分代假说(短命对象占绝大多数)+ 强分代假说(熬过 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 互斥。

面试回答模板

30 秒版(先说结论)

"堆分代基于弱分代假说——绝大多数对象朝生夕灭。年轻代用 Eden + 双 Survivor(8:1:1),Minor GC 用复制算法,快但 STW;长寿对象按年龄或动态判定晋升老年代(约 2/3 堆),用更慢的策略回收。G1 之后年轻代/老年代变成 Region 的逻辑集合,用 Mixed GC 化整为零,目标是避免 Full GC。"

3 分钟版(追加分层)

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 · 评论