OOM排查别再全量dump分析了:记住JVM这6个区的溢出症状,按图索骥快10倍

22 阅读12分钟

JVM 内存模型:6 个运行时数据区 + 堆分代 + 对象生命周期

OOM 排查的本质不是"dump 分析技巧"——是先知道 JVM 6 个区各存什么,再根据报错信息反推是哪个区爆了。Java heap space→堆里找大对象。Metaspace→检查动态代理类。unable to create new native thread→数线程数。这篇文章把 6 个区的"溢出症状→排查路径"画成一张地图。

阅读约 15 分钟 | 系列第 4/17 篇


一、总体架构:6 区 + 1

JVM 规范定义了 6 个运行时数据区 + 直接内存,按线程私有/共享分为两大类:

┌──────────────────────────────────────────────────────────────┐
│                      JVM 运行时数据区(逻辑视图)               │
│                                                              │
│  ┌─── 线程私有(每线程一份)───┐  ┌─── 线程共享(所有线程共用)───┐ │
│  │                            │  │                            │ │
│  │ ① 程序计数器(PC Register) │  │ ④ 堆(Heap)                │ │
│  │   字节码行号指示器          │  │   GC 主战场                 │ │
│  │                            │  │                            │ │
│  │ ② 虚拟机栈(VM Stack)     │  │ ⑤ 方法区(Metaspace)        │ │
│  │   每个方法 = 一个栈帧       │  │   类的元数据                │ │
│  │                            │  │                            │ │
│  │ ③ 本地方法栈               │  │ ⑥ 运行时常量池               │ │
│  │   为 native 方法服务       │  │   Class常量池的运行时版本     │ │
│  │                            │  │                            │ │
│  └────────────────────────────┘  │ ⑦ 直接内存                  │ │
│                                  │   NIO Buffer              │ │
│                                  └────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘

逻辑 vs 物理

以上是 JVM 规范的逻辑划分。在 HotSpot JDK 8 的物理实现中,实际内存布局如下:

  堆(-Xms/-Xmx 控制)              本地内存(操作系统直接管理)
  ┌─────────────────────┐         ┌─────────────────────┐
  │ 对象实例 + 数组       │         │ Metaspace(方法区)    │
  │ Class对象 + 静态变量  │         │  类元数据、JIT代码缓存 │
  │ 运行时常量池          │         │                     │
  │ 字符串常量池          │         │ 直接内存(NIO)       │
  └─────────────────────┘         └─────────────────────┘
分类区域内容物理位置(JDK 8)异常
线程私有程序计数器字节码行号线程独有❌ 不 OOM
线程私有虚拟机栈栈帧线程独有StackOverflowError / OOM
线程私有本地方法栈native 方法栈帧线程独有StackOverflowError / OOM
线程共享对象/数组/Class 对象/静态变量/常量池OOM
线程共享方法区类元数据(不含静态变量和常量)本地内存OOM: Metaspace
线程共享运行时常量池字面量 + 符号引用(逻辑独立)OOM
线程共享直接内存NIO Buffer本地内存OOM: Direct buffer memory

为什么这样分?

  • 线程私有:每个线程执行到不同的方法、不同的代码行,所以程序计数器和栈必须各有一份
  • 线程共享:对象是所有线程都可能用到的,放一起(堆),通过 GC 统一管理

二、线程私有区域详解

① 程序计数器(PC Register)

记录"当前线程执行到了字节码的哪一条指令"。JVM 唯一不会 OOM 的区域。

Java 代码:                    编译后的字节码:
int sum = a + b;      →       0: iload_1    // 取 a
                              1: iload_2    // 取 b
                              2: iadd       // 相加
                              3: istore_3   // 存到 sum
​
程序计数器存的数字:0123(每执行一条指令+1

为什么需要它? CPU 在多线程间来回切换——线程被切走时 PC=2,恢复后从第 2 条继续。没有 PC,线程不知道自己执行到哪了。

程序计数器的工作方式如同书签:看书看到第 3 页被叫走开会,折个角(PC=3),回来直接翻到第 3 页继续。

② 虚拟机栈(VM Stack)——最核心的运行时数据区

栈和栈帧的关系

栈(Stack)= 容器(一个线程一根,~1MB)
栈帧(Stack Frame)= 栈里的一个元素(一个方法对应一个栈帧)
​
入栈 = 方法被调用 → 在栈顶压入新栈帧 → 方法开始执行
出栈 = 方法执行完毕 → 栈顶栈帧弹出销毁 → 返回调用者
​
main() 调 add():
  main 栈帧入栈   →   add 栈帧压入   →   add 执行完出栈   →   main 出栈(栈空)

栈帧内部四部分

┌─────────────────────────────────────────┐
│  局部变量表(Local Variables)            │ ← 存"数据":参数和局部变量的值
│    静态方法 slot[0] = 第一个参数          │   基本类型存值,引用类型存堆地址
│    实例方法 slot[0] = this(隐式传入)     │
├─────────────────────────────────────────┤
│  操作数栈(Operand Stack)               │ ← 存"演算过程":字节码指令的操作区
│    计算 a+b:iload a → iload b → iadd    │   中间结果临时存放,用完就清
├─────────────────────────────────────────┤
│  动态链接(Dynamic Linking)             │ ← 指向当前方法所属类的运行时常量池
├─────────────────────────────────────────┤
│  返回地址(Return Address)             │ ← 方法执行完后 PC 恢复到调用者的哪一行
└─────────────────────────────────────────┘

局部变量表 vs 操作数栈——必须分清楚

局部变量表好比冰箱(食材存放处,有名有位置),操作数栈好比料理台(操作区,用完就清)。

int sum = a + b;   // a=3, b=5
​
① iload_1:从[1]a=3,压入操作数栈     操作数栈: [3]
② iload_2:从[2]b=5,压入操作数栈     操作数栈: [3][5]
③ iadd:弹出 3 和 5,加得 8,压回         操作数栈: [8]
④ istore_3:弹出 8,存入局部变量表[3]     操作数栈: []
                                         sum = 8

静态方法 vs 实例方法——唯一区别在 slot[0]

static int add(int a, int b) { … }  → slot[0]=a, slot[1]=b
int multiply(int a, int b) { … }    → slot[0]=this, slot[1]=a, slot[2]=b

异常

异常原因复现
StackOverflowError栈深度超限(默认约 1024 层,以 1MB 栈、每帧约 1KB 估算)void f() { f(); }
OutOfMemoryError疯狂创建线程,每个占 ~1MBwhile(true) { new Thread(…).start(); }

③ 本地方法栈(Native Method Stack)

结构 = 和虚拟机栈一模一样,只是服务对象不同:虚拟机栈 → Java 方法,本地方法栈 → native 方法(C/C++/Rust 实现均可)。HotSpot 将两个栈合二为一,理解概念即可。

典型 native 方法:Object.hashCode()Thread.start0()Unsafe.compareAndSwapInt()


三、线程共享区域详解

④ 堆(Heap)——最核心

堆中存放的内容(JDK 8)

┌────────────────────────────────────────────────┐
│ ① 对象实例  →  new Person(), new ArrayList<>()  │
│ ② 数组     →  new int[100], new String[3]       │
│ ③ Class对象 →  Demo.class 这个对象就在堆里       │
│ ④ 静态变量  →  附在 Class 对象上(JDK 8 搬入)   │
│ ⑤ 运行时常量池 → Class 文件常量池的运行时版本     │
│ ⑥ 字符串常量池 → "hello" 等字符串字面量(全局一份)│
└────────────────────────────────────────────────┘

分代结构

  ┌─────────────────────┐  ┌─────────────────────┐
  │      新生代           │  │       老年代         │
  │  ┌──────┬─────┬────┐ │  │                     │
  │  │ Eden │ S0  │ S1 │ │  │  长期存活对象         │
  │  │  811  │ │  │  大对象              │
  │  └──────┴─────┴────┘ │  │                     │
  └─────────────────────┘  └─────────────────────┘
        ↖ 默认比例 新生代:老年代 = 1:2 ↗

关键参数:-Xms(初始)  -Xmx(最大)  -Xmn(新生代大小)

Eden:S0:S1=8:1:1——为什么这样设计?每次 Minor GC 存活对象进入 Survivor,两个 Survivor 交替使用(一个空着等下次 Copy)。8:1:1 意味着 90% 的新对象在 Eden 中,10% 在 Survivor 中周转,符合"绝大多数对象朝生夕死"的假设。

⑤ 方法区(Metaspace)

存放类的元数据(描述类的信息,而非对象本身):

Demo 类被加载后,Metaspace 中存入:
├── 类的完整限定名 → com.example.Demo
├── 类的修饰符   → public
├── 父类名       → java.lang.Object
├── 实现的接口   → Serializable
├── 字段信息:name(String), age(int), 修饰符 private
├── 方法信息:add, 签名 (II)I, 字节码 [iload_0, iload_1…], 异常表
├── 运行时常量池的引用 → 指向堆
└── JIT 编译后的本地代码(CodeCache)

⑥ 运行时常量池 vs 字符串常量池(关键区分)

Class 文件常量池(编译时,在 .class 文件里)
         ↓ 类加载时复制
运行时常量池(运行时,在堆中)← 每个类独立一份
  ├ 符号引用:#10 = Demo.add:(II)I
  └ 字面量:#15 → "hello"(指向字符串常量池)
                     ↓
字符串常量池(运行时,在堆中)← 全局只有一份,去重
  ├ "hello" = 一个堆对象
  ├ "world" = 一个堆对象
  └ "paid"  = 一个堆对象
维度运行时常量池字符串常量池
存什么符号引用 + 各种字面量只存字符串
范围每个类各有一份全局唯一,去重
String.intern()操作的就是它
GC类卸载时释放没引用时被回收

String.intern() 经典问题

String s1 = new String("hello");   // 堆中新建对象
String s2 = s1.intern();           // 常量池已有"hello",返回池中引用
String s3 = "hello";               // 从常量池取,和 s2 是同一个引用
System.out.println(s1 == s2);      // false(s1 是堆对象,s2 是常量池引用)
System.out.println(s2 == s3);      // true(两者都是常量池引用)

⑦ 直接内存(Direct Memory)

不在 JVM 堆内,直接向操作系统申请的内存。核心价值:零拷贝(Zero Copy)

传统 I/O(2 次拷贝):磁盘 → OS缓冲区 → JVM堆 → 应用程序
直接内存 I/O(1 次拷贝):磁盘 → OS缓冲区 → 应用程序(直接读写 OS 内存)
  • 用途:NIO 大文件读写、Netty 网络编程
  • 分配ByteBuffer.allocateDirect(1024)Unsafe.allocateMemory() → OS malloc
  • 代价:不受 GC 直接管——只有 ByteBuffer 对象被 GC 时才连带释放底层内存。直接内存满 ≠ 堆满,堆没触发 GC 时不会释放直接内存 → OOM: Direct buffer memory。监控盲区:-Xmx 设了 4G 感觉安全,但直接内存也在吃系统内存

四、JDK 6 → 7 → 8 内存模型演变

JDK 6 及之前               JDK 7                    JDK 8+
─────────────────────────────────────────────────────────────
方法区 = PermGen(堆内)     PermGen(堆内)           Metaspace(堆外本地内存)

PermGen 中:                PermGen 中剩下:          Metaspace 中只有:
├ 类元数据                  ├ 类元数据                └ 类元数据(纯元数据)
├ 常量 ──────→ 搬到堆(运行时常量池)
└ 静态变量                     ────────→ 搬到堆(附在Class对象上)

参数变化-XX:MaxPermSize-XX:MaxMetaspaceSize,默认无上限(有多少物理内存用多少)。

"JDK 7 把'数据'搬出去(常量、静态变量),JDK 8 把方法区自身也搬出去了(PermGen→Metaspace)。目的是消除 PermGen 固定大小导致的 OOM——Metaspace 在本地内存中动态扩容。"


五、哪些区域会 OOM?

区域OOM 信息常见原因
Java heap space对象太多、内存泄漏、大对象分配失败
MetaspaceMetaspaceCGLIB 动态生成大量类、MaxMetaspaceSize 太小
虚拟机栈unable to create new native thread线程创建过多,每个占 ~1MB 栈
直接内存Direct buffer memoryNIO 大量分配未释放

六、堆和栈的本质区别(核心必知)

维度堆(Heap)栈(Stack)
存什么对象实例、数组方法调用的栈帧
线程归属所有线程共享每个线程私有
大小大(-Xmx,几百MB~几十GB)小(每线程默认 ~1MB)
生命周期由 GC 决定方法结束即释放
异常OutOfMemoryErrorStackOverflowError
速度慢(需 GC)快(确定性入栈出栈)

七、栈上分配(Stack Allocation)

JIT 编译器的逃逸分析优化:如果对象不会逃逸出方法(没有 return、没有赋值给静态变量),直接在栈上分配,方法结束自动销毁——不需要 GC 参与。

void foo() {
    Point p = new Point(1, 2);  // p 没有逃逸出 foo()
    int x = p.x;                // JIT 可能把 p 拆成标量 + 栈上分配
}

这是 JIT 编译器的自动优化,不需要手动干预。但理解它有助于回答"为什么现代 JVM 中 GC 的压力比以前小"这类问题。


八、对象生命周期:从 new 到回收的完整链路

标题承诺了"对象生命周期"——这一节就是兑现这个承诺。从一行 new User() 开始,追踪一个对象在 JVM 中的完整旅程。

new User("张三", 25)
  │
  ▼
① 类加载检查 — User.class 是否已加载?未加载则触发类加载(加载→验证→准备→解析→初始化)
  │
  ▼
② 分配内存 — 在 Eden 区为新对象分配空间
  ├── TLAB(Thread Local Allocation Buffer):每个线程在 Eden 中有自己的小块缓冲区
  │    优先在 TLAB 中分配,无锁、极快。TLAB 用完 → 申请新的 TLAB(CAS 一次)
  └── TLAB 外分配:大对象(-XX:PretenureSizeThreshold)或 TLAB 申请失败时
  │
  ▼
③ 初始化零值 — 所有实例字段赋默认值(int=0, Object=null, boolean=false)
  │
  ▼
④ 设置对象头 — Mark Word(hashCode/GC 年龄/锁状态)+ 类型指针(指向 User.class)
  │
  ▼
⑤ 执行 <init> — 调用构造函数,字段赋程序员指定的值(name="张三", age=25)
  │
  ▼
⑥ 在 Eden 区"活着" — 对象在 Eden 中等待被引用、被使用
  │
  ▼
⑦ Minor GC 触发 — Eden 区满 → Young GC
  ├── 无引用(垃圾)→ 直接回收,内存释放
  └── 有引用(存活)→ 复制到 Survivor 区(S0),GC 年龄 +1
  │
  ▼
⑧ Survivor 区来回复制 — 每次 Minor GC:S0↔S1 交替
  ├── 存活对象从一个 Survivor 复制到另一个
  ├── GC 年龄每次 +1
  └── 年龄达到阈值(默认 15-XX:MaxTenuringThreshold)→ 晋升老年代
  │
  ▼
⑨ 晋升老年代(Tenured) — 以下情况之一触发:
  ├── GC 年龄 ≥ 15(默认)
  ├── 动态年龄判断:Survivor 中同龄对象总大小 > Survivor 空间 50% → 该年龄及以上直接晋升
  └── 分配担保失败:Minor GC 后 Survivor 放不下 → 多余对象直接进老年代
  │
  ▼
⑩ 在老年代中继续存活 — 由 Major GC / Full GC 管理
  │
  ▼
⑪ Full GC 触发 — 老年代满或 System.gc()(不建议)→ 标记-清除-整理
  ├── 仍被引用 → 存活,整理到老年代一端(减少碎片)
  └── 无引用 → 回收
  │
  ▼
⑫ 对象彻底消亡 — 内存被回收,finalize() 仅执行一次(JDK 9 已废弃,不推荐使用)

关键参数串联

参数作用默认值
-XX:MaxTenuringThresholdSurvivor→老年代的年龄门槛15
-XX:PretenureSizeThreshold超过该大小的对象直接在老年代分配0(不启用)
-XX:SurvivorRatioEden 与单个 Survivor 的比例8
-XX:+UseTLAB是否启用线程本地分配缓冲默认开启

三个触发晋升的时机,记住口诀:年龄够了直接升、Survivor 放不下了溢升、大对象跳过 Eden 直升老年代


核心要点回顾

JVM 运行时数据区按线程归属分为两大类:程序计数器、虚拟机栈、本地方法栈为线程私有,每个线程独立一份;堆、方法区(Metaspace)、运行时常量池、直接内存为线程共享,所有线程共同使用。JVM 规范定义的是逻辑划分,HotSpot JDK 8 的物理实现将 Class 对象、静态变量、运行时常量池、字符串常量池统一放在堆中,仅将类元数据和 JIT 编译后的本地代码放在本地内存的 Metaspace 中。堆的分代设计(Eden:S0:S1=8:1:1)基于"绝大多数对象朝生夕死"的经验假设——90% 的新生代空间分配给 Eden,两个 Survivor 各占 10% 并交替使用,每轮 Minor GC 存活对象在 S0 和 S1 之间来回复制。JDK 8 的关键变化是将 PermGen 彻底移除,方法区迁移到本地内存中的 Metaspace,同时将静态变量和常量池搬入堆中,核心目的是消除固定大小的 PermGen 导致的 OOM——Metaspace 默认无上限,按需动态扩容。字符串常量池从 JDK 7 起位于堆中,全局唯一,String.intern() 操作的就是这个池;运行时常量池则每个类独立一份,存储 Class 文件常量池的运行时版本(符号引用和各种字面量),两者是不同的概念。栈上分配是 JIT 编译器通过逃逸分析实现的自动优化——如果对象不会逃逸出方法,直接在栈上分配,方法结束自动销毁,无需 GC 参与。


下次线上 OOM 别慌,对照6个区一张图按图索骥。收藏下来,出问题时直接翻。

上一篇:《MySQL数据库原理》 | 下一篇:《JVM GC与调优:7种收集器选型+参数调优实战》 系列合集掘金Java合集