深蓝黑背景品牌封面:左侧大标题「Eden 与两个 Survivor」,副标题「JVM 堆内存为什么这样分」;右侧原创装饰——新生代内存条按 8:1:1 分成 Eden、S0、S1 三段,一枚小方块对象正沿箭头从 Eden 移向 Survivor 再走向老年代;左上标注「经典题解析 · Java 后端」,右上标注「Day 04 · 10-02」 图 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 只需搬运少量幸存者。

结构与流转图:顶部是堆内存布局——新生代按 8:1:1 分为 Eden、Survivor0、Survivor1,老年代占更大空间。中部是对象的一生:新对象进 Eden;Eden 满触发 Minor GC,存活者复制到 S0;下次 Minor GC 把 Eden 与 S0 的存活者一起复制到 S1,年龄加一,两个 Survivor 区角色互换;年龄到阈值(默认 15)晋升老年代;老年代满了才触发成本更高的 Full GC。底部解释设计原因:绝大多数对象朝生夕灭,复制算法在存活率低时极便宜;两个 Survivor 一个装存活者、一个保持空白,避免内存碎片;8:1:1 是对「约九成对象熬不过第一次 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、内存溢出)折磨过吗?当时是怎么定位的?欢迎分享。