# 第 32 章 JVM 内存结构与 GC


> 本章把"内存怎么分、对象怎么生、垃圾怎么判、收集器怎么停"串成一条因果链，是 Java 岗追问最深的一章。

## 学习目标

读完本章，你应当能够：

1. 默画运行时数据区五部分并标出线程私有性，说出两类栈/堆异常各自的触发位置；
2. 推导"为什么永久代必须被元空间替换"，并解释 `-XX:MetaspaceSize` 为什么不是初始大小；
3. 走完 `new` 的五步与 `TLAB` 分配路径，判断"对象一定在堆上"在什么条件下不成立；
4. 背出 `GC Roots` 主要类别与四种引用的语义，用可达性分析解释循环引用为什么不会泄漏；
5. 用赋值器/收集器双视角推导三色标记的两个漏标条件，说明增量更新与原始快照各破坏哪一个；
6. 在给定堆大小、延迟与吞吐要求下选出收集器，并针对八种 `OutOfMemoryError` 给出定位手段。

### 前置知识检查

| 你需要知道 | 在哪一节补齐 |
|---|---|
| 虚拟内存、分页与缺页 | 见第 8 章 8.2 节（本层内存是 JVM 向 OS 申请的虚拟地址空间） |
| 线程是内核调度实体 | 见第 4 章 4.3 节（本层只讲 Java 线程私有什么） |
| 对象头 `Mark Word`、锁升级、类加载过程 | 见第 30 章 30.3 节与第 28 章 28.6 节（本层只用"对象头有 Klass Pointer"这一点） |

## 32.1 运行时数据区与线程私有性

JVMS 2.5 只定义了五块区域。划分标准不是"放什么数据"，而是**这块内存的生命周期绑定谁**：绑定线程的随线程生死，绑定进程的随 JVM 生死。

```
                   一个 JVM 进程的地址空间
 线程私有（随线程创建/销毁）        线程共享（随 JVM 生死）
 ┌──────────────────────────────┬──────────────────────────────┐
 │ 程序计数器 PC Register：唯一不 │ 堆 Heap：对象实例、数组；     │
 │ 规定任何 OOM 的区域（只存字节码 │ 新生代 / 老年代               │
 │ 偏移量）                       │  · 超了抛 OutOfMemoryError    │
 ├──────────────────────────────┼──────────────────────────────┤
 │ 虚拟机栈 VM Stack：栈帧 = 局部 │ 方法区 Method Area：类元信息、 │
 │ 变量表 + 操作数栈 + 动态链接 + │ 运行时常量池（JDK 8+ 由元空间  │
 │ 返回地址                      │ 实现；字符串常量池在堆内）     │
 │ · -Xss 默认约 1 MB，随线程数线 ├──────────────────────────────┤
 │   性累加；深度超了抛           │ 直接内存 Direct Memory：非    │
 │   StackOverflowError          │ JVMS 区域，-Xmx 管不到；      │
 ├──────────────────────────────┤ ByteBuffer.allocateDirect 耗尽 │
 │ 本地方法栈 Native Stack：服务  │ 抛 Direct buffer memory       │
 │ native 方法，同为栈帧模型      │                              │
 └──────────────────────────────┴──────────────────────────────┘
```

- **程序计数器**是 JVMS 中唯一不规定任何 `OutOfMemoryError` 的区域 —— 它只存一个字节码偏移量，大小常量级。
- **虚拟机栈**默认大小由 `-Xss` 决定（HotSpot 64 位 Linux 约 1 MB），**随线程数线性累加**：这就是"`-Xss` 调大会让可创建线程数变少"的原因。栈是 JVM 向 OS 申请的，所以**栈深度不够报 `StackOverflowError`，而创建线程失败报 `OutOfMemoryError: unable to create new native thread`**。**方法区**是 JVMS 的**规范概念**，"永久代"与"元空间"都只是 HotSpot 的实现；**直接内存**不是运行时数据区，只受 `-XX:MaxDirectMemorySize` 与物理内存约束。

> [!IMPORTANT]
> 第一个必背映射：栈深度不够 → `StackOverflowError`（线程仍可继续）；**要不到内存** → `OutOfMemoryError`；无法创建线程 → `unable to create new native thread`。判断看的是**耗尽的资源类型**，不是代码在哪个区域。

## 32.2 永久代到元空间：JDK 7 与 JDK 8 的两次搬家

最容易被"背错版本号"的考点：**JDK 7 与 JDK 8 做的是两件事**，不能合并成一句"JDK 8 把永久代换成了元空间"。

**版本迁移时间线**（一图记住两次搬家）：
```
 JDK 6                        JDK 7                      JDK 8
 堆: —                        堆: ★字符串常量池          堆: ★字符串常量池
                                  ★静态变量                  ★静态变量
 永久代: 字符串常量池/静态变量/  永久代(掏空): 类的元信息    元空间(本地内存):
         类的元信息            -XX:MaxPermSize             类的元信息 + 运行时常量池
                                                          -XX:MaxMetaspaceSize
```

**JDK 7 搬走了什么、为什么**：字符串常量池（string constant pool）与静态变量（`static` field）移入堆 —— 这两类数据的**生命周期与应用代码一致**且会被大量创建（`intern`、反射生成类名），放在受 `-XX:MaxPermSize` 硬限制、且只能 Full GC 时才回收的永久代里，极易触发 `OutOfMemoryError: PermGen space`；搬进堆后它们跟着堆 GC 一起回收，效率立刻上升。

**JDK 8 为什么彻底废掉永久代**：

| 原因 | 具体机制 |
|---|---|
| 容量上限不可控 | `-XX:MaxPermSize` 是启动时确定的固定上限（默认约 64 MB），类数量运行时不可预测，只能试错调参 |
| 回收效率低 | 只在 Full GC 回收，且类卸载条件苛刻：该类的所有实例已回收、`ClassLoader` 已回收、`Class` 对象无引用且未被反射使用 |
| 与 `JRockit` 合并 | 两个虚拟机要合并，`JRockit` 没有永久代概念，元空间是双方都能接受的抽象 |
| 便于分块管理 | 元空间按 `Chunk`（块）分配，由 `VirtualSpaceList` 管理、块可归还 OS；永久代是一整块，归还粒度粗 |

**`-XX:MetaspaceSize` 的真实语义**（常被答错）：它**不是初始大小**，而是**首次触发元空间 GC 的阈值**（HotSpot 里叫 `initial_high_water_mark`），64 位 Linux 默认约 20.8 MB。元空间启动时几乎不占承诺内存，随类加载增长；一旦触及这条线就触发一次 GC 并把它**上调**（按 `MinMetaspaceFreeRatio` / `MaxMetaspaceFreeRatio` 动态调整），代价是一次 Full GC，所以"启动期前几次 GC 由元空间扩容引起"是正常现象。

> [!CAUTION]
> 答"`-XX:MetaspaceSize` 是初始大小"会被追问到崩："那为什么把它调大能减少启动期 Full GC？" —— 只有答出"它是阈值/水位线，调大等于推迟首次元空间 GC"才自洽。`-XX:MaxMetaspaceSize` 不设则**默认无上限**，只受物理内存限制，这也是元空间耗尽报 `Metaspace` 而非 `PermGen` 的原因。

## 32.3 对象创建与内存分配

### 32.3.1 `new` 的五个步骤

```
 ① 类加载检查：常量池能否定位到类的符号引用？类是否已加载/解析/初始化？
        否 → 先走类加载流程（见第 28 章 28.6 节）
 ② 分配内存：方式取决于堆是否规整（是否带压缩）
        · 指针碰撞 Bump-the-Pointer：空闲|已用 之间一个指针平移
          → Serial / ParNew / Parallel Scavenge
        · 空闲列表 Free List：维护可用块列表挑一块够大的
          → CMS（标记-清除，不压缩）
        并发安全 → CAS 重试 / TLAB
 ③ 零值初始化：内存全部置 0（保证实例字段可直接读出默认值）
 ④ 设置对象头：Mark Word（哈希码/GC 分代年龄/锁状态）+ Klass Pointer
 ⑤ 执行 <init>：调用构造方法按代码赋值
```

关键区分：**第 ③ 步后对象已"存在"**（字段全零），第 ⑤ 步后才"可用"。所以构造方法里把 `this` 逸出给其他线程是危险的 —— 别人可能看到字段全为零的半成品（安全发布问题，见第 30 章 30.5 节）。数组对象的对象头额外多 4 字节 `length`。

### 32.3.2 `TLAB`：把分配从同步操作变成线程内操作

堆共享，"指针平移"本身不是原子操作，多线程同时分配必须同步；而 `new` 是最频繁的操作，一次 CAS（无竞争约 20 ns）乘以天文数字的分配次数不可接受。

`TLAB`（thread local allocation buffer）的解法：**每个线程在 Eden 里预划一小块私有区域**，线程内 `new` 只动自己 TLAB 里的指针，**完全不需要同步**；TLAB 用尽才去 Eden 申请新块（这一步才需要 CAS）。它由 `-XX:+UseTLAB` 控制、**默认开启**，单块默认占 Eden 的 1%（`-XX:TLABWasteTargetPercent`），大小按分配速率自适应；代价是 **TLAB 内部碎片**（`TLABWastePercent` 控制可容忍浪费），太大浪费空间、太小退化成频繁 CAS。大对象放不进 TLAB，退回 Eden 共享区用 CAS 分配。

### 32.3.3 逃逸分析与标量替换：为什么"对象都在堆上"不再严格

逃逸分析（escape analysis，`-XX:+DoEscapeAnalysis`，JDK 6u23 后默认开启）判断对象作用域：**不逃逸**（只在方法内用，未返回、未赋给静态字段、未传给其他线程）与**方法/线程逃逸**。据此 JIT 做两种优化：**标量替换（scalar replacement）**把不逃逸对象拆成若干基本类型局部变量（标量）放进寄存器或栈，效果等同于"栈上分配"；**锁消除（lock elision）**则判断不逃逸对象上的 `synchronized` 不可能有竞争，直接删掉 `monitorenter/exit`，省掉一次约 20 ns 的加解锁。需注意 **HotSpot 并未实现纯粹的栈上分配（stack allocation）**，标量替换才是它的真实实现路径。

所以严格表述是：**"绝大多数情况下对象在堆上分配"**。开启逃逸分析后，不逃逸对象的字段可能被拆成栈上标量，堆上根本不存在这个对象。答"对象一定在堆上，这是规范规定的"是错的 —— JVMS 只说堆是"所有类实例和数组分配"的运行时区域，而 JIT 把不可观测的分配优化掉并不违反可观测语义。

> [!TIP]
> 被追问"什么会破坏逃逸分析？"答：对象被返回、存入静态/成员字段、传给无法内联的多态调用点。再追问"标量替换和栈上分配是一回事吗？"答：概念上栈上分配是目标，标量替换是 HotSpot 的实现路径。

## 32.4 可达性分析与四种引用

### 32.4.1 为什么不用引用计数：`GC Roots` 清单

引用计数的致命缺陷是**无法处理循环引用**：`a.next = b; b.next = a;` 之后即使外部再无引用，两者计数都不为 0，永不回收；它还要在每次赋值时插桩，高并发下计数的原子更新也成瓶颈。因此主流 JVM 选择**可达性分析（reachability analysis）**：从一组 `GC Roots` 出发沿引用链遍历，遍历不到的即为可回收。

| 类别 | 具体内容 |
|---|---|
| 虚拟机栈中引用的对象 | 各线程栈帧局部变量表（Local Variable Table）里的引用 —— 正在运行代码的活动变量 |
| 本地方法栈 JNI 引用 | native 方法持有的 `jobject` 句柄 |
| 方法区静态属性 / 常量引用 | 类的 `static` 字段、运行时常量池里的 `String`/`Class` 等常量（JDK 7 后变量本体在堆，被引用对象同样视为根） |
| 活跃线程与同步锁对象 | 已启动未结束的 `Thread` 实例；被 `synchronized` 监视器（monitor）持有的对象 |
| `Class` 对象与系统类加载器 | 每个已加载类的 `java.lang.Class`；启动类加载器加载的类（类卸载条件极苛刻） |
| `Unsafe` / `JVMTI` / 其他内部 | `Unsafe.allocateInstance` 创建的对象；调试 agent 注册的引用；预分配的常驻异常对象 |

面试答出**前四类加上"活跃线程、同步锁对象、`Class` 对象、系统类加载器"** 八类即算完整。

### 32.4.2 两次标记、`finalize()` 的复活与四种引用

不可达对象要过两道关：**第一次标记**发现不可达后，若它未覆盖 `finalize()`（或该方法已被 JVM 调用过）就**直接回收** —— 这是绝大多数对象的路径；只有覆盖了 `finalize()` 且从未被调用过的对象才被放入 `F-Queue`，由 `Finalizer` 线程执行，随后**第二次标记**：若它在 `finalize()` 里把 `this` 赋给某个 `GC Root` 可达的变量就**复活**（仅此一次机会），否则回收。`finalize()` 的执行时机与是否执行都不保证，这正是它被弃用的原因 —— JDK 9 起 deprecated，推荐 `Cleaner` 或 `try-with-resources`（见第 28 章 28.4 节）。

| 引用类型 | 类 | 回收语义 | 典型用途 |
|---|---|---|---|
| 强引用（strong） | 直接赋值 | 只要引用链可达就绝不回收，宁可抛 OOM | 常规对象 |
| 软引用（soft） | `SoftReference<T>` | **内存不足时**回收（在 OOM 之前清空软引用） | 缓存：图片缓存、本地页面缓存 |
| 弱引用（weak） | `WeakReference<T>` | **下一次 GC 必定回收**，不管内存是否充足 | `ThreadLocalMap` 的 `key`、`WeakHashMap` |
| 虚引用（phantom） | `PhantomReference<T>`，**必须**传 `ReferenceQueue` | `get()` 永远返回 `null`，仅在对象被回收时收到入队通知 | 回收通知、`DirectByteBuffer` 的堆外内存释放 |

```java
import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;

public class ReferenceDemo {
    public static void main(String[] args) throws InterruptedException {
        // 用 ReferenceQueue 而不是轮询引用对象：引用本身被 GC 后我们无从观察，
        // 只有队列能可靠报告"被引用对象已被回收"这一事件已经发生
        ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
        // 刻意不建强引用：一旦存在强引用，弱引用形同虚设，GC 绝不会回收它
        WeakReference<byte[]> weak = new WeakReference<>(new byte[1024 * 1024 * 4], queue);

        System.gc();
        Thread.sleep(200); // 给引用处理线程留出入队时间，生产代码不应依赖 sleep
        // 断言"能从队列取出"而不是"get() == null"：get() 为 null 只是必要条件，
        // 队列通知才是软/虚引用的真正用途
        System.out.println("入队: " + (queue.poll() == weak) + ", get()=" + weak.get());
    }
}
```

`ThreadLocalMap` 的 `key` 用弱引用是为解决一个具体问题：若线程长期存活（线程池）而 `ThreadLocal` 在外部被置空，强引用会让整个 `Entry` 的 value 也无法回收；但 **value 仍是强引用**，仍会泄漏 —— 用完必须 `remove()`（见第 30 章 30.6 节）。

## 32.5 GC 算法基础与分代假说

### 32.5.1 三种基础算法与各自的碎片问题

| 算法 | 过程 | 碎片问题 | 适用场景 | 移动对象 |
|---|---|---|---|---|
| 标记-清除（mark-sweep） | 标记存活 → 回收未标记块 | **产生外部碎片**；效率随对象数下降（分配要在空闲列表找块） | 老年代（CMS） | 否，因此不能指针碰撞分配 |
| 标记-复制（mark-copy） | 存活对象整体复制到另一块空闲区 | **无碎片**，但**浪费一半空间**，存活率越高复制越贵 | 新生代（存活率通常 < 10%） | 是 |
| 标记-整理（mark-compact） | 标记 → 全部向一端移动 → 清边界外 | 无碎片 | 老年代 | 是，**必须 `STW`**（移动中引用不稳定） |

标记-复制的关键数字：新生代对象约 **98% 朝生夕死**，这正是 `Eden : S0 : S1 = 8 : 1 : 1`（浪费 1/10 而非 1/2）的依据。Appel 式回收把新生代切成一大两小，只在两个 Survivor 间复制，把空间浪费压到 10%。

### 32.5.2 分代假说与 `Card Table`

分代收集建立在两条弱假说上：**弱分代假说（weak generational hypothesis）** —— 绝大多数对象朝生夕死，决定了"新生代用复制、老年代用整理"；**强分代假说（strong generational hypothesis）** —— 熬过越多次 GC 的对象越难死，决定了"年龄计数与晋升"。

但分代带来新问题：**跨代引用**。Young GC 时若老年代对象引用了新生代对象，只扫新生代就会漏掉这些实际可达的对象。逐一扫描整个老年代代价太高，解法是 **`Card Table`（卡表）**：把老年代按 512 字节（HotSpot 卡页）划分，每张卡对应一个字节标记"这页里有指向新生代的引用"。

```
 老年代内存（按 512 B 分卡）
 ┌────┬────┬────┬────┬────┬────┬────┐
 │卡0 │卡1 │卡2 │卡3 │卡4 │卡5 │卡6 │  ← 每张卡 512 字节
 │ 0  │ 1  │ 0  │ 0  │ 1  │ 0  │ 0  │  ← 0 干净 / 1 脏（dirty）
 └────┴────┴────┴────┴────┴────┴────┘
      ▲         ▲  这些卡里的对象引用了新生代
 Young GC 只扫脏卡，不扫整个老年代；置脏由写屏障在
 老年代 → 新生代 的引用赋值时插桩完成
```

写屏障是**赋值器（mutator，应用线程）**在写引用时插入的一小段代码（HotSpot 中由 `oop_store` 的 `post_barrier` 实现），成本是每次跨代引用赋值多一次内存写，换来 Young GC 不必扫描整个老年代。

## 32.6 Minor GC 的完整流程与对象晋升

```
 ① Eden 满，触发 Young GC（Minor GC），【STW】
 ② 从 GC Roots 标记 Eden + 当前 From Survivor(S0) 的存活对象
        老年代的引用靠 Card Table 定位（见 32.5.2）
 ③ 复制存活对象到 To Survivor(S1)，年龄 +1；以下任一命中则直接进老年代：
        · 年龄 ≥ MaxTenuringThreshold（默认 15）
        · 动态年龄判定命中
        · 对象大于 PretenureSizeThreshold（大对象路径）
        · To Survivor 放不下（空间分配担保）
 ④ 清空 Eden 与 From Survivor，From/To 角色互换
 ⑤ 若老年代空间不足（担保失败）──► 触发 Full GC
```

**`SurvivorRatio`**：`-XX:SurvivorRatio=8` 表示 `Eden : 单个 Survivor = 8 : 1`，即 `Eden : S0 : S1 = 8 : 1 : 1`。**分母是"单个 Survivor"而不是两个之和** —— 最常见的算错点。设 `-Xmn100m`，则 Eden = 80 MB，S0 = S1 = 10 MB。

**空间分配担保（handle promotion failure）**：Minor GC 前 JVM 预估"最坏情况有多少对象会晋升"（取历次晋升的平均大小），检查老年代剩余连续空间是否够。不够时，若允许冒险（JDK 6u24 后默认开启担保）仍试一次 Minor GC，让 Survivor 放不下的对象直接进老年代；冒险失败则退化为 Full GC。

### 32.6.1 对象晋升老年代的四种触发条件

| 条件 | 参数 | 说明 |
|---|---|---|
| 年龄达到阈值 | `-XX:MaxTenuringThreshold`，默认 **15** | 每熬过一次 Minor GC 年龄 +1；15 是 4 bit 的极限（对象头年龄字段占 4 位），调大于 15 不生效 |
| **动态年龄判定（dynamic age determination）** | 无独立参数 | `Survivor` 中**相同年龄所有对象大小之和 > 单个 Survivor 的一半**时，年龄 ≥ 该年龄的对象**全部**直接晋升，不必等 15 |
| 大对象直接进老年代 | `-XX:PretenureSizeThreshold`，默认 0（不启用） | 只对 `Serial` / `ParNew` 有效，`Parallel Scavenge` 不认这个参数；避免大对象在 Eden/Survivor 间反复复制 |
| `Survivor` 空间不足 | 无独立参数 | 由空间分配担保接管，放不下的进入老年代 |

> [!WARNING]
> 动态年龄判定最常被答成"年龄超过 Survivor 一半就晋升"。正确表述是**同类年龄对象的总大小超过 `Survivor` 空间的一半**，判定的是"空间占用"不是"年龄数值"。推论：即使 `MaxTenuringThreshold=15`，只要一批同龄对象集体占了 Survivor 的一半以上，它们可能在第 2、3 次 GC 就全部晋升 —— 这就是"对象提前晋升把小对象填进老年代"的经典成因。

## 32.7 三色标记法：漏标的两条件与两类解法
三色标记是并发标记（标记与用户线程同时跑，不全程 `STW`）的理论基础。先立两个视角：**收集器（collector）视角**只做两件事 —— 扫描灰色对象的引用（扫完变黑）、发现白色对象；**赋值器（mutator，用户线程）视角**也只做两件事 —— 插入新引用、删除旧引用。并发标记期间这两组动作同时发生：**白色**是尚未被访问到的对象（标记结束时仍是白色即判定为垃圾）；**灰色**是自己已被访问到、但它引用的对象还没扫描完（在待处理队列里）；**黑色**是自己及它引用的所有对象都已扫描完、不会被重新扫描。初始时 `GC Roots` 直接引用的对象置灰、其余全白；过程是"取一个灰 → 扫描其引用并把白引用置灰 → 自己置黑"，重复到灰色为空为止。

### 32.7.1 漏标（对象消失）的两个条件

并发标记期间赋值器可能"破坏"标记正确性，让一个**实际存活**的对象留在白色被误回收。漏标必须**同时**满足两条：① 赋值器插入了一条或多条从**黑色对象到白色对象的新引用**（`A.ptr = C`，而 `A` 已黑、收集器不会再扫它）；② 赋值器删除了**全部**从灰色对象到该白色对象的直接或间接引用（`B.ptr = null`，`B` 是 `C` 唯一的"灰→白"通路）。结果 `C` 既无"灰→白"通路、也不被黑对象扫描，收集器永远访问不到它 —— 它是存活的，却在标记结束时仍是白色，于是被回收，程序崩溃。**两条件缺一不可**：只满足 ①（A 插入指向 C 的新引用）时，C 仍被灰色 B 引用，扫到 B 会把 C 置灰，不漏标；只满足 ②（B 删掉指向 C 的引用）时，C 成了孤岛（既不被黑对象也不被灰对象引用），它本来就是垃圾，回收它是**正确**的。

### 32.7.2 两类解法：增量更新 vs 原始快照

思路只有两条：**要么禁止 ① 发生，要么不许 ② 造成信息丢失**。

| 方案 | 破坏哪个条件 | 手段 | 写屏障 | 收集器 | 代价 |
|---|---|---|---|---|---|
| 增量更新（incremental update） | 破坏 ①：新增的"黑→白"引用被记录 | 黑色对象插入指向白色的新引用时，把该**黑色对象重新置灰**，留到重新标记阶段重扫 | **写后屏障（post-write barrier）** | CMS | 重新标记阶段需重扫被记录的黑色对象，`STW` 变长；记录集可能很大 |
| 原始快照（snapshot-at-the-beginning, SATB） | 破坏 ②：删除的"灰→白"引用被记录 | 灰色对象**删除**指向白色的引用时，记录被删的**旧引用**，认为它在标记开始那一刻的快照里还活着，按快照继续标 | **写前屏障（pre-write barrier）** | G1、Shenandoah | 产生**浮动垃圾（floating garbage）**：标记期间死掉的对象被当作存活，本轮不回收，留到下一轮 |

一句话钉住两者：**增量更新 = "你把新人介绍进来了，那就重新审你一遍"（记新增）；SATB = "照片拍下时谁在就是谁，哪怕现在他走了"（记删除）。**

> [!IMPORTANT]
> 标准答法是三段：**先给两条件（新增黑→白 + 删除灰→白的唯一通路），再说两解法各破坏哪一个，最后说屏障类型与代价（增量更新是写后屏障 + 重新标记变长；SATB 是写前屏障 + 浮动垃圾）。** 只答"CMS 用增量更新、G1 用 SATB"而说不出破坏哪个条件，是最常见的失分位置。

## 32.8 CMS：第一款并发收集器

`CMS`（Concurrent Mark Sweep，`-XX:+UseConcMarkSweepGC`）基于**标记-清除**，目标是降低停顿。它的四个阶段是：**① 初始标记（`STW`）** 仅标记 `GC Roots` 直接关联的对象，极快；**② 并发标记**从①的结果出发遍历整个对象图，最耗时但与用户线程并发；**③ 重新标记（`STW`）** 修正②期间用户线程继续运行产生的变动，即增量更新记录下来的那些对象；**④ 并发清除**清除白色对象。**只有 ① 与 ③ 会 `STW`**，这就是 CMS 压低停顿的机制。

**`Concurrent Mode Failure`（并发模式失败）**：阶段 ④ 并发清除期间应用线程仍在分配与晋升；若老年代被填满而清理未完成，CMS 无法再并发下去 —— 后果是**退化为 `Serial Old` 做一次单线程标记-整理**，停顿可能到秒级，这是 CMS 最严重的性能事故。相关参数：

- `-XX:CMSInitiatingOccupancyFraction`：老年代占用达到该百分比时启动 CMS，**默认约 92%**（JDK 5 之前是 68%）。调大 → 启动晚、GC 次数少，但**更容易 `Concurrent Mode Failure`**；调小 → 更安全但更频繁、吞吐下降。纯权衡，无普适值。
- `-XX:+UseCMSInitiatingOccupancyOnly`：只用固定值触发，不让 JVM 按历史自适应（默认自适应，常导致"明明设了 70% 却迟迟不 GC"）。
- `-XX:+CMSScavengeBeforeRemark`：重新标记前先做一次 Young GC，减少要扫描的对象。

**三个缺点**：**内存碎片**（标记-清除不压缩，碎片严重时大对象分配失败被迫 `Full GC` 整理）、**浮动垃圾**（并发清除期间新产生的垃圾本轮收不掉）、**CPU 敏感**（并发阶段占线程，默认回收线程数 `(CPU 核数 + 3) / 4`，2 核机器上占掉 1/4 以上算力）。

**JDK 14 移除 CMS（JEP 363）**的核心原因：CMS 是 G1 的前身，两套并发标记代码长期维护成本高；CMS 的并发清除依赖"整堆"假设，无法适应 G1 的 `Region` 化结构；G1/ZGC 已在延迟与可维护性上全面替代它。**不含 CMS 的第一个版本是 JDK 14**。

## 32.9 G1：分 Region 的增量式收集器

`G1`（Garbage-First）从 **JDK 9 起成为服务端默认收集器**（JEP 248），核心创新是把"物理连续的分代"改成"**逻辑分代 + 物理分块**"：堆被切成约 2048 个 `Region`（`-XX:G1HeapRegionSize` 1~32 MB，必须是 2 的幂），每个 `Region` 动态扮演 Eden、Survivor、Old 或 `Humongous` 角色，回收时只选一部分组成 CSet（Collection Set）。

```
 约 2048 个 Region：E=Eden  S=Survivor  O=Old  H=Humongous
 ┌────┬────┬────┬────┬────┬────┐
 │ E  │ S  │ O  │ O  │ H  │ 空 │  Region 角色可动态改变；
 └────┴────┴────┴────┴────┴────┘  CSet = 本次要回收的 Region 集合
```

- **逻辑分代**：Eden/Survivor/Old 只是 `Region` 的角色标签，**不需要物理连续**，因此 G1 能"每次只回收一部分老年代 `Region`"。**`Humongous` 对象**（大小 ≥ 单个 `Region` 一半，≥ 一个 `Region` 则占连续多个）直接放入 `Humongous Region`，大对象多时是 G1 的常见痛点。
- **`Remembered Set`（RSet）**：每个 `Region` 一份，记录"**谁引用了我**"（points-into 结构）。这是与 `Card Table` 的关键差异：卡表是全局的，回答"哪些卡脏了"；RSet 是 **per-Region** 的，回答"回收我这块时要去扫哪些别的 `Region`"。底层仍用卡表做粗粒度记录，RSet 是其上层的 per-Region 索引。G1 的并发标记用原始快照（见 32.7.2），因此会产生浮动垃圾 —— 这也解释了 `-XX:G1HeapWastePercent`（允许浪费的堆比例，默认 5%）的存在。

### 32.9.1 Young GC、Mixed GC 与 `MaxGCPauseMillis`

| 类型 | 回收范围 | 触发 | 停顿特征 |
|---|---|---|---|
| Young GC | 全部 Eden + 部分 Survivor | Eden 的 `Region` 用尽 | 几毫秒到几十毫秒 |
| Mixed GC | 全部 Young `Region` + **并发标记选出的、回收价值最高的部分 Old `Region`** | 并发标记周期完成，且老年代占用达 `-XX:InitiatingHeapOccupancyPercent`（IHOP，默认 **45%**） | 比 Young GC 长，由 `MaxGCPauseMillis` 约束 |
| Full GC | 整堆 | 见下文 | 单/多线程整理，可能秒级 —— **G1 下最需要避免** |

**`-XX:MaxGCPauseMillis`（默认 200 ms）不是"设多少就停多少"，而是软目标**：G1 基于历史回收数据（每个 `Region` 的回收耗时、存活比例）估算，在暂停预算内挑选**回收收益最高**的 `Region` 组成 CSet —— 它求解的是"在时间预算内最大化收益"的选择问题，这就是 Garbage-First 名字的实质。

| 参数 | 默认 | 作用 |
|---|---|---|
| `-XX:G1MixedGCLiveThresholdPercent` | 85（JDK 8 为 65） | 存活对象占比高于此值的 Old `Region` **不纳入** Mixed GC（复制成本划不来） |
| `-XX:G1HeapWastePercent` / `-XX:G1MixedGCCountTarget` | 5 / 8 | 可容忍的浮动垃圾占比（低于此值停止 Mixed GC）；一次并发标记周期最多分几次 Mixed GC 收完候选 `Region` |
| `-XX:InitiatingHeapOccupancyPercent` | 45 | 老年代占用超此比例即启动并发标记周期 |

**G1 下 `Full GC` 的触发条件与排查方向**：① 并发标记来不及（分配速率远高于回收速率，日志出现 `to-space exhausted`）；② `Humongous` 对象分配失败（需连续多个 `Region`，碎片化后找不到）；③ 晋升失败（promotion failed）；④ 显式 `System.gc()`（未开 `-XX:+ExplicitGCInvokesConcurrent` 时）。排查看 GC 日志中 `to-space exhausted` / `Humongous` / `Pause Full` 关键词，用 `jmap -histo` 找大对象来源（常见是超大数组、一次性加载的大集合）；对策是调小 `-XX:G1HeapRegionSize` 让大对象不再 `Humongous`、降低 IHOP 让并发标记更早开始。

> [!NOTE]
> 面试官常追问的下一层是"G1 为什么还叫 Garbage-First？" —— 答案不在名字而在 `MaxGCPauseMillis` 的实现：它把"整堆回收"变成**在停顿预算内求解最大收益子集**的问题。这也解释了它为什么不能保证停顿（只能统计意义上收敛），以及为什么超高实时要求要交给 `ZGC`。

## 32.10 ZGC 与 Shenandoah：把停顿与堆大小解耦

`ZGC`（JEP 333 起实验，JDK 15 转正）与 `Shenandoah`（JDK 15 转正）的目标是 **`STW` 停顿不超过 10 ms，且停顿时间与堆大小、存活对象数量无关**。

**着色指针（colored pointers）**是 `ZGC` 的核心：64 位指针中实际寻址只用低 42 位（支持 4 TB 堆），高 4 位被借用来存标记状态（`Marked0`/`Marked1` 两套标记位交替以区分本轮与上轮标记、`Remapped` 指向重定位后的新地址、`Finalizable` 仅终结器可达）。读指针时先看状态位决定是否需要修复指针 —— 这一步由**读屏障（load barrier）**完成：它在**每次从堆读引用**时插入，检查指针状态，若指向"正在被转移"的旧地址就当场修正到新地址（"自愈"）。**写屏障（store barrier）**则在写引用时记录，用于维护并发转移所需的映射表。

为什么停顿与堆大小无关：对象转移（复制）是**并发**做的，`STW` 阶段只做极短的初始标记/再标记这类只与 `GC Roots`（而非堆大小）相关的操作；转移期间应用线程仍可能读到旧地址，而读屏障负责把"读旧地址"变正确，因此不需要停下所有线程。**主要阶段**：并发标记 → 并发预备重定位（选择要转移的 `Region`）→ 并发重定位 → 并发重映射，全程只有极短暂停点。**分代 `ZGC`（JEP 439，JDK 21 引入，实验特性）**：此前 `ZGC` **非分代**，每轮都要处理整个堆，分配速率高时频繁触发 GC、对短命对象不友好；引入 Young/Old 两代后**降低 GC 频率与内存占用**并**提高分配吞吐**。

**代价**：**吞吐量下降**（读屏障在每次读引用时执行，是"逐次访问都要付"的成本，实测吞吐一般比 G1 低一档）、**内存放大（memory amplification）**（并发转移需同时保留新旧两份空间）、**需要更大堆**（大堆下才体现收益）。`-XX:SoftMaxHeapSize` 是软上限：正常情况下尽量不超此值（倾向更频繁 GC 而非扩堆），应急时才允许涨到 `-Xmx`，用于在延迟与内存占用间取舍。

## 32.11 GC 选型决策表与版本默认值

判断维度只有三个：**堆大小、延迟要求、吞吐要求**，三者不可能同时最优。

| 场景 | 堆大小 | 延迟要求 | 吞吐要求 | 选择 | 理由 |
|---|---|---|---|---|---|
| 小内存服务/批处理/单机工具 | < 4 GB | 不敏感 | 高 | `Serial` / `Parallel` | 少量线程、`STW` 短、无并发开销 |
| 主流后端服务（JDK 8） | 4~16 GB | 百毫秒级 | 高 | `Parallel Scavenge` + `Parallel Old`（默认）或 `CMS` | `Parallel` 吞吐最高；`CMS` 换更短停顿但付碎片代价 |
| 主流后端服务（JDK 9+） | 4~32 GB | 200 ms 级 | 较高 | `G1`（默认） | 停顿可预测、参数少 |
| 大堆 + 低延迟 | 16~64 GB | 几十毫秒 | 中 | `G1` 调优（`MaxGCPauseMillis`/IHOP）或 `ZGC` | 堆越大 G1 的并发标记越难跑赢分配速率，转 `ZGC` |
| 超大堆 + 极低延迟 | ≥ 32 GB（`ZGC` 优势区） | < 10 ms | 可让步 | `ZGC` | 停顿与堆大小解耦，代价是吞吐与内存放大 |
| 低延迟且不便换版本 | 中等 | < 10 ms | 中 | `Shenandoah` | 同样靠并发转移，屏障实现路径不同 |

| 版本 | 服务端默认收集器 | 备注 |
|---|---|---|
| JDK 8 | `Parallel Scavenge` + `Parallel Old` | 永久代仍在；`CMS` 可用但需显式开启 |
| JDK 9 ~ 13 | `G1`（JEP 248） | 元空间取代永久代；`CMS` 仍可用 |
| JDK 14 / 15 | `G1` | **CMS 被移除**（JEP 363）；`ZGC`（JEP 377）与 `Shenandoah`（JEP 379）转正 |
| JDK 21 | `G1` | 分代 `ZGC` 作为实验特性引入（JEP 439） |

> [!WARNING]
> 常见错误回答："JDK 8 默认 CMS"（错，默认 `Parallel`，`CMS` 要 `-XX:+UseConcMarkSweepGC` 打开）、"ZGC 在 JDK 11 就能上生产"（JDK 11 引入时为实验特性，JDK 15 才转正）、"JDK 17 默认 ZGC"（默认仍是 `G1`）。版本与默认值是硬事实，答错会被判定为背书而非理解。

## 32.12 八种 `OutOfMemoryError` 与 `StackOverflowError`

| # | 错误信息 | 触发位置 | 首要排查方向 |
|---|---|---|---|
| 1 | `Java heap space` | 堆：对象太多或单个对象太大 | `jmap -histo:live <pid>` 看实例数 TOP；`-XX:+HeapDumpOnOutOfMemoryError` 抓 dump 用 MAT 看支配树 |
| 2 | `GC overhead limit exceeded` | 堆：GC 耗时占比过高 | 默认 GC 时间超过 **98%** 且回收不到 **2%** 堆时抛出，本质是"堆太小/泄漏"；查长生命周期集合是否持续增长 |
| 3 | `Metaspace` / `PermGen space` | 元空间（JDK 8+）/ 永久代（JDK 7 及以前） | 类加载器泄漏（动态生成类、热部署、`CGLIB`/`Groovy` 反复生成）；`jmap -clstats` 看类加载器数量 |
| 4 | `unable to create new native thread` | 线程创建：栈内存或线程数上限 | `ulimit -u`（进程数）、cgroup `pids.max`、`-Xss` 过大导致每线程栈占用过多；`jstack` 数线程数 |
| 5 | `Direct buffer memory` | 直接内存（堆外） | `ByteBuffer.allocateDirect` 未释放或未走 `Cleaner`；查 `-XX:MaxDirectMemorySize`；NMT 看 `Internal`/`Other` |
| 6 | `Requested array size exceeds VM limit` | 数组分配：请求长度超 JVM 上限 | 长度接近 `Integer.MAX_VALUE`；通常伴随整数溢出，是计算错误，先查业务算法 |
| 7 | `Compressed class space` | 元空间中存放类元数据指针的部分 | 由 `-XX:CompressedClassSpaceSize`（默认 1 GB）限制，与 `Metaspace` 报错常成对出现 |

**`StackOverflowError` 与它们的区别**：前者是**栈深度**耗尽（一般是无限递归），发生在虚拟机栈；后者是**内存无法满足分配**。两者也可能互相引发：栈溢出时若栈可扩展而内存不足，HotSpot 报的是 `OutOfMemoryError`。

```
 症状                     第一手段                              第二手段
 堆 OOM                   -XX:+HeapDumpOnOutOfMemoryError      jmap -histo:live / MAT 支配树
 疑似泄漏但没 OOM         jmap -histo:live 隔 10 min 对比       jcmd <pid> GC.heap_info
 GC 频繁、吞吐下滑        -Xlog:gc*（JDK 9+）看频率与停顿       -XX:+PrintGCDetails（JDK 8）
 堆外 / 元空间增长        -XX:NativeMemoryTracking=detail      jcmd <pid> VM.native_memory summary
 线程创建失败             jstack <pid> | grep -c '^"'          ulimit -u / cat /proc/<pid>/limits
 类加载器泄漏             jmap -clstats <pid>                  jcmd <pid> GC.class_stats
```

> [!TIP]
> OOM 排查话术按"**先分类（哪个区域）→ 再取证据（dump / NMT / jstack）→ 最后定性（泄漏 or 容量不足）**"三步说，比罗列工具清单得分高。区分两者靠**隔一段时间取两次 `jmap -histo` 对比增长**：持续单向增长是泄漏，稳定在高位是容量不足。

## 本章小结

| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| 元空间阈值 | `-XX:MetaspaceSize` 是首次 GC 阈值，不是初始大小；`MaxMetaspaceSize` 不设则无上限 | 答成"元空间初始大小" |
| TLAB 的动机 | 把"分配指针平移"从需要 CAS 的共享操作变成线程内操作 | 答成"加快对象初始化" |
| 对象位置 | 开启逃逸分析后，不逃逸对象可被标量替换为栈上标量，"一定在堆上"不严格 | 断言"对象一定在堆上" |
| `GC Roots` | 栈中引用、JNI 引用、静态属性、常量、活跃线程、锁对象、`Class`、系统类加载器 | 只答"栈和静态变量" |
| 四种引用 | 强（不可回收）、软（内存不足时回收）、弱（下次 GC 必回收）、虚（仅回收通知） | 认为弱引用能解决 `ThreadLocalMap` 的 value 泄漏 |
| 三色标记漏标 | 需同时满足"黑→白新增引用"与"灰→白引用全删"；增量更新破坏前者，SATB 破坏后者 | 只记结论说不出条件，或答"SATB 没有代价" |
| `Concurrent Mode Failure` | 并发清除期间老年代被填满 → 退化 `Serial Old` 单线程整理 | 以为它只是"CMS 跑得慢" |
| 动态年龄判定 | 同年龄对象总大小 > 单个 `Survivor` 一半即整体晋升 | 答成"年龄超过一半" |
| G1 的 Full GC | `to-space exhausted` / `Humongous` 失败 / 晋升失败 | 认为 G1 不会 Full GC |

## 自测题

**Q1**（推导）某服务启动参数为 `-Xmn100m -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15`，但线上观测到大量对象在第 2~3 次 Minor GC 就进入老年代。请给出至少两个可能原因，并说明如何验证。

<details><summary>答案与解析</summary>

原因一：**动态年龄判定**。此时 Eden = 80 MB、单个 Survivor = 10 MB，动态年龄判定只要求"Survivor 中**同年龄**对象总大小超过 **5 MB**（一半）"，则年龄 ≥ 该年龄的对象整体晋升；在 10 MB 的小 Survivor 里 5 MB 极易达到，15 这个阈值根本轮不到生效。验证：`-Xlog:gc+age=trace`（JDK 9+）或 `-XX:+PrintTenuringDistribution`（JDK 8）看每次 GC 后各年龄分布，能直接看到"某年龄总字节数超阈值 → 全部晋升"。

原因二：**`Survivor` 空间不足 + 空间分配担保**。若某次 GC 存活对象超过 10 MB，To Survivor 放不下的部分直接进老年代。验证：GC 日志里 `Desired survivor size` 与实际晋升字节数对比。

错误答案的典型形态是"因为 Survivor 太小所以提前晋升" —— 只答了原因二，漏掉更隐蔽的动态年龄判定。面试官追问路径：先问"Eden、S0、S1 各多大"（考分母是单个 Survivor），再问"动态年龄判定的条件是什么"（考是空间总和不是年龄数值），最后问"怎么验证"。

</details>

**Q2**（推导）初始状态 `A`（黑，已被收集器扫描完）、`B`（灰，尚未扫描）、`B → C`（白）。赋值器依次执行 ① `A.f = C`；② `B.f = null`。判断是否漏标，并说明两类解法各自怎么救。

<details><summary>答案与解析</summary>

**发生漏标**，两条件同时满足：① 黑色对象 `A` 到白色对象 `C` 插入了新引用，而 `A` 已黑不会再被扫描；② 灰色对象 `B` 删除了到 `C` 的引用，且 `B → C` 是 `C` 唯一的"灰→白"通路。结果 `C` 既不被黑对象也不被灰对象扫描，标记结束时仍是白色被判为垃圾 —— 但 `A.f` 仍指向它，程序会读到已回收对象。

去掉任一条都不漏：只做 ① 时，收集器扫 `B` 会把 `C` 置灰；只做 ② 时，`C` 是真正的孤岛垃圾，回收正确。两类解法：**增量更新破坏 ①** —— 把 `A` 重新置灰留到重新标记阶段重扫（CMS，写后屏障）；**SATB 破坏 ②** —— 在 `B.f = null` 之前把旧引用 `B → C` 记录下来，认为 `C` 在快照里存活（G1，写前屏障）。面试官会继续追问"记录下来的东西**怎么用**"：增量更新把黑色对象**重新置灰**重扫（延长 `STW`），SATB 把被删的旧引用**当作存活**（产生浮动垃圾）。

</details>

**Q3**（辨析）同事说："既然 `ThreadLocalMap` 的 `Entry` 的 key 是弱引用，`ThreadLocal` 用完不清 `remove()` 也不会泄漏。"请指出错在哪并画出引用链。

<details><summary>答案与解析</summary>

这句话只对了"key 会被回收"，错在 **value 的引用链从未被切断**：`Entry` 继承 `WeakReference`，弱引用只作用在 **key** 上，于是引用链是 `Thread → ThreadLocalMap → Entry[] table`，而 `Entry.key --弱引用--> ThreadLocal`（外部置空后下次 GC 即回收，key 变 null）、`Entry.value --强引用--> 业务对象`。`ThreadLocal` 被回收后 `Entry` 就变成"key 为 null 但 value 仍被强引用"的状态，value 一直被 `table` 数组引用、`table` 又被 `Thread` 引用。**线程池**场景下线程几乎不结束，value 永不回收 —— 这就是典型的 `ThreadLocal` 内存泄漏。`ThreadLocalMap` 的补偿机制（`get`/`set` 时顺带清理 key 为 null 的 `Entry`，即 `expungeStaleEntry`）**只在恰好访问到该 `ThreadLocal` 时才触发**，覆盖不了"它再也不被使用"的场景，所以**必须在 `finally` 里 `remove()`**。

面试官追问方向：为什么 key 用弱引用而不是强引用？（强引用下连 key 都无法回收，泄漏范围更大）；`ThreadLocalMap` 的哈希冲突怎么解决？（开放地址法 + 线性探测，而非 `HashMap` 的链表/红黑树）。详见第 30 章 30.6 节。
</details>

**Q4**（设计）服务部署在 8 GB 堆、JDK 17，要求 P99 GC 停顿不超过 50 ms，吞吐不能明显下降。运维建议直接上 `ZGC`，你如何回应？给出方案与参数。

<details><summary>答案与解析</summary>

**不应直接上 `ZGC`。** 8 GB 属 `G1` 舒适区，而 `ZGC` 的读屏障给每次引用读增加成本（吞吐下降一档），并发转移还需额外空间（内存放大约 15%~20%），8 GB 上收益不明显。方案：**先用 `G1` 调优，压不住再评估 `ZGC`**。

- `-XX:MaxGCPauseMillis=50`：把停顿目标写进预测模型。它是软目标，G1 会为此**减少每次回收的 `Region` 数量**，代价是 GC 更频繁 —— 这是显式的"吞吐换延迟"。
- `-XX:InitiatingHeapOccupancyPercent=35`（默认 45）：更早启动并发标记周期，避免 Mixed GC 来不及导致 `to-space exhausted` 进而 Full GC。
- `-XX:G1HeapRegionSize` / `-XX:+ParallelRefProcEnabled`：把 `Region` 调大使大对象不再算 `Humongous`（默认由堆大小推导，8 GB 堆约 4 MB）；并行处理引用入队以缩短重新标记。

分步验证：用 `-Xlog:gc*:file=gc.log:time,uptime,level,tags` 看停顿分布，确认超时的是 Young GC 还是 Mixed GC、有无 `Pause Full`。若调优后 P99 仍超 50 ms 且**堆继续增长到 32 GB 以上**，再评估 `ZGC`（`-XX:+UseZGC`，可配 `-XX:SoftMaxHeapSize` 留出余量）。

错误答案形态：① 直接说"用 ZGC，它停顿最低"—— 忽略吞吐与内存代价，也忽略 G1 在 8 GB 的适用性；② 只设 `-XX:MaxGCPauseMillis=50` 就以为能达标 —— 不懂它是软目标，G1 只能靠减少回收 `Region` 来逼近。

</details>

**Q5**（判断）逐条判断：① "`-XX:MetaspaceSize=256m` 会让元空间启动时就占 256 MB"；② "JDK 8 把字符串常量池从永久代移到了堆"；③ "标记-整理必须 `STW`，而标记-清除不需要"。

<details><summary>答案与解析</summary>

① **错**。它设定的是**首次触发元空间 GC 的高水位阈值**，元空间仍从接近 0 按需分配、随类加载增长。实际效果是把首次元空间 GC 推迟到用量达 256 MB 时，减少启动期 Full GC。理解成初始大小会导致错误预期。

② **错，版本记错了**。把**字符串常量池与静态变量**移入堆的是 **JDK 7**；**JDK 8** 做的是用**元空间替换永久代**（类的元信息搬到本地内存）。JDK 6 时字符串常量池确实还在永久代。这个"两次搬家"必须分清，因为面试官会接着问"JDK 7 为什么先搬字符串常量池？"（永久代容量受限且回收效率低，`intern` 字符串是最容易撑爆它的数据）。

③ **前半对、后半错**。标记-整理要移动对象，移动中地址在变，赋值器不可能读到一致引用，所以必须 `STW`。标记-清除**不移动对象**，其清除阶段可以并发 —— 但 `CMS` 的初始标记与重新标记仍 `STW`（根扫描与增量更新修正必须停下），只有并发标记与并发清除阶段并发。正确说法是"**清除阶段不需移动对象，因此可以并发**"。

面试官追问方向：`CMS` 既然能并发清除，为什么还会有 `Concurrent Mode Failure`？（并发期间应用仍在分配与晋升，老年代可能先被填满，只能退化 `Serial Old` 做单线程整理）。

</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| `-XX:MetaspaceSize` 到底是什么意思？把它调大有什么效果？ | 参数语义 | 错误回答："元空间的初始大小，调大就是启动时多占内存"。正确：它是首次元空间 GC 的**阈值**，调大等于推迟首次 GC、减少启动期 Full GC | 32.2 |
| 为什么 JDK 8 要用元空间替换永久代？JDK 7 又做了什么？ | 版本迁移因果 | 错误回答：只答"永久代容易 OOM"却说不出为什么容易（容量硬限制、只在 Full GC 回收、类卸载条件苛刻），且把 JDK 7 的字符串常量池搬家混进 JDK 8 | 32.2 |
| 分代收集为什么还需要 `Card Table`？它解决的是什么问题？ | 跨代引用 | 错误回答："为了记录对象年龄"。正确：为了让 Young GC 不必扫描整个老年代，只扫脏卡；配套机制是写屏障置脏 | 32.5.2 |
| 什么样的对象会直接进入老年代？ | 晋升路径 | 错误回答：只答"年龄到 15"。漏掉动态年龄判定、大对象（`PretenureSizeThreshold`，且只对 `Serial`/`ParNew` 有效）、Survivor 不足三种；且把动态年龄判定说成"年龄超过一半" | 32.6.1 |
| 为什么 CMS 会出现 `Concurrent Mode Failure`？后果是什么？ | 并发收集器固有缺陷 | 错误回答："CMS 的 bug"或"内存不够了"。正确：并发清除期间应用继续分配晋升、老年代被填满 → 退化 `Serial Old` 单线程整理，停顿剧增；靠 `CMSInitiatingOccupancyFraction` 提前启动缓解 | 32.8 |
| 三色标记为什么会漏标？增量更新和 SATB 各是怎么解决的？ | 并发标记理论基础 | 错误回答："漏标是因为并发执行"（等于没答）。必须说出两个条件，并指出增量更新破坏"新增黑→白"、SATB 破坏"删除灰→白"，以及写后/写前屏障与代价（重扫 vs 浮动垃圾） | 32.7 |
| G1 什么时候会退化成 Full GC？怎么排查？ | G1 调优实战 | 错误回答："G1 不会 Full GC"。正确：`to-space exhausted`、`Humongous` 失败、晋升失败、显式 `System.gc()` 四种；排查看 GC 日志关键词与 `jmap -histo` 找大对象 | 32.9.1 |
| `ZGC` 凭什么做到停顿与堆大小无关？代价是什么？ | 着色指针与屏障 | 错误回答："因为它是并发的"（所有并发收集器都并发，不足以解释）。必须提到着色指针 + 读屏障 + 并发转移，代价是吞吐下降与内存放大 | 32.10 |
| JDK 8 的默认垃圾收集器是什么？JDK 17 呢？ | 版本默认值 | 错误回答："JDK 8 默认 CMS"（默认 `Parallel Scavenge + Parallel Old`，`CMS` 要显式开启）；"ZGC 从 JDK 11 起可用于生产"（JDK 11 为实验，JDK 15 转正） | 32.11 |
| 遇到 `OutOfMemoryError: unable to create new native thread` 你怎么定位？ | OOM 分类排查 | 错误回答："堆内存不够，加 `-Xmx`"（与堆无关）。正确：查 `ulimit -u`、cgroup `pids.max`、`-Xss` 是否过大，用 `jstack` 数线程数 | 32.12 |

## 延伸阅读

- JVMS 第 2 章 *The Structure of the Java Virtual Machine*：2.5 运行时数据区、2.6 栈帧；JLS 第 17 章 *Threads and Locks*；
- JEP 122（Remove the Permanent Generation）、JEP 248（Make G1 the Default GC）、JEP 363（Remove CMS）、JEP 333/377（ZGC）、JEP 439（Generational ZGC）；
- Oracle *HotSpot Virtual Machine Garbage Collection Tuning Guide*：`G1`/`ZGC` 调优章节与 `NativeMemoryTracking` 用法；
- OpenJDK 源码：`src/hotspot/share/gc/shared/cardTable.hpp`（卡表）、`gc/g1/g1RemSet.hpp`（RSet）、`gc/shared/markBitMap` 与三色标记实现；
- 《深入理解 Java 虚拟机》第 3 版第 2、3 章；另见第 12 章 12.4 节（从 Linux 侧用 `pmap`/`smaps`/NMT 对照观测 JVM 内存占用）。


---

> 作者: [破戒僧](https://onlyblacktea.cn)  
> URL: https://onlyblacktea.cn/book/backend/ch32/  

