堆内存分代结构

候选人小李在面试美团 P6 时,面试官问道:

"JVM 堆的分代结构是什么?Minor GC 和 Full GC 有什么区别?"

小李说:"堆里有 Eden、Survivor 和老年代..."面试官追问:"为什么对象要优先在 Eden 区分配?"

小李说:"因为 Eden 区空间大..."面试官继续追问:"对象什么时候会进入老年代?年龄阈值默认是多少?"

小李答不上来了...

一、核心问题:堆内存分代结构 🔴

1.1 问题拆解

第一层:分代结构(有哪些区域?)
  "JVM 堆分代分为哪几部分?默认比例是多少?"
  考察点:Eden/S0/S1/Old 的比例、-XX:NewRatio

第二层:对象分配(怎么分配?)
  "新对象在哪里分配?TLAB 是什么?"
  考察点:Eden 区分配、线程本地分配缓冲

第三层:晋升条件(什么时候晋升?)
  "对象什么时候从年轻代进入老年代?"
  考察点:年龄阈值、大对象直接进入老年代、动态年龄判定

1.2 ❌ 错误示范

候选人原话 A:"Eden 区和 Survivor 区的比例是 1:1。"

问题诊断:默认比例是 Eden : Survivor = 8 : 1(即 -XX:SurvivorRatio=8,默认 8)。两个 Survivor 区各占年轻代的 1/10。

候选人原话 B:"所有对象都在堆上分配。"

问题诊断:不一定。经过逃逸分析后,如果对象不逃逸出方法/线程,可以进行栈上分配(Stack Allocation),甚至标量替换(Scalar Replacement),对象直接在栈上分配,无需 GC。

1.3 标准回答

P5 级别:分代结构

JVM 堆的分代

graph TD
    H[堆 Heap] --> Y[年轻代 Young Gen<br/>约 1/3 堆]
    H --> O[老年代 Old Gen<br/>约 2/3 堆]

Y --> E[Eden 区
约 80% 年轻代] Y --> S0[Survivor 0
约 10% 年轻代] Y --> S1[Survivor 1
约 10% 年轻代]

O --> O1[Old Gen
长期存活对象]


**默认比例**(JDK 8):

- 年轻代 : 老年代 = 1 : 2(可通过 `-XX:NewRatio` 调整)
- Eden : Survivor0 : Survivor1 = 8 : 1 : 1(可通过 `-XX:SurvivorRatio` 调整)

**Minor GC vs Full GC**:

| 类型 | 触发条件 | 回收区域 | 停顿时间 |
| --- | --- | --- | --- |
| **Minor GC** | Eden 区满 | 年轻代 | 短(通常 < 100ms) |
| **Full GC** | 老年代满 / Metaspace 满 / System.gc() | 全堆 + Metaspace | 长(通常 > 500ms) |

P6 级别:对象分配与晋升

对象分配流程

graph TD
    A[新对象分配请求] --> B{Eden 区够?}
    B -->|是| C{TLAB 分配}
    C -->|是| D[TLAB 分配<br/>线程本地缓冲区]
    C -->|否| E[尝试 Eden 区 CAS 分配]
    E -->|失败| F[Minor GC]
    F -->|GC 后 Eden 够| D
    F -->|GC 后 Eden 不够| G[大对象直接进老年代<br/>或 Full GC]
    B -->|否| H[Minor GC]

TLAB(Thread Local Allocation Buffer)

为减少并发分配时的竞争,JVM 为每个线程在 Eden 区预分配一块专属缓冲区:

// TLAB 的工作原理
// 每个线程在 Eden 区有自己的专属区域(通常 1~2MB)
// 线程在自己的 TLAB 中分配对象,无需加锁(CAS)
// TLAB 满了后,分配新的 TLAB

对象晋升条件

  1. 年龄达到阈值:Minor GC 后,对象的年龄 age 增加 1。当 age >= -XX:MaxTenuringThreshold(默认 15)时,进入老年代。

  2. 动态年龄判定:如果 Survivor 区内相同年龄所有对象的大小之和超过 Survivor 空间的 50%,则 age >= 该年龄的对象直接进入老年代。

  3. 大对象直接进入老年代:超过 -XX:PretenureSizeThreshold(默认 0,不启用)的对象直接在老年代分配。

// 示例:大量创建字符串
for (int i = 0; i < 100000; i++) {
    // 每个字符串都会被优先分配到 Eden 区
    // Minor GC 后,年龄增加
    // 达到阈值后进入老年代
}

P7 级别:分代假设与调优

分代假说(Generational Hypothesis)

JVM 的分代设计基于两个假设:

  1. 弱分代假说:大多数对象都是朝生夕死的(大部分对象很快变得不可达)
  2. 强分代假说:熬过多次 GC 的对象倾向于继续存活

这两个假设使得分代 GC 策略非常高效——只需要扫描年轻代的小部分存活对象。

分代配置策略

场景配置理由
通用 Web 服务-XX:NewRatio=2(年轻代 1/3)兼顾大小对象
大数据/缓存-XX:NewRatio=1(年轻代 1/2)大量长期存活对象
短生命周期任务-XX:NewRatio=3~4(年轻代 1/4)老年代更多空间
避免 Full GC-XX:NewRatio=1 -XX:MaxTenuringThreshold=1快速晋升

一个容易被忽视的参数

-XX:TargetSurvivorRatio(默认 50):Survivor 区使用率超过这个值时,即使未达到年龄阈值,对象也会提前晋升。

【面试官心理】 这道题我能问到 P7 级别,是因为分代结构涉及了 GC 策略的核心假设和调优参数。能说出 TargetSurvivorRatio 的候选人说明他对 GC 调优有实战经验。能解释动态年龄判定的候选人说明他看过相关源码。

1.4 追问升级

追问 1:为什么 Survivor 区要设计成两个(S0 和 S1)?

Survivor 分区的设计是为了解决内存碎片问题。Minor GC 后,存活对象从 Eden + 一个 Survivor 区复制到另一个 Survivor 区(Copying GC 算法)。这样 Eden 和 Survivor 区的内存始终是连续的,避免了标记-清除算法的碎片问题。

S0 和 S1 每次只有一个被使用,另一个空闲。角色在每次 Minor GC 后交换。

追问 2:对象头中年龄占几位?最大年龄为什么是 15?

对象头 Mark Word 中的 GC 分代年龄字段只有 4 位(age:4),最大表示 15。当 age 达到 15 时,对象晋升到老年代(JDK 8 及之前)。JDK 11+ 引入了 G1,G1 中对象的年龄概念有所变化。

二、生产调优 🟡

2.1 Survivor 空间配置

# 年轻代 : 老年代 = 1 : 2
-XX:NewRatio=2

# Survivor Ratio = Eden : Survivor = 8 : 1
-XX:SurvivorRatio=8

# 最大年龄阈值
-XX:MaxTenuringThreshold=15

2.2 大对象配置

# 超过 100KB 的对象直接进入老年代
-XX:PretenureSizeThreshold=100000

三、常见问题 🟡

3.1 对象过早晋升

场景:Minor GC 后,大量对象进入老年代,频繁触发 Full GC。

根因:Survivor 区太小,对象年龄未达到阈值就因 Survivor 区空间不足而提前晋升。

解决:增加 Survivor 区大小(-XX:SurvivorRatio)或增大年轻代比例。

3.2 内存分配担保

Minor GC 前,JVM 检查老年代最大可用连续空间是否大于年轻代所有对象总空间。如果小于,则检查是否允许担保失败。如果允许,则尝试 Minor GC。否则,执行 Full GC。

-XX:-HandlePromotionFailure  # JDK 8 已移除(总是担保)
💡

面试加分点:能说出"JDK 11 的 ZGC 已经不再使用传统的分代结构,它使用着色指针和读屏障技术,实现了几乎无停顿的 GC(停顿时间 < 1ms)",说明他关注了现代 GC 的演进。

⚠️

面试陷阱:被问到"对象一定在 Eden 区分配吗",很多人会说"是"。准确答案是:不一定。大对象(超过 PretenureSizeThreshold)直接在老年代分配;TLAB 分配在线程专属缓冲区;逃逸分析后可能栈上分配。