JVM 运行时内存区域
线上出现 OutOfMemoryError 时,报错信息往往只给出一个区域名,比如 Java heap space 或 Metaspace。如果分不清这些区域由谁分配、由谁回收、受哪个参数控制,排查就只能靠猜。本文按 JVM 规范逐个拆解运行时数据区,并以 JDK 8 与 JDK 17 为基准说明实现差异,适合已经写过 Java、但还没系统梳理过内存模型的开发者。
一张内存地图
JVM 规范定义了 5 个运行时数据区,HotSpot 在此之外还使用直接内存。按线程是否共享,可以分成两类:
| 区域 | 线程归属 | 主要存放内容 | 溢出错误 | 关键参数 |
| --- | --- | --- | --- | --- |
| 程序计数器 | 私有 | 当前字节码行号 | 无 | 无 |
| 虚拟机栈 | 私有 | 栈帧、局部变量表 | StackOverflowError / OutOfMemoryError | -Xss |
| 本地方法栈 | 私有 | native 方法栈帧 | 同上 | -Xss |
| 堆 | 共享 | 对象实例、数组 | Java heap space | -Xms / -Xmx |
| 方法区 | 共享 | 类元信息、运行时常量池 | Metaspace | -XX:MaxMetaspaceSize |
| 直接内存 | 共享 | NIO 堆外缓冲区 | Direct buffer memory | -XX:MaxDirectMemorySize |
线程私有区域
程序计数器
程序计数器(Program Counter Register)记录当前线程正在执行的字节码行号。分支、循环、跳转、异常处理以及线程切换后的恢复,都依赖这个值。它是唯一不会抛出 OutOfMemoryError 的区域,因为它的容量在类加载完成后就固定了。
执行 native 方法时,程序计数器的值是 undefined,此时它不指向任何字节码指令。
虚拟机栈与栈帧
虚拟机栈的生命周期与线程相同。每调用一个方法,就有一个栈帧入栈;方法返回,栈帧出栈。一个栈帧包含四部分:
- 局部变量表:以 Slot 为单位,每个 Slot 占 32 位,
long和double占两个 Slot。实例方法的第 0 个 Slot 固定是this。 - 操作数栈:字节码指令的运算工作区,最大深度在编译期确定。
- 动态链接:指向运行时常量池中该方法的符号引用。
- 方法返回地址:正常返回或异常退出的下一条指令地址。
-Xss 控制单个线程栈的大小,64 位 Linux 上默认 1 MB,大致能支撑一万层左右的调用。栈溢出有两种表现:调用深度超过限制抛 StackOverflowError;线程创建过多导致无法申请栈内存,抛 OutOfMemoryError: unable to create new native thread。
HotSpot 把本地方法栈与虚拟机栈合并实现,因此 -Xss 同时作用于两者。
线程共享区域
堆
堆是 JVM 中最大的一块内存,也是垃圾回收的主战场。按分代收集理论,堆被划分为新生代和老年代,新生代又分为 Eden 和两块 Survivor,默认比例 8:1:1,新生代与老年代默认比例 1:2(-XX:NewRatio=2)。
堆是所有线程共享的,但对象分配并不需要每次都加锁。HotSpot 为每个线程划分了一块 TLAB(Thread Local Allocation Buffer),线程优先在自己的 TLAB 中做指针碰撞分配,用满后再申请新的缓冲区。这也是 -XX:+UseTLAB 默认开启的原因。
-Xms 和 -Xmx 分别控制初始堆和最大堆。生产环境通常把两者设为相同值,避免堆反复扩容收缩带来的额外 GC 和抖动。
方法区、永久代与元空间
方法区是规范中的逻辑概念,存放已被虚拟机加载的类元信息、运行时常量池、静态变量和即时编译器编译后的代码缓存。不同 JDK 版本的实现差别很大:
- JDK 7 及以前用永久代(PermGen)实现,位于堆内,受
-XX:MaxPermSize限制,动态生成类较多时容易抛PermGen space。 - JDK 8 移除了永久代,改用元空间(Metaspace),使用本地内存,由
-XX:MetaspaceSize和-XX:MaxMetaspaceSize控制。 - JDK 8 起,字符串常量池和静态变量已经移到堆中(JDK 7 就开始迁移)。
改成元空间的核心原因是永久代大小难以预估:它既要装类元数据,又要装常量池,还要和堆共享地址空间,调大调小都容易出问题。元空间使用本地内存后,默认不设上限,类元数据可以随本地内存增长,但也带来了新风险——容器里不设 MaxMetaspaceSize,动态代理和字节码增强可能把物理内存吃光。
-XX:MetaspaceSize 并不是初始大小,而是首次触发元空间 GC 的高水位阈值,默认约 21 MB。每次 GC 后如果元空间仍在增长,这个阈值会被动态抬高。
运行时常量池
运行时常量池是 Class 文件常量池的运行时表示,存放字面量和符号引用。String.intern() 的行为值得单独记住:JDK 7 以后,首次出现的字符串会被放入堆中的字符串常量池,而不是复制到永久代。
直接内存
直接内存不属于运行时数据区,但它是 JVM 进程内存的重要组成部分。NIO 的 DirectByteBuffer 通过 Unsafe.allocateMemory 在堆外分配内存,受 -XX:MaxDirectMemorySize 限制,未显式设置时默认等于 -Xmx。
直接内存的回收依赖 Cleaner 和一次 Full GC:当 DirectByteBuffer 对象被回收时,Cleaner 会调用 free 释放堆外内存。因此会出现「堆很健康,但进程 RSS 持续上涨」的现象,而 heap dump 里完全看不到这部分内存。
一次栈溢出的最小复现
public class StackOverflowDemo {
private static int depth = 0;
public static void recurse() {
depth++;
recurse();
}
public static void main(String[] args) {
try {
recurse();
} catch (StackOverflowError e) {
System.out.println("depth = " + depth);
throw e;
}
}
}
用 java -Xss256k StackOverflowDemo 运行,输出深度会明显小于默认 1 MB 时的结果。这说明 -Xss 调小并不能修复栈溢出,只会让溢出更早发生。
常见坑 / 最佳实践
- 只设
-Xmx不设元空间上限。使用 CGLIB、Groovy、反射代理或热部署的进程,类元数据会持续增长。建议显式设置-XX:MaxMetaspaceSize=256m到512m,让问题在可控范围内暴露。 - 容器内存只按堆大小申请。容器 limit 必须覆盖堆、元空间、线程栈(线程数 ×
-Xss)、直接内存、Code Cache、GC 自身数据结构以及 glibc 的 arena 开销,通常要在-Xmx基础上多留 20% 到 30%。 - 用调小
-Xss的方式处理StackOverflowError。栈溢出几乎都是递归没有收敛或调用链异常深,正确做法是查代码和框架调用栈,而不是压缩栈空间。 - 排查堆外内存只看 heap dump。堆外问题要用
-XX:NativeMemoryTracking=summary配合jcmd <pid> VM.native_memory summary,或者用pmap观察进程内存段。 - 把
-Xms和-Xmx设成不同值。堆动态伸缩会在每次扩容时触发 Full GC,延迟毛刺明显,生产环境应保持一致。
小结
- JVM 运行时数据区分为线程私有的程序计数器、虚拟机栈、本地方法栈,以及线程共享的堆和方法区。
- 程序计数器是唯一不会 OOM 的区域;栈会抛
StackOverflowError;堆和方法区分别对应Java heap space与Metaspace。 - JDK 8 用元空间取代永久代,元空间位于本地内存,默认无上限,必须显式限制。
- 直接内存不在堆内,heap dump 看不到,是容器 OOMKilled 的常见盲区。
- 理解每个区域由哪个参数控制,是把「内存不够」变成「哪里不够」的第一步。
- 点赞
- 收藏
- 关注作者
评论(0)