JVM运行时数据区各区域作用与溢出原理
1. 这篇文章要解决什么问题
很多开发者在日常编写业务代码时,对 JVM(Java虚拟机)的感知往往只停留在"它能自动帮我回收垃圾"。但在生产环境中,一旦遇到 OutOfMemoryError(OOM内存溢出)或者 StackOverflowError(栈溢出),往往会一头雾水:
- 到底哪里溢出了?
- 为什么这段代码会导致溢出?
- 溢出了该去哪里排查问题?
这篇文章要解决的核心问题,就是把 JVM 在运行时是如何划分和使用内存的,以及每个区域为什么会发生崩溃(溢出)的底层逻辑讲透。学完之后,再看到 OOM 报错,你的第一反应将不再是"赶紧重启",而是能精准定位到是堆、栈、还是元空间出了问题,并知道如何去抓取证据和修复。
2. 核心原理
JVM 在执行 Java 程序时,会把它管理的内存划分为若干个不同的数据区域。根据是否被所有线程共享 ,我们可以将其分为两大类:线程私有区 和线程共享区。
2.1 线程私有区(随着线程生与死,不存在并发问题)
① 程序计数器(Program Counter Register)
- 它是干什么的: 简单理解,它就是当前线程所执行的字节码的"行号指示器"。CPU 在多个线程之间来回切换执行,当线程被挂起后重新唤醒时,它怎么知道上次执行到了哪里?就是靠看一眼程序计数器来恢复现场。
- 是否会溢出: 绝不。 它是 JVM 规范中唯一一个没有规定任何
OutOfMemoryError情况的区域。因为它只存一个内存地址,空间需求极小且固定。
② Java 虚拟机栈(Java Virtual Machine Stack)
- 它是干什么的: 这是支撑 Java 方法执行的内存模型。你每次调用一个方法,JVM 就会在当前线程的栈里压入一个"栈帧(Stack Frame)"。方法执行完毕,栈帧出栈。
- 栈帧里面有什么: 这是核心!栈帧里装着局部变量表 (存基本数据类型、对象引用地址)、操作数栈 (做加减乘除计算的临时工作台)、动态连接 (把符号引用转成物理内存地址)和方法出口(执行完回哪去)。
- 为什么会出问题:
- 栈深度超限(StackOverflowError): 如果你写了一个死递归函数,方法一直进栈不出栈,就会把这根柱子撑爆。
- 内存溢出(OOM): 如果 JVM 允许动态扩展栈容量,当开辟的新线程太多,系统内存耗尽无法为新线程分配栈空间时,就会 OOM。
③ 本地方法栈(Native Method Stack)
- 它是干什么的: 和虚拟机栈一模一样,只不过虚拟机栈是为 Java 代码(字节码)服务的,而本地方法栈是为 Java 中调用的 C/C++ 等底层的
native方法服务的(比如Unsafe类里的方法)。同样会抛出 StackOverflowError 和 OOM。
2.2 线程共享区(所有线程共用,重点监控区域)
④ Java 堆(Java Heap)
- 它是干什么的: JVM 内存里最大的一块地盘。它的唯一目的就是存放对象实例 。几乎你在代码里
new出来的所有对象,都在这里安家。 - 内部怎么划分: 为了方便垃圾回收(GC),堆通常被分为年轻代 (Eden区、Survivor 0区、Survivor 1区)和老年代。新对象出生在年轻代,熬过多次垃圾回收后会被转移到老年代。
- 为什么会溢出(OOM): 当你不断地创建对象,并且由于代码逻辑问题(比如塞进了一个全局的 List 里一直不清理),导致垃圾回收器认为这些对象都是"存活"的,无法回收。当堆空间被填满,连老年代也装不下时,就会彻底引爆
OutOfMemoryError: Java heap space。
⑤ 方法区(Method Area / Metaspace 元空间)
- 它是干什么的: 它用来存储被 JVM 加载的类信息(Class的结构)、常量、静态变量、即时编译器(JIT)编译后的代码缓存等数据。
- 需要澄清的历史: 在 JDK 7 及以前,方法区被称为"永久代(PermGen)",这块内存是在堆里划出去的。这就导致类的数目一多,永久代就容易 OOM。所以在 JDK 8 之后,JVM 彻底废除了永久代,改为利用操作系统的本地内存(Native Memory)来实现,叫作元空间(Metaspace)。
- 为什么会溢出(OOM): 只要不限制元空间大小,通常很难溢出。但如果使用了大量的动态代理(如 Spring AOP底层的 CGLib)在运行期间疯狂生成动态类,或者 JSP 热部署,都会把元空间撑爆,报错
OutOfMemoryError: Metaspace。
3. 流程/机制描述
为了让你脑海中有画面感,我们来看看一段最典型的代码在 JVM 里是怎么流转的,以及溢出是如何一步步逼近的。
正常流转机制:
- 类加载: 当代码执行到
new User()时,JVM 首先去"方法区(元空间)"检查有没有User这个类的结构定义。如果没有,先加载类。 - 分配内存: 类加载完后,JVM 开始在 Java 堆 的 Eden 区划分一块内存给这个新生成的
User对象。 - 压栈与引用: 接着,JVM 会在当前执行线程的虚拟机栈 里,往当前方法的栈帧中的局部变量表里创建一个变量
user,这个变量里面存放的,就是那块堆内存的物理地址(引用)。 - 垃圾回收: 方法执行完毕,退栈。局部变量表里的引用
user消失了。此时堆里的那个User对象就成了"断线风筝"。GC 线程扫描到它没人要了,就会回收这块内存。
溢出崩溃机制(以堆内存溢出为例):
- 如果你在死循环里不停地
new User(),并且每次 new 出来都放入一个全类级别的static List中。 - 方法退栈了,局部变量表的引用确实没了,但那个
static List(生命周期极长)依然死死抓着这一堆User对象引用。 - Eden 区满了,触发 Minor GC,GC 发现这些对象都有人引用,一个都杀不掉。只能把它们赶到 Survivor 区。
- Survivor 区爆满,直接将大批对象晋升到老年代。
- 老年代也满了,触发终极整理 Full GC。发现老年代里全是
static List引用的对象,还是杀不掉。 - JVM 绝望停机,抛出
OutOfMemoryError: Java heap space。
4. 关键代码/示例
为了让你深刻理解,这里给出产生各类溢出的标准实验代码。你可以直接在本地运行,配合 JVM 参数体验崩溃的瞬间。
示例 1:模拟 Java 堆溢出 (Heap Space)
JVM 配置参数: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError (限制最大堆内存为 20M,且溢出时自动导出 Dump 文件)
可用 -XX:HeapDumpPath= 参数来指定Dump文件的生成路径
java
import java.util.ArrayList;
import java.util.List;
public class HeapOomDemo {
// 定义一个占位的大对象,加速内存消耗
static class BigObject {
private byte[] placeholder = new byte[1024 * 1024]; // 每次占用 1MB
}
public static void main(String[] args) {
List<BigObject> list = new ArrayList<>();
// 核心原理:对象一直被强引用(list持有),垃圾回收器无法介入
while (true) {
list.add(new BigObject());
}
}
}
示例 2:模拟虚拟机栈溢出 (StackOverflowError)
JVM 配置参数: -Xss128k (将每个线程的栈容量缩小到 128K)
java
public class StackOverflowDemo {
private int stackDepth = 1;
// 核心原理:没有出口的递归调用,导致栈帧无限入栈
public void recursiveCall() {
stackDepth++;
recursiveCall();
}
public static void main(String[] args) {
StackOverflowDemo demo = new StackOverflowDemo();
try {
demo.recursiveCall();
} catch (StackOverflowError e) {
System.out.println("发生栈溢出,当前栈深度为:" + demo.stackDepth);
throw e;
}
}
}
示例 3:模拟元空间溢出 (Metaspace)
JVM 配置参数: -XX:MaxMetaspaceSize=10m (限制元空间大小为 10M,JDK8及以上有效)
java
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
public class MetaspaceOomDemo {
static class TargetObject {
}
public static void main(String[] args) {
// 核心原理:利用 CGLib 在运行时不断生成全新的代理类,塞满元空间
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(TargetObject.class);
enhancer.setUseCache(false);
enhancer.setCallback((MethodInterceptor) (obj, method, args1, proxy) -> proxy.invokeSuper(obj, args1));
enhancer.create(); // 动态生成新的 Class
}
}
}
5. 常见误区
-
误区一:只要报 OOM,一定是用 Java 堆内存太大导致的。
- 正解: OOM 只是大类。你必须仔细看后面的提示。如果是
/Java heap space,才是堆问题。如果是/Metaspace,是元空间问题(类加载太多);如果是/Unable to create new native thread,是系统级别的线程数或者栈空间达到了上限;如果是/Direct buffer memory,则是 NIO 使用的通过Unsafe分配的堆外系统内存泄露了。
- 正解: OOM 只是大类。你必须仔细看后面的提示。如果是
-
误区二:字符串常量池(StringTable)一直都在方法区里。
- 正解: 在 JDK 6 时,确实在方法区(永久代)。但由于太容易引发永久代 OOM,从 JDK 7 开始,字符串常量池和静态变量被移到了 Java 堆 中。这也是为什么大量的
String.intern()滥用在现代 Java 中会导致堆溢出,而不是元空间溢出。
- 正解: 在 JDK 6 时,确实在方法区(永久代)。但由于太容易引发永久代 OOM,从 JDK 7 开始,字符串常量池和静态变量被移到了 Java 堆 中。这也是为什么大量的
-
误区三:Java 有超强的 GC 机制,不可能像 C++ 那样发生内存泄漏。
- 正解: C++ 的泄露是"忘了释放物理内存",Java 的泄露是"逻辑内存泄露"。只要你的代码错误地持有了一个毫无用处对象的引用(比如静态 Map 中的缓存一直只进不出,或者
ThreadLocal用完忘记remove),GC 就永远不敢回收它,这同样会造成内存泄漏。
- 正解: C++ 的泄露是"忘了释放物理内存",Java 的泄露是"逻辑内存泄露"。只要你的代码错误地持有了一个毫无用处对象的引用(比如静态 Map 中的缓存一直只进不出,或者
6. 实际工作中怎么用
懂了原理之后,你就要在实际的服务器部署、代码编写和故障排查中把这些知识用起来。这是一名初级开发走向资深必须要养成的习惯:
-
启动脚本规范:
- 绝对不要让生产网关在没有监控和现场保留参数的情况下裸奔。
- 启动参数必须加上:
-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath=/log/heapdump.hprof。一旦发生 OOM,JVM 会在临死前自动生成快照。这就是你事后破案的唯一"黑匣子"。 - 建议将
-Xms(初始堆大小) 和-Xmx(最大堆大小) 设为一样大。原理是什么?防止在应用高峰期,JVM 需要频繁向操作系统申请扩容堆内存而带来的严重停顿开销。
-
写代码的红线:
- 如果你要在内存里做缓存(比如写个
HashMap存高频数据),一定要考虑淘汰策略。如果不确定能控制好边界,去用Guava Cache或者直接上Redis,千万别用原生的 Map 充当无界缓存。 - 在写拦截器、使用
ThreadLocal时,必须且绝对 要在finally代码块里调用.remove()。因为 Tomcat 这种 Web 容器使用的是线程池,线程是会被复用的。如果上一个请求放进ThreadLocal的巨大上下文对象没清理掉,下一个请求复用该线程时,不仅数据可能错乱,这个巨大对象也会像牛皮癣一样挂在线程空间里一直无法被 GC,最终生生把堆内存挤爆。
- 如果你要在内存里做缓存(比如写个
-
排查动作流:
- 收到告警服务挂了 -> 查日志发现
OutOfMemoryError。 - 找到启动参数配置的
dump文件所在目录,把它下载到本地电脑。 - 使用 Eclipse MAT 或者 VisualVM 导入
dump文件。 - 查看大对象饼图(Dominator Tree),揪出占用内存 90% 以上的具体类名。
- 顺藤摸瓜找到类对应的业务代码,修复长生命周期引用,修复上线。
- 收到告警服务挂了 -> 查日志发现