图 1:本文封面,点明主题「堆内存的分代设计」。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「堆内存为什么分代、为什么是两个 Survivor」是 JVM 面试的常驻题——能画出 Eden、S0、S1 的人很多,能讲清「为什么这么设计」的人不多。
想象面试现场,面试官问:新生代为什么不是一个整块,而要分成 Eden 加两个 Survivor 区?直接回答「这是规定」就输了,这题考的是设计动机。
第一层动机:绝大多数对象朝生夕灭。
这是全部设计的地基——弱分代假说:绝大多数对象活不过第一次垃圾回收,活下来的少数往往会活很久。既然生死两极分化,就把堆按年龄分区:新生对象全进新生代,回收勤、成本低;熬过多轮回收的「老资格」晋升老年代,回收少、管得住。如果全部对象混在一个堆里,每次回收都要扫描全部内存,绝大多数扫描都浪费在短命对象的残骸上。
第二层动机:存活率低,复制算法最便宜。
新生代回收用复制算法:把活着的对象整体搬走到另一块区域,剩下的一整块直接清零。复制成本只和「活了多少」成正比——九成都死了,搬运量只有一成,极其划算;反过来,如果存活率高,复制就搬不动了。所以复制算法只在新生代成立,老年代存活率高,得用标记-整理等其他算法。
第三层动机:为什么是两个 Survivor,而不是一个。
复制算法需要一块「搬过去」的目标空间。如果只有 Eden 加一个 Survivor,回收时存活者搬进 Survivor,下一轮换 Eden 空着、从 Survivor 往里搬——目标区和来源区里的新旧对象交错混杂,几轮下来就产生碎片,还得额外整理。两个 Survivor 的设计让角色轮换:任何时刻,一个(from)装着上次活下来的对象,另一个(to)永远空白待命。回收时把 Eden 和 from 里的存活者一起复制进 to,然后 Eden 和 from 整体清空,from 与 to 交换身份。总有一块空白区接着下一批存活者,内存永远不碎。
至于比例,Oracle 官方调优指南给出的默认值可作参照:新生代与老年代默认比例 NewRatio 为 2,Eden 与单个 Survivor 的比例 SurvivorRatio 为 8——也就是常见的 8:1:1。这是对「约九成对象熬不过第一次回收」这一经验的比例适配:拿来真正干活的区域占九成,留白成本只有一成。
对象的一生,走一遍。
新对象进 Eden;Eden 满了触发 Minor GC,存活者复制进 S0,年龄记 1;下次 Minor GC,Eden 和 S0 的存活者一起进 S1,年龄加一,两区角色互换。年龄熬到阈值(HotSpot 晋升阈值上限默认 15,虚拟机也会按 Survivor 占用动态调整)就晋升老年代;老年代满了才触发成本更高的 Full GC。Survivor 区装不下的存活者会提前溢出晋升——这也是官方指南提醒「Survivor 太小会让复制直接溢进老年代」的原因。
用可运行代码把分代账算出来。
下面这份模拟按 8:1:1 的分区与一个「约九成短命」的对象分布跑三十轮回收,统计对象生死去向,用 Node.js 22.15.0 直接运行(node jvm-gc.js)即可:
'use strict';
// 分代复制回收模拟:Eden 8 份、S0/S1 各 1 份(8:1:1),年龄到 15 晋升老年代
const EDEN = 800, SURVIVOR = 100, MAX_AGE = 15;
let eden = [], sFrom = [], sTo = [], old = 0;
let allocated = 0, diedYoung = 0, copiedTotal = 0, gcCount = 0;
function minorGC() {
gcCount++;
const survivors = [];
for (const obj of [...eden, ...sFrom]) {
obj.alive = Math.random() < obj.survivalRate; // 按预设存活率决定死活
if (obj.alive) {
obj.age++;
copiedTotal++; // 每复制一次都是成本
if (obj.age >= MAX_AGE) old++;
else survivors.push(obj);
} else {
diedYoung++;
}
}
if (survivors.length > SURVIVOR) old += survivors.length - SURVIVOR; // 装不下提前晋升
sTo = survivors.slice(0, SURVIVOR);
eden = [];
[sFrom, sTo] = [sTo, sFrom]; // 两个 Survivor 角色互换,一个装存活者一个留白
sTo = [];
}
// 模拟一个典型服务:每轮新分配 800 个对象,约九成熬不过第一次 GC
for (let round = 0; round < 30; round++) {
for (let i = 0; i < EDEN; i++) {
allocated++;
eden.push({ age: 0, survivalRate: Math.random() < 0.9 ? 0.05 : 0.85 });
}
minorGC();
}
console.log(`共触发 Minor GC:${gcCount} 次`);
console.log(`累计分配对象:${allocated}`);
console.log(`在新生代被回收:${diedYoung}(${((diedYoung / allocated) * 100).toFixed(1)}%)`);
console.log(`晋升老年代:${old}(${((old / allocated) * 100).toFixed(1)}%)`);
console.log(`复制总次数:${copiedTotal},平均每次 GC 复制 ${(copiedTotal / gcCount).toFixed(0)} 个对象(占 Eden 的 ${((copiedTotal / gcCount / EDEN) * 100).toFixed(1)}%)`);
实测输出如下(比例为随机模拟,每次略有浮动):
共触发 Minor GC:30 次
累计分配对象:24000
在新生代被回收:22103(92.1%)
晋升老年代:1797(7.5%)
复制总次数:4797,平均每次 GC 复制 160 个对象(占 Eden 的 20.0%)
92% 的对象在新生代就被收走,只有 7.5% 需要惊动老年代——「朝生夕灭」不是玄学,是可以算账的。复制成本也摆在明处:每次 GC 只需搬运少量幸存者。
图 2:堆的分代布局、对象流转路径与三层设计动机。
可能的追问。
「对象一定熬到 15 岁才晋升吗?」不一定,Survivor 装不下会提前晋升,虚拟机也会动态调整年龄阈值。「大对象也走 Eden 吗?」超过阈值的大对象可能直接进老年代,避免在新生代里反复搬动大块头。「G1 也是这个布局吗?」G1 把堆划成等大的 Region,逻辑上仍分 Eden、Survivor、老年代角色,分代思想没变,物理形态变了——具体收集器是另一道题。
易错点清单。
一,把 8:1:1 背成规定,讲不出「复制便宜、留白防碎片」的动机。二,以为两个 Survivor 都在装数据——任何时候都有一个在留白待命。三,把晋升说成「固定 15 次回收」,忽略动态年龄与 Survivor 溢出两条提前路径。四,以为 Minor GC 只扫 Eden——它扫的是整个新生代(Eden 加 from 区)。
回到面试现场,建议的答题节奏:先抛弱分代假说,再推导出「复制算法在新生代最便宜」,顺势解释两个 Survivor 的角色轮换,最后把 8:1:1 和对象一生串起来。推理网关、数据管道这类 Java 服务,对象生命周期的直觉就是内存调优的前提;分代的「冷热分离」思想,在缓存分层里也一脉相承。
你被 JVM 内存问题(频繁 Full GC、内存溢出)折磨过吗?当时是怎么定位的?欢迎分享。