# JVM内存结构与OOM排查实战

> **技术基线**：64 位 HotSpot **JDK 8**，以 Linux / Docker / Kubernetes 服务为例；在末尾说明 JDK 11/17+ 的主要差别。
> **内容范围**：通过 **23 张可缩放 SVG 图（结构图、流程图、内存趋势图）**、诊断命令、判读逻辑、典型生产案例和应急清单，梳理 JVM 内存与 OOM 排查。
> **核心认识**：**Java 进程内存 ≠ Java 堆；OOM ≠ 一定有内存泄漏；排查要先识别“哪一种内存”出了问题。**

## 1. 开场：我们究竟要排查什么

**先从三个常见问题入手：**

- Java 应用的 `-Xmx` 配成 3G，容器限制 4G，为什么还会被 `OOMKilled`？
- GC 已经执行过很多次，为什么还会报 `java.lang.OutOfMemoryError: Java heap space`？
- Heap Dump 看着没问题，为什么网关进程 RSS 一直涨，甚至出现 `OutOfDirectMemoryError`？

这三个问题能直接暴露一个常见误区：把 **JVM 运行时内存**、**HotSpot 原生内存**、**操作系统实际驻留内存**混成了一个概念。

![图 01：Java 进程的内存组成](../assets/jvm-memory-structure-oom/diagrams/01-进程总内存.svg)

**分析要点：** `-Xmx` 只约束 Java 堆上限，并不等于进程内存上限。Metaspace、线程栈、Direct Buffer、Code Cache、JVM 自身的 GC 数据结构、JNI 分配等，也需要资源。图中的 RSS 只是为了说明不同来源都可能影响进程驻留量；真实 RSS 与各分项之间可能有共享页、已保留但未提交、已提交但未驻留等差异，不能直接算术相加。

### 1.1 先区分三个指标

| 指标 | 含义 | 常见误区 |
|---|---|---|
| `reserved` | 保留的虚拟地址空间 | 保留 ≠ 实际使用了物理内存 |
| `committed` | 已提交、可供使用的虚拟内存 | 提交 ≠ 该时刻全部页都驻留在 RSS |
| `RSS` | 进程实际驻留物理页规模 | RSS ≠ 堆已使用，也不等同于容器全部内存用量 |

在容器中还需要区分 **进程 RSS**、**`memory.current` / cgroup 使用量**和**内存限制**。容器内存账单可能包含其下进程、文件页缓存等，具体由 cgroup 版本与统计口径决定。

## 2. JVM 内存结构总览

### 2.1 先看规范，再看 HotSpot 实现

![图 02：JVM 运行时数据区](../assets/jvm-memory-structure-oom/diagrams/02-JVM运行时区域.svg)

**线程共享：**

- **Java Heap（堆）**：主要存放普通对象和数组，GC 的核心管理对象。这里说的是“主要”，因为逃逸分析、标量替换、JIT 优化可能使部分对象分配被消除。
- **方法区（Method Area）**：JVM 规范中的**逻辑概念**，包含类型信息、运行时常量池等。**HotSpot JDK 8 用 Metaspace 等机制实现类元数据存储**，不要把“方法区”与某一个具体物理内存块完全画等号。
- **运行时常量池**：与类关联的常量、符号引用等；不要与 `String.intern()` 的字符串实例存放位置混为一谈。JDK 7 起 HotSpot 的字符串常量池相关对象已主要置于 Java 堆。

**线程私有：**

- **程序计数器**：跟踪线程字节码执行位置。
- **Java 虚拟机栈**：每一次方法调用都会压入栈帧，方法完成后栈帧弹出。
- **本地方法栈**：支持 native 方法调用；HotSpot 的实现细节并非必须与 Java 栈形成两个独立物理存储区。

**规范之外但故障时尤其关键：** Direct Buffer、JNI/native malloc、Code Cache、线程实际栈内存、内存映射、分配器缓存等。

### 2.2 栈帧里放什么？“对象都在栈上”对不对？

![图 03：栈帧与堆对象之间的关系](../assets/jvm-memory-structure-oom/diagrams/03-栈帧与对象.svg)

考虑以下代码：

```java
public void foo(int id) {
    User user = new User();
    user.setId(id);
    consume(user);
}
```

`id`、局部变量 `user`（对象引用）可以存在当前栈帧局部变量表中，而 `new User()` 通常在堆上分配。`user` 是一个**引用变量**，不是把整个 `User` 实例放进了栈帧。JIT 可能进一步优化，因此不要用源代码中的 `new` 数量直接等价于最终堆分配量。

**容易混淆的问题：**

- `StackOverflowError`：通常是调用深度、递归或栈空间问题；它不是堆 OOM。
- `OutOfMemoryError: unable to create new native thread`：通常属于无法再为线程创建本地资源，不代表某个 Java 栈帧“装满了”。
- **静态变量存什么？** 静态字段的值可能是对象引用；所引用的普通对象仍可在 Heap 上，其所属类的元数据另由类加载系统管理。

### 2.3 一个普通对象在 HotSpot 里大致长什么样

![图 04：对象头、字段和类元数据关联](../assets/jvm-memory-structure-oom/diagrams/04-对象内存布局.svg)

以常见 HotSpot 对象为例，可分为：**对象头**、**实例字段**、**对齐填充**。对象头包含 Mark Word 和类型指针等信息；数组对象还会包含数组长度。对象真实大小取决于 32/64 位、压缩指针、字段布局、对齐参数及 JDK 版本，**不要把图里的分区比例当成固定字节数**。

这张图的价值在于解释：为什么 Heap Dump 能看到上百万个 `User`、`byte[]`，而 Metaspace 追踪的是 `User` 这样的**类型元数据**，不是 100 万份 `User` 实例的全部内容。

### 2.4 JDK 8 的 PermGen / Metaspace 到底变了什么

| 维度 | JDK 7 及更早的典型 HotSpot | JDK 8 HotSpot |
|---|---|---|
| 类元数据主要存储 | 永久代 PermGen | 原生内存中的 Metaspace |
| 典型上限参数 | `-XX:MaxPermSize` | `-XX:MaxMetaspaceSize` |
| OOM 文本 | `PermGen space` | `Metaspace`、`Compressed class space` |
| 容易犯的错 | 认为 PermGen 就是规范的方法区本身 | 认为元空间无限、也不受容器内存约束 |

**结论：** 移除了 PermGen 不是“类不会引发 OOM”，只是换了一套内存分配与管理机制。

## 3. 堆中的对象如何诞生、存活和死亡

### 3.1 传统分代结构：Eden / Survivor / Old

![图 05：年轻代复制与老年代晋升](../assets/jvm-memory-structure-oom/diagrams/05-分代复制与晋升.svg)

教学时分成三步：

1. **分配**：绝大多数新生对象优先进入 Eden，线程可能使用 TLAB（Thread Local Allocation Buffer）来降低分配竞争。
2. **Young / Minor GC**：Eden 和存活的 Survivor 对象参与回收，存活对象复制到目标 Survivor 或晋升 Old。
3. **晋升**：年龄达到阈值、目标 Survivor 容量不足等情况，都可能影响晋升。不是“对象恰好经历 15 次 GC 才会进老年代”。

#### TLAB 是什么

**TLAB 在 Java Heap 里面**，不是 Direct Memory，也不是额外新增的一块内存区域。线程在 Eden 内领用小块独占分配缓冲区，便于通过指针碰撞快速分配对象。TLAB 用尽走慢路径，可能申请新 TLAB 或按分配策略处理。

### 3.2 `new` 发生时，并不必然先触发 GC

![图 07：对象分配路径及失败兜底](../assets/jvm-memory-structure-oom/diagrams/07-对象分配决策.svg)

这是一张**教学用近似决策图**，不是 HotSpot 某个版本的精确源码控制流：

- 优先走当前线程的快速分配路径；失败后尝试更慢的分配路径。
- 分配器、GC 策略、是否大对象、是否需要晋升，决定后续行为。
- 如果最终无法满足堆内分配，才会出现 `Java heap space`。
- 对 G1 而言，**Humongous** 对象走特殊路径，不能机械套用上面传统 Eden → Survivor 的路径。

### 3.3 G1 的 Region：不是每个 Region 里都含 Eden + Survivor + Old

![图 06：G1 Region 物理布局与逻辑角色](../assets/jvm-memory-structure-oom/diagrams/06-G1-Region布局.svg)

**重点要讲清楚：** G1 将整个 Heap 划成**多个等大小 Region**，每个 Region 在某个时刻对应一个角色（Eden、Survivor、Old、Free 或 Humongous 等），**不是在单个 Region 里面再划出一小块 Eden、一小块 Survivor 和 Old**。

- Region 大小通常为 **1MB—32MB 的 2 次幂**，由 JVM 结合堆规模选择，也可以检查 `-XX:G1HeapRegionSize`。
- 多个 Eden Region 不需要彼此连续；Old Region 也不必是堆末尾的一大片连续物理空间。
- **Humongous**：大小达到或超过 Region 一半的对象在 G1 中走特殊分配路径，可能需要多个连续 Region。大型 `byte[]`、压缩包、整包响应等场景值得注意。
- 图上 20 个 Region 只是帮助理解，真实 Region 数量通常更多。

**讨论题：** “G1 有没有年轻代和老年代？”——有，是**逻辑上的 Region 集合**，不是必须连续的两大物理区。

### 3.4 对象什么时候才会被回收？看 GC Roots

![图 08：可达性分析示意](../assets/jvm-memory-structure-oom/diagrams/08-可达性与GC-Roots.svg)

判断一个对象是否还活着，不取决于“业务还需不需要它”，而取决于它是否**从 GC Roots 可达**。常见根包括线程活动栈帧中的引用、静态字段可达链、JNI 引用、JVM 内部根等。

```java
private static final Map<String, byte[]> CACHE = new ConcurrentHashMap<>();

public void handle(String key) {
    CACHE.put(key, new byte[1024 * 1024]);
}
```

即便请求早已结束，只要 `CACHE` 继续持有这些条目，就不会因为“这个请求完成了”而自动回收。与此相对，只有短生命周期大对象导致的瞬时高水位，不一定是内存泄漏。

### 3.5 强、软、弱、虚引用有什么区别

| 类型 | 典型场景 | 回收理解 |
|---|---|---|
| 强引用 | 正常字段、集合成员 | 根可达时通常不会被回收 |
| `SoftReference` | 某些缓存策略 | 内存压力下可能清理；**不可当成可靠缓存淘汰方案** |
| `WeakReference` | 不阻止回收的索引、映射 | 仅弱可达对象在 GC 中可被清理 |
| `PhantomReference` | 配合 `ReferenceQueue` 做资源生命周期跟踪 | `get()` 不提供被引用对象；不等于直接释放 native 资源 |

**Netty 补充：** ByteBuf 的释放由引用计数决定；垃圾回收并不自动保证池化 ByteBuf 被正确归还。

## 4. GC：为什么回收之后还会 OOM

### 4.1 先理解四个术语

| 术语 | 重点 |
|---|---|
| Young / Minor GC | 主要收集年轻代，不是全堆扫描的同义词 |
| Mixed GC | G1 收集年轻代，并选取部分 Old Region 一起回收 |
| Full GC | 通常涉及全堆级别的停顿和回收；不同收集器的触发及行为不同 |
| STW（Stop The World） | 应用线程因 GC 的某些阶段暂停；并发标记不意味着全过程零 STW |

### 4.2 G1 的典型周期

![图 09：G1 Young、并发标记与 Mixed 周期](../assets/jvm-memory-structure-oom/diagrams/09-G1收集周期.svg)

图中先后顺序展示的是一次**典型**标记/回收周期，而不是每轮 Young GC 都必然马上开始一次标记。G1 通常经历：

1. 多次 Young GC，部分对象晋升 Old。
2. 达到条件后，以一次 Young GC 搭载 **Initial Mark**（STW）。
3. **Root Region Scan** 与 **Concurrent Mark**：可与应用线程并发（期间仍可能有 Young GC）。
4. **Remark**（STW）与 **Cleanup**：完成存活性判断、统计候选 Region。
5. 如果收益足够，开始**若干轮 Mixed GC**，回收 Young + 部分 Old；之后再回到 Young-only 阶段。

**两条常见错误说法：**

- “G1 是并发 GC，所以不会 STW”——错误。Young / Mixed 的 Evacuation Pause 等阶段需要 STW。
- “Mixed GC 就是 Full GC”——错误。Mixed 只选部分老年代 Region，Full GC 有不同的触发机制和代价。

### 4.3 从一段 GC 日志里到底看什么

下面是**示意日志，非真实生产截取**：

```text
2026-10-14T10:00:00.000+0800: 120.000: [GC pause (G1 Evacuation Pause) (young)
  4096M->2850M(6144M), 0.083 secs]
2026-10-14T10:01:00.000+0800: 180.000: [GC pause (G1 Evacuation Pause) (mixed)
  5300M->4220M(6144M), 0.211 secs]
2026-10-14T10:03:00.000+0800: 300.000: [Full GC ...
  6100M->5780M(6144M), 8.200 secs]
```

读法：`回收前占用 -> 回收后占用 (堆容量)`，后面是停顿时间。生产日志格式受 JDK 版本、垃圾回收器和 JVM flag 影响；不要把上面格式当成唯一格式。

**重点看三项：**

- **GC 后基线**：回收后还剩多少？长期持续抬升比“瞬时占用冲高”更值得警惕。
- **回收效率**：回收前后下降了多少？Full GC 后几乎不降，提示存活对象确实多或回收条件不利。
- **暂停时间与频率**：是偶发长暂停，还是短时间持续 GC 抢走 CPU？两者改善措施不同。

### 4.4 用 `jstat` 看趋势，而不是只看一次快照

```bash
PID=12345
jstat -gcutil "$PID" 1000 10
```

输出中常见的 `S0/S1/E/O/M` 表示各区使用比例，`YGC/YGCT`、`FGC/FGCT` 是相应 GC 次数与耗时（字段依具体 JDK/收集器而异）。

**判读：**

- `O` 随时间升高，但 Young GC 后不明显下降：不一定异常，因为 Young GC 通常不回收 Old。
- **回收老年代之后**占用仍持续增加：要判断是业务在积累合理活对象，还是对象引用意外留存。
- `FGC` 短期暴涨，且 GC 后老年代使用率不下降：需要优先采集证据并干预。

![图 19：区分泄漏、合理峰值与 native 增长](../assets/jvm-memory-structure-oom/diagrams/19-内存增长形态.svg)

下面三张曲线是**为了帮助理解而生成的示意数据，不是某个真实生产系统的监控截图**。观察这些曲线时可以注意：只看瞬时高点，很容易误判；要看 GC 后低点是否上涨，以及 Heap 与 RSS 是否背离。

![图 21：正常 GC 的锯齿形变化](../assets/jvm-memory-structure-oom/diagrams/21-健康GC锯齿.svg)

![图 22：疑似泄漏时 GC 后基线抬升](../assets/jvm-memory-structure-oom/diagrams/22-堆内泄漏基线上升.svg)

![图 23：Heap 稳定、RSS 却持续上升](../assets/jvm-memory-structure-oom/diagrams/23-Heap与RSS背离.svg)

**注意：** 堆内存的 GC 后基线上升只是“异常线索”，并不自动等于泄漏；还需要排除流量上升、缓存预热和合理数据积累。RSS 背离也不是 Netty 的唯一证据。

---

## 5. 七类常见 OOM 与非 Java OOM

![图 10：OOM 类型全景](../assets/jvm-memory-structure-oom/diagrams/10-OOM类型全景.svg)

| 症状 / 文本 | 主要排查方向 | 第一批证据 |
|---|---|---|
| `OutOfMemoryError: Java heap space` | 堆容量不足、大对象、缓存/集合引用泄漏 | GC 日志、Heap Dump、对象直方图 |
| `GC overhead limit exceeded` | GC 花费大量时间但回收极少 | GC 日志、Heap Dump、流量指标 |
| `OutOfMemoryError: Metaspace` | 类元数据增长、类加载器不能释放、配额不足 | 类加载计数、`jmap -clstats`、NMT |
| `Compressed class space` | 压缩类空间不足、动态类过多 | 相关 JVM 参数、类数量、ClassLoader 统计 |
| `Direct buffer memory` / Netty `OutOfDirectMemoryError` | Direct Buffer 上限、池化缓冲区未释放、高并发峰值 | RSS、Direct Memory 指标、Netty 泄漏日志 |
| `unable to create new native thread` | 线程数、`pids.max`、ulimit、剩余 native 内存 | 线程数、thread dump、进程/cgroup 限制 |
| `Requested array size exceeds VM limit` | 单个数组申请超过 VM 支持限制 | 异常栈、数组长度计算、入参 |
| 容器 `OOMKilled` / exit code 137 | cgroup 上限被打穿（可能没有 Java OOM） | cgroup 事件、Pod 描述、RSS/内存曲线 |

**强调：** `OutOfMemoryError` 是一个异常类别，**不是所有 Java 进程内存问题都表现为这个异常**。Java 的 OOM 与 Linux OOM killer 是两个不同层次的事件。

### 5.1 `GC overhead limit exceeded` 要怎么理解

在 JDK 8 HotSpot 的典型判据中，GC 长时间占据大量运行时间（约 98%），而回收到的堆空间极少（不到约 2%，连续多轮满足条件）时可能触发。**不要把这两个百分比当成普适、跨 GC 实现的固定判据**，也不要把禁用 `UseGCOverheadLimit` 当成根因修复。

### 5.2 4 GiB 容器、3 GiB Xmx，为什么照样被杀

![图 11：Heap、Native 与容器额度](../assets/jvm-memory-structure-oom/diagrams/11-容器与RSS预算.svg)

示例容量账单（**演示用，非统一配比**）：

| 项目 | 假设量级 |
|---|---:|
| Java Heap | 3.0 GiB（假设大部分页驻留） |
| Direct/Netty Buffer | 0.55 GiB |
| Metaspace + Code Cache | 0.25 GiB |
| 线程栈、JVM/GC、其他 native | 0.30 GiB |
| **粗略合计** | **4.10 GiB** |

即使 `Xmx` 未超 3 GiB，整体已经有超过 4 GiB 的风险。还要考虑**内存统计重叠、页缓存和瞬时峰值**，所以不能把这个演示合计当成可精确推算 RSS 的公式。要看系统实测，而不是纸面上只加 `Xmx`。

## 6. 生产环境 OOM 排查总流程

### 6.1 五步法：先定类、后取证、再定位

![图 12：OOM 现场排查总流程](../assets/jvm-memory-structure-oom/diagrams/12-现场排查总流程.svg)

#### 第一步：先判断“异常是谁抛的、进程是谁杀的”

先看 `OutOfMemoryError` 完整文本、最后几十秒业务日志、容器退出状态。

```bash
# Kubernetes：示例，需替换命名空间与 Pod 名
kubectl -n <namespace> describe pod <pod>
kubectl -n <namespace> logs <pod> --previous --timestamps --tail=300

# Linux cgroup v2（文件路径依实际部署而变化）
cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.events
```

**注意：** `137` 常对应进程收到 SIGKILL，容器 OOMKill 是重要原因之一，**但并不能单凭退出码 137 就确认 OOMKill**；需要事件、容器状态或 cgroup 证据相互印证。

#### 第二步：记录现场，不要先盲目改 Xmx

建议立即记录：JDK 版本、启动 flags、GC 类型、进程/容器内存、线程数、最近发布、流量与错误分布。原则上先**止损**（摘流、限流、扩容、回滚），并与证据保全并行开展。

#### 第三步：确定该用哪类证据

![图 18：故障现象对应取证工具](../assets/jvm-memory-structure-oom/diagrams/18-信号对应排查工具.svg)

**一句话口诀：** 堆问题看 GC + Dump；类元数据问题看 ClassLoader；堆外问题看 Native + RSS；线程问题看线程数 + 系统限制；直接被杀先看 cgroup。

#### 第四步：找“谁持有”或“谁增长”

不要停在“`byte[]` 占了 60%”这种表象。必须确认：**是谁持有它、为什么持有这么久、什么时候应该释放**。对于 Heap 问题看 GC Roots 和 Retained Heap；对于 native 问题看时间差、分配来源与所有权。

#### 第五步：通过同负载回归证明修复

“改了代码，不再立刻 OOM”不等于修复成功。至少验证：相同 QPS、相同包大小、相同长连接数、相同运行时长下，GC 后基线、RSS、Direct Memory、类数量、线程数不再无界增长。

![图 20：生产止损到防再发闭环](../assets/jvm-memory-structure-oom/diagrams/20-事故处置全链路.svg)

### 6.2 为什么采集 Heap Dump 要谨慎

在 JDK 8 常见 HotSpot 版本里：

```bash
# 优先先看支持哪些诊断命令和参数
jcmd "$PID" help GC.heap_dump
jcmd "$PID" help GC.class_histogram

# HPROF：可能高停顿、高磁盘 I/O，并可能引发 Full GC
jcmd "$PID" GC.heap_dump /data/dumps/java-$PID.hprof

# 或采用 JDK 8 常见工具：对堆 dump 也有明显影响
jmap -dump:format=b,file=/data/dumps/java-$PID.hprof "$PID"
```

生产注意事项：**预留足够磁盘空间**、确认线程/堆停顿风险、控制取证频率、避免多个实例同时 dump、根据故障紧急程度决定是否先切换流量。`jmap -histo:live`、部分 `jcmd` Heap Dump 默认行为可能主动请求 Full GC；不同版本选项不完全相同，一定先看 `jcmd <pid> help <command>`。

Heap Dump 可能包含姓名、手机号、身份证号、Token、请求报文等敏感信息。**按生产敏感数据管理，最小权限传输并按制度清除**。

## 7. 五类典型故障实战

### 案例 A：堆内泄漏——缓存 / 集合无限增长

**现象：** 请求量正常但 GC 后 Old 占用持续升高，Full GC 后仍居高不下，最终 `Java heap space`。

![图 13：Heap Dump 分析闭环](../assets/jvm-memory-structure-oom/diagrams/13-HeapDump分析闭环.svg)

打开 [Eclipse MAT](https://eclipse.dev/mat/) 后：

1. `Histogram`：按 **Shallow Heap** 看数量和对象自身内存；看 `byte[]`、`String`、业务 DTO、集合节点等异常规模。
2. `Dominator Tree`：按 **Retained Heap** 排序，观察某个 Map、缓存管理器或 Service 单例间接保留多少内存。
3. `Path to GC Roots`：找出引用链，比如 `static INSTANCE → cache → map → entry → byte[]`。
4. 与业务指标核对：请求结束后这些对象是否仍有正当用途？缓存是否设置容量/TTL？是否意外使用无限队列？

**非常关键：Shallow vs Retained**

- Shallow Heap：当前对象本身占用的内存。
- Retained Heap：当对象失去引用、且由它所支配的对象也因此不可达时，理论上能释放的一整批对象内存。

`HashMap` 自身不一定大，却可能通过 value 持有大量 `byte[]`。因此不要只看最靠前的“大数组”就去改 `byte[]` 的创建语句。

**修复方向：** 缓存增加 TTL / 最大容量、改分页/流式处理、限制批量返回大小、修正静态集合以及异步任务完成后的引用清理。仅增大 `Xmx` 通常只是延缓出问题的时间。

### 案例 B：Netty / Spring Cloud Gateway 堆外内存问题

**现象：** `-Xmx` 内的 Heap 很稳定，Old 也不高，但进程 RSS 不断上升；可能出现 Netty `OutOfDirectMemoryError`、`OutOfMemoryError: Direct buffer memory`，或者先被容器杀掉。

![图 14：Netty ByteBuf 引用计数所有权](../assets/jvm-memory-structure-oom/diagrams/14-Netty引用计数所有权.svg)

#### 关键概念：谁拿到 ByteBuf，谁负责生命周期？

`ByteBuf` 初始引用计数一般是 1；`retain()` 增加，`release()` 减少，降到 0 时由其实现释放或归还底层资源。异步传递、切片和聚合要遵守 Netty / 框架约定：**谁拥有、谁转移、谁释放**。

```java
ByteBuf buf = PooledByteBufAllocator.DEFAULT.directBuffer(1024);
try {
    buf.writeInt(42);
    // 只在当前作用域读取，不把所有权交给其他组件
    consumeSynchronously(buf);
} finally {
    buf.release();
}
```

上面是**自己分配、自己释放**的最小示例，不可照搬到所有 `channelRead` 或 WebFlux 操作符内。**若成功把所有权移交给下游（例如框架接管出站写入），再由本层释放就可能 double release。**

#### 两种相反的 bug

- **漏释放 / 多余 retain**：占用积累，buffer 池无法回收，可能出现堆外耗尽。
- **提前释放 / 重复释放**：`IllegalReferenceCountException: refCnt: 0` 等访问失效问题，常发生在异步链路或手工 `release` 与框架自动释放交错时。

```bash
# 有疑点时先在测试环境或灰度开启（高档位有性能开销）
-Dio.netty.leakDetection.level=advanced

# 压测时可短期开启，全量跟踪成本很高
-Dio.netty.leakDetection.level=paranoid
```

观察 `LEAK: ByteBuf.release() was not called...`，结合 `Created at`、`Recent access records` 回溯。注意 Netty 泄漏检测默认常为采样，**没有日志不等于没有泄漏**。

#### 排查顺序

1. 对齐 RSS 与堆占用，看两者是否背离。
2. 看 DirectBufferPool 的 `MemoryUsed`、`TotalCapacity` 等监控（注意**这类 MXBean 指标不一定能完整覆盖 Netty 所有 native 分配**）。
3. 区分 JDK NIO 的 `MaxDirectMemorySize` 路径和 Netty 自身分配/额度逻辑，检查版本及 `io.netty.maxDirectMemory` 等配置。
4. 对 suspect handler、DataBuffer 转换、`retain`、`release`、`slice`、异步转交、异常分支逐一标注所有权。
5. 在压测/灰度开启 Netty Advanced 泄漏检测，复现相同请求大小与并发连接数。

**实践提示：** Spring WebFlux 的 `DataBuffer` 可能包装 Netty 引用计数缓冲区；对 `join()`、filter 读取 body、异常分支、取消订阅后的资源释放要按框架 API 契约检查，而不能简单地“在哪里看到就释放”。

### 案例 C：Metaspace 不断增长——动态类与 ClassLoader

**现象：** 类加载数量持续增加，Metaspace 接近上限，Heap 并不总是很满，可能触发 `OutOfMemoryError: Metaspace`。

![图 15：动态加载、ClassLoader 与元空间](../assets/jvm-memory-structure-oom/diagrams/15-Metaspace类加载器链路.svg)

排查重点是**到底谁在重复生成/加载类，谁又把对应 ClassLoader 留住了**。场景包括热部署、动态代理、脚本引擎、错误的缓存、过度生成代理类、旧版本反射/序列化框架等。

```bash
# JDK 8：看当前加载/卸载类累计信息
jstat -class "$PID" 1000 10

# JDK 8：查看类加载器和类元数据占用（可能有明显开销）
jmap -clstats "$PID"

# 结合 NMT 和 JVM flags
jcmd "$PID" VM.flags
jcmd "$PID" VM.native_memory summary scale=MB
```

**特别讨论：** 旧环境中曾可能在内存/类统计中出现大量如下类型：

```text
sun.reflect.GeneratedMethodAccessor123
sun.reflect.GeneratedMethodAccessor124
sun.reflect.GeneratedConstructorAccessor125
sun.reflect.DelegatingClassLoader
```

JDK 8 的反射实现可能在调用达到阈值后生成访问器类。**看到这些名字不意味着已经证明 XStream 一定有 bug**；应继续比较增长趋势、类加载器数量、代码是否反复创建序列化器实例以及 ClassLoader 生命周期。某些应用将重复创建的解析器复用，可能减少元数据增长；但“改成 `static` 就一定解决”不是通用结论，仍需检查线程安全、配置共享和并发行为。

**修复方向：** 避免不必要的动态类生成；复用线程安全且配置稳定的序列化组件；杜绝热部署旧 ClassLoader 被线程、静态注册表、缓存持有；检查代理类和类加载器缓存策略。盲目增大 `MaxMetaspaceSize` 可能会掩盖泄漏。

### 案例 D：线程创建失败——不一定是堆不够

![图 16：线程创建失败的资源链路](../assets/jvm-memory-structure-oom/diagrams/16-线程创建失败.svg)

典型症状：

```text
java.lang.OutOfMemoryError: unable to create new native thread
```

重点是线程资源，而不只是 `-Xmx`。常见原因：无界线程创建、阻塞任务导致线程池膨胀、系统线程数 / PID 限制、容器 `pids.max`、本地虚拟内存不足。

```bash
ps -o pid,rss,vsz,nlwp,cmd -p "$PID"
ps -L -p "$PID" | head -50
cat /proc/"$PID"/status | grep -E 'Threads|VmRSS|VmSize'
cat /sys/fs/cgroup/pids.max 2>/dev/null
ulimit -u
jcmd "$PID" Thread.print > /data/thread-$PID.txt
```

**注意：** `-Xss1m × 2000 线程 = 2GiB` 是用于估算每线程最大栈空间量级的思路，**不等于每个线程一创建就立即驻留 1MiB 的 RSS**。还受到 guard pages、保留/提交方式、系统线程结构和平台实现影响。

**修复方向：** 限制线程池上界、排除阻塞/死锁/慢 IO、优化异步模型、检查 PID 约束。简单降低 `-Xss` 有栈溢出风险，需验证真实调用深度。

### 案例 E：容器 OOMKilled——没有 Java 堆 OOM

**现象：** 最后一条日志正常，实例突然重启；没有 HPROF 或 Java 异常，Kubernetes 显示 `OOMKilled`。

处理顺序：

1. 查 `kubectl describe pod`、事件、cgroup `memory.events`（或 v1 对应字段），确认容器内存限制命中。
2. 拿 Heap used、进程 RSS、容器内存曲线，同一个时间点对齐。
3. 若堆稳定而进程/容器上升，优先考虑 Direct/Netty、Metaspace、线程、JNI/native、文件映射或文件缓存等。
4. 若只在流量峰值发生，区分**瞬时高峰与长期泄漏**；必要时先做限流/背压与更合理的容量预算。

## 8. 排查命令速查和判读

### 8.1 低风险优先：先拿线程、flags 和资源快照

```bash
# 替换 PID；一般尽量使用同版本 JDK 的工具和相同运行用户
PID=12345

java -version
jcmd "$PID" VM.command_line
jcmd "$PID" VM.flags
jcmd "$PID" GC.heap_info
jcmd "$PID" Thread.print > /data/thread-$PID.txt

# 资源快照
ps -o pid,ppid,rss,vsz,nlwp,%mem,%cpu,cmd -p "$PID"
cat /proc/"$PID"/status | grep -E 'VmRSS|VmHWM|VmSize|Threads'
cat /proc/"$PID"/smaps_rollup 2>/dev/null | head -30
pmap -x "$PID" | tail -5
```

`jcmd GC.heap_info`、线程 dump 仍可能触发短暂 safepoint / 较大输出，不要认为诊断命令一律零开销。`/proc/*/smaps_rollup` 与 cgroup 文件是否存在，取决于 Linux 内核和环境权限。

### 8.2 堆内：监控 → Histogram → Heap Dump → MAT

```bash
jstat -gcutil "$PID" 1000 20
jcmd "$PID" GC.class_histogram > /data/histo-$PID.txt
jcmd "$PID" GC.heap_dump /data/dumps/java-$PID.hprof
```

**选择原则：**

- `jstat`：适合观察 GC 趋势，通常开销较低。
- `class_histogram`：可以快速识别哪些对象类型占比较大，但有停顿/扫描开销，不建议作为高频监控。
- `heap_dump`：信息最全面，但可能有高停顿与很大的磁盘成本；请先通过 `jcmd PID help GC.heap_dump` 核实选项和副作用。
- Eclipse MAT：Dominator Tree、Leak Suspects、Path to GC Roots。**Heap Dump 主要帮助定位堆对象引用链，不能代替 native 追踪。**

### 8.3 Native Memory Tracking（NMT）

![图 17：NMT 基线与差分排查](../assets/jvm-memory-structure-oom/diagrams/17-NMT差分快照.svg)

NMT 的价值是找 JVM 自身 native 内存分类的**变化趋势**，不是记录“所有 C 库与 JNI malloc”。

```bash
# JVM 启动时开启，已经运行的 JVM 通常无法临时从 off 打开
-XX:NativeMemoryTracking=summary

# 运行后采集
jcmd "$PID" VM.native_memory summary scale=MB
jcmd "$PID" VM.native_memory baseline
# 在等效负载下运行 10~30 分钟后
jcmd "$PID" VM.native_memory summary.diff scale=MB
```

`summary`、`detail` 有性能/内存开销，`detail` 更大；不同版本可能不完全相同。**NMT 追踪 HotSpot/JVM 内部原生内存，不覆盖全部第三方 native 申请。**若 NMT 中增长不明显，但 RSS 在涨，应继续查 DirectBuffer、Netty 原生分配、JNI、自定义 malloc、内存映射和内核侧指标。

### 8.4 Linux / Kubernetes 的证据与风险等级

| 动作 | 目的 | 典型影响 / 风险 |
|---|---|---|
| `ps`、读 `/proc`、读 cgroup | 内存 / 线程 / OOM 事件 | 通常低，取决于环境 |
| `jstat -gcutil` | GC 使用趋势 | 通常较低 |
| `jcmd VM.flags`、`VM.command_line` | 配置 | 通常较低 |
| `jcmd Thread.print` | 阻塞栈、线程数 | 线程很多时输出量较大，有短暂停顿可能 |
| `jmap -clstats` | 类加载器统计 | 中高开销，视版本和类数量 |
| `jcmd GC.class_histogram` | 堆对象类型 | 可能较高，可能影响停顿 |
| `jcmd GC.heap_dump` | 完整堆快照 | **高风险**：停顿、磁盘、可能 Full GC |
| `jmap -F`、强制 attach | 非常规恢复 | **高风险**：仅确认环境与风险后使用 |

## 9. JVM 参数、容量预算与预防

### 9.1 JDK 8 G1 常用基础启动参数示例

以下是一组**示例**参数，不代表可以不经压测就直接上生产：

```bash
-Xms2g
-Xmx2g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:MaxMetaspaceSize=512m
-XX:MaxDirectMemorySize=512m
-Xss1m
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dumps
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/data/logs/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=20M
```

**特别注意：**

- `MaxGCPauseMillis` 是目标，不是保证。
- `MaxDirectMemorySize` 不必然完全约束 Netty 所有原生内存路径，需结合版本和 Netty 参数。
- `Xms = Xmx` 有利于稳定堆容量/避免部分扩容成本，但也会改变初始内存足迹和弹性，不是所有容器都适用。
- **JDK 8 GC 日志选项**与 **JDK 9+ 统一日志**格式不同。JDK 11/17+ 应使用 `-Xlog:gc*...` 等新语法。
- JDK 8 早期更新版本的**容器感知能力**存在差异；必须核对具体 `8u` 版本和实际 `VM.flags`，不要假设所有 JDK 8 都能自动正确识别 cgroup 限制。

### 9.2 计算容量时要留出 native 空间

一种稳妥的预算方法是先拆分：

```text
容器内存额度
  ├── Java Heap 可驻留内存（受 Xmx 上界影响）
  ├── Direct / Netty / JNI 等堆外开销
  ├── Metaspace 与 Compressed Class Space 的已提交部分
  ├── JIT Code Cache、GC、自身 native 开销
  ├── 线程栈和本地线程开销
  └── 文件页缓存 / 突发峰值 / 安全余量
```

没有一个对所有 JVM 服务都适用的 `Xmx = container limit × 75%` 黄金比例。**应在真实峰值下测量上述指标和高水位**，再决定容器限制和 Xmx。特别是网关、文件上传下载、图片/PDF 处理、JNI 调用密集型服务，其 native 预算可能显著高于普通 CRUD 服务。

### 9.3 应该建立哪些常态化监控

建议至少包含：

- Heap used / committed / max、Old 使用率、**GC 后基线**。
- Young/Full/Mixed GC 次数、停顿时长、GC 总耗时占比。
- Metaspace used/committed、loaded class count、unloaded class count。
- Direct Buffer pool 指标、Netty allocator/pool 使用情况（按可用监控接口）。
- Process RSS、容器 `memory.current` / 工作集（需统一口径）、容器 OOM 事件。
- 线程数、线程池 active/queue、EventLoop 排队/阻塞。
- QPS、响应报文大小、上传下载吞吐、错误率、最近发布批次。

**告警不要只设一个堆百分比阈值。** 例如“GC 后 Old 持续上升 + RSS 上升 + 相同 QPS + 类数量稳定”比单纯 Heap 80% 更有诊断价值。

### 9.4 JDK 8 与 JDK 17+ 排查差别速览

| 主题 | JDK 8 HotSpot | JDK 17+ HotSpot（视实现/版本） |
|---|---|---|
| 默认 GC | 依环境、JDK 版本与 ergonomics 确定，常见 Parallel GC | 常见默认 G1，但应以实际 flags 为准 |
| GC 日志 | `-XX:+PrintGCDetails`、`-Xloggc` | `-Xlog:gc*` 等统一日志 |
| 反射访问器实现 | 常见 `sun.reflect.GeneratedMethodAccessor` 等 | JDK 18 后核心反射逐步改基于 MethodHandle；诊断表现不同 |
| Metaspace | JDK 8 已存在 | 仍存在，内部实现有变化 |
| native / 容器行为 | 早期 8u 容器支持需核对 | 容器支持更成熟，但仍需核对 JVM/部署 |
| dump / NMT / MAT 思路 | 适用 | 适用，但参数与工具选项可能变化 |

---

## 10. 排查清单、自查问题与参考资料

### 10.1 阅读路线

| 顺序 | 内容 | 相关图 |
|---|---|---|
| 01 | 从 JVM 进程全貌与内存边界开始 | 图 01、11 |
| 02 | 理解 Heap、Stack、Metaspace 与对象布局 | 图 02、03、04、15 |
| 03 | 梳理对象分配、分代与 G1 Region | 图 05、06、07 |
| 04 | 结合 GC Roots、G1 周期和日志判断回收效果 | 图 08、09、19 |
| 05 | 按 OOM 类型建立现场排查路径 | 图 10、12、18、20 |
| 06 | 深入 Heap Dump、Netty、Metaspace 等典型案例 | 图 13、14、15、17 |
| 07 | 使用检查清单和自查问题回顾 | 本节 |

### 10.2 一页应急 SOP（可复制进团队 Runbook）

```text
[ ] 1. 确认故障：Java OOM 文字？容器 OOMKilled？线程创建失败？
[ ] 2. 确认环境：JDK 版本、GC、Xmx、Direct、Meta、容器限制。
[ ] 3. 保存时间线：最近发布、QPS、错误、Heap、GC、RSS、线程数。
[ ] 4. 止损：摘流/限流/扩容/回滚；评估保留现场的风险。
[ ] 5. 分流证据：Heap → GC + HPROF；Meta → ClassLoader；
           Direct/native → NMT + RSS + Netty；线程 → dump + pids。
[ ] 6. 判断链路：谁持有对象？谁生成类？谁分配 buffer？谁创建线程？
[ ] 7. 复现并修复：相同流量、相同包大小、相同连接数、长稳压测。
[ ] 8. 验证修复：GC 后基线稳定、RSS 不再无界增长、无异常增量。
[ ] 9. 沉淀：监控告警、预置取证权限、灰度配置、复盘和防再发。
```

### 10.3 自查问题与分析要点

1. **`-Xmx4g` 是否意味着 Java 进程至多使用 4G？** 否；还有 Metaspace、栈、Direct、JNI、Code Cache 等。
2. **对象 `new` 一定立刻分配到 Heap Eden 吗？** 不一定；JIT 可能消除分配，G1 Humongous 等存在特殊路径。
3. **G1 的一个 Region 里同时有 Eden 和 Old 吗？** Region 作为逻辑角色单位，某时刻按 Region 分配角色，不是每个 Region 内各划一块。
4. **GC 后老年代不降就是泄漏吗？** 不一定；可能是合理长期存活对象，需业务语义和增长趋势验证。
5. **`byte[]` 数量最多，就说明创建 `byte[]` 的地方是根因吗？** 不一定；应查 Retained Heap、引用链、持有者。
6. **`OutOfDirectMemoryError` 应先做 Heap Dump 吗？** 不是第一选择；应先核查 RSS、Direct、Netty 池与引用计数。
7. **`sun.reflect.GeneratedMethodAccessor` 多，能否直接断定 XStream 的问题？** 不能；必须结合反射/动态类趋势、ClassLoader 和创建模式。
8. **`ThreadLocal.remove()` 为什么重要？** 池化线程可能长寿，ThreadLocalMap 的 value 仍可能被线程间接保留；请求范围数据需及时清理。
9. **容器进程退出码 137 能否直接确定是 OOMKilled？** 不能；需事件与 cgroup 证据。
10. **发现问题后为什么不直接调大内存？** 需要区分容量不足、无界增长、流量峰值、算法问题；盲目加内存可能只延迟复发。

### 10.4 五条排查原则

> 1. **先识别内存类型，再选工具**，不要所有 OOM 都导 Heap Dump。
> 2. **看增长趋势比看单次占用更重要**，尤其是 GC 后基线。
> 3. **Heap 泄漏看引用链，Native 泄漏看分配与释放责任**。
> 4. **Xmx 不是 Java 进程的上限**，容器预算必须覆盖 native 和峰值。
> 5. **排查的终点不是调参数，而是能解释根因并通过相同负载验证**。

### 10.5 参考资料与进一步阅读

文中的图均为教学示意，**不是直接从官方文档复制的内存布局图**。参数、不同版本 GC 实现、日志和诊断命令，请以现场 JVM 实际输出和以下资料为准。

1. Oracle JDK 8 Troubleshooting Guide — [Understand the OutOfMemoryError Exception](https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/memleaks002.html)
2. Oracle JDK 8 — [G1 Garbage Collector Tuning](https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc_tuning.html)
3. Oracle — [Getting Started with the G1 Garbage Collector](https://www.oracle.com/technetwork/tutorials/tutorials-1876574.html)
4. Oracle — [Native Memory Tracking](https://docs.oracle.com/en/java/javase/20/vm/native-memory-tracking.html)
5. Oracle JDK 8 — [The jmap Utility](https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/tooldescr014.html)
6. Netty — [Reference counted objects](https://netty.io/wiki/reference-counted-objects.html)
7. Eclipse MAT — [Finding Memory Leak](https://help.eclipse.org/latest/topic/org.eclipse.mat.ui.help/reference/findingmemoryleak.html)
8. Eclipse MAT — [Dominator Tree](https://help.eclipse.org/latest/topic/org.eclipse.mat.ui.help/concepts/dominatortree.html)

---

**阅读说明**：文中23张示意图与趋势图以SVG矢量图提供，页面包含完整图像资源。
