恐怖解谜密室逃脱
109.73M · 2026-03-09
很多开发者在日常编写业务代码时,对 JVM(Java虚拟机)的感知往往只停留在“它能自动帮我回收垃圾”。但在生产环境中,一旦遇到 OutOfMemoryError(OOM内存溢出)或者 StackOverflowError(栈溢出),往往会一头雾水:
这篇文章要解决的核心问题,就是把 JVM 在运行时是如何划分和使用内存的,以及每个区域为什么会发生崩溃(溢出)的底层逻辑讲透。学完之后,再看到 OOM 报错,你的第一反应将不再是“赶紧重启”,而是能精准定位到是堆、栈、还是元空间出了问题,并知道如何去抓取证据和修复。
JVM 在执行 Java 程序时,会把它管理的内存划分为若干个不同的数据区域。根据是否被所有线程共享,我们可以将其分为两大类:线程私有区和线程共享区。
① 程序计数器(Program Counter Register)
OutOfMemoryError 情况的区域。因为它只存一个内存地址,空间需求极小且固定。② Java 虚拟机栈(Java Virtual Machine Stack)
③ 本地方法栈(Native Method Stack)
native 方法服务的(比如 Unsafe 类里的方法)。同样会抛出 StackOverflowError 和 OOM。④ Java 堆(Java Heap)
new 出来的所有对象,都在这里安家。OutOfMemoryError: Java heap space。⑤ 方法区(Method Area / Metaspace 元空间)
OutOfMemoryError: Metaspace。为了让你脑海中有画面感,我们来看看一段最典型的代码在 JVM 里是怎么流转的,以及溢出是如何一步步逼近的。
正常流转机制:
new User() 时,JVM 首先去“方法区(元空间)”检查有没有 User 这个类的结构定义。如果没有,先加载类。User 对象。user,这个变量里面存放的,就是那块堆内存的物理地址(引用)。user 消失了。此时堆里的那个 User 对象就成了“断线风筝”。GC 线程扫描到它没人要了,就会回收这块内存。溢出崩溃机制(以堆内存溢出为例):
new User(),并且每次 new 出来都放入一个全类级别的 static List 中。static List(生命周期极长)依然死死抓着这一堆 User 对象引用。static List 引用的对象,还是杀不掉。OutOfMemoryError: Java heap space。为了让你深刻理解,这里给出产生各类溢出的标准实验代码。你可以直接在本地运行,配合 JVM 参数体验崩溃的瞬间。
JVM 配置参数: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
(限制最大堆内存为 20M,且溢出时自动导出 Dump 文件)
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());
}
}
}
JVM 配置参数: -Xss128k (将每个线程的栈容量缩小到 128K)
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;
}
}
}
JVM 配置参数: -XX:MaxMetaspaceSize=10m (限制元空间大小为 10M,JDK8及以上有效)
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
}
}
}
误区一:只要报 OOM,一定是用 Java 堆内存太大导致的。
/Java heap space,才是堆问题。如果是 /Metaspace,是元空间问题(类加载太多);如果是 /Unable to create new native thread,是系统级别的线程数或者栈空间达到了上限;如果是 /Direct buffer memory,则是 NIO 使用的通过 Unsafe 分配的堆外系统内存泄露了。误区二:字符串常量池(StringTable)一直都在方法区里。
String.intern() 滥用在现代 Java 中会导致堆溢出,而不是元空间溢出。误区三:Java 有超强的 GC 机制,不可能像 C++ 那样发生内存泄漏。
ThreadLocal 用完忘记 remove),GC 就永远不敢回收它,这同样会造成内存泄漏。懂了原理之后,你就要在实际的服务器部署、代码编写和故障排查中把这些知识用起来。这是一名初级开发走向资深必须要养成的习惯:
启动脚本规范:
-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 文件所在目录,把它下载到本地电脑。dump 文件。