2、Java内存区域模型:堆、栈、方法区、程序计数器、本地方法栈,以及直接内存
说到OOM,咱们得先把Java的地盘摸清楚。
你想想看,一个Java程序跑起来,内存到底分成了几块?哪些地方最容易爆掉?
我个人习惯把运行时数据区画成一张图,贴在我的工位上。每次排查OOM,第一件事就是对着这张图,定位到底哪个区域出了问题。
这张图,就是Java虚拟机的内存区域模型。
2.1 程序计数器(PC寄存器)
这是Java内存中最「小」的一块。
说白了,它就干一件事:记录当前线程执行到哪一行字节码了。
为什么需要它?因为Java是支持多线程的。线程切换时,得保存「上次看到哪了」。切换回来才能接着干。
嗯,这里要注意:程序计数器是唯一不会发生OOM的区域。JVM规范里没规定它的OutOfMemoryError。
我在项目里从没遇到过它出问题。但面试时经常问——「哪个区域没有OOM?」答案就是它。
2.2 Java虚拟机栈
每个线程都有一个私有的栈。
栈里装的是啥?栈帧。
每调用一个方法,就会创建一个栈帧压入栈中。方法执行完,栈帧弹出。
栈帧里包含什么?
- 局部变量表:存放方法参数和局部变量
- 操作数栈:字节码指令的「工作台」
- 动态链接:指向运行时常量池的引用
- 方法出口:正常返回或异常退出的地址
我遇到过最典型的栈OOM场景——递归调用没写终止条件。日志里疯狂报java.lang.StackOverflowError。
还有一种情况:线程数过多。每个线程的栈默认是1MB(不同JDK版本有差异)。你创建5000个线程,光是栈就要吃掉5GB内存。
避坑指南:我曾经接手过一个压测项目,开了3000个线程处理请求,结果应用直接挂了。调了半天,最后定位到是栈空间太大。解决方案:减少线程数 + 调小-Xss。
// 递归导致栈溢出
public class StackOverflowDemo {
private int depth = 0;
public void recursiveCall() {
depth++;
recursiveCall(); // 没有终止条件,疯狂入栈
}
public static void main(String[] args) {
new StackOverflowDemo().recursiveCall();
}
}
2.3 本地方法栈
这东西跟虚拟机栈很像。
区别在于:Java方法走虚拟机栈,Native方法走本地方法栈。
什么叫Native方法?就是被native关键字修饰的,底层由C/C++实现的方法。
比如System.currentTimeMillis()、Thread.start()底层都调用了本地实现。
在实际项目中,直接遇到本地方法栈OOM的情况不多。但如果你用了JNI(Java Native Interface),或者集成了一些第三方C库,那就要当心了。我曾经见过一个图像处理系统,因为JNI层内存泄漏,最后本地方法栈爆了。
2.4 Java堆
这是咱们排查OOM的「主战场」。
所有对象实例和数组都在堆上分配。它也是Java内存管理中最大的区域。
堆分为新生代和老年代,这个后续章节会细讲。
堆的配置参数:
-Xms:初始堆大小-Xmx:最大堆大小-Xmn:新生代大小-XX:NewRatio:新生代与老年代比例
你肯定会问:堆OOM最常见的原因是什么?
我直接说结论:对象泄漏。创建了对象,用完了却不释放。GC回收不掉,堆空间一点点被蚕食,最终爆掉。
个人经验:遇到堆OOM,别急着加内存。先用jmap dump出堆快照,再用MAT或JProfiler分析。我曾经发现一个系统,90%的堆空间被一个静态HashMap占着。里面存了上千万条缓存数据,永远不清理。
// 堆OOM模拟
public class HeapOOMDemo {
static class BigObject {
private byte[] data = new byte[1024 * 1024]; // 1MB
}
public static void main(String[] args) {
List<BigObject> list = new ArrayList<>();
while (true) {
list.add(new BigObject()); // 不断创建大对象,堆很快会被填满
}
}
}
2.5 方法区(含运行时常量池)
方法区存的是「类」级别的信息。
比如:类的全限定名、字段描述、方法描述、字节码、常量池、静态变量等等。
JDK 8之前,方法区还有一个名字叫「永久代」(PermGen)。
JDK 8之后,被元空间(Metaspace)取代了。
| 对比项 | 永久代(PermGen) | 元空间(Metaspace) |
|---|---|---|
| 所在位置 | JVM堆内存内 | 本地内存(堆外) |
| 默认大小 | 有限,通常64MB-256MB | 理论受本机内存限制 |
| 常见OOM | 大量动态生成类(如CGLIB) | 同样会出现,但更灵活 |
| 配置参数 | -XX:PermSize / -XX:MaxPermSize | -XX:MetaspaceSize / -XX:MaxMetaspaceSize |
实话说,我在早期项目中遇到过永久代OOM。当时用CGLIB做AOP动态代理,生成了大量代理类。每次重启就能撑一会,过几天又爆了。后来升级到JDK 8,改用元空间才彻底解决。
2.6 直接内存(Direct Memory)
直接内存不是JVM运行时数据区的一部分。它是堆外内存。
但为什么我要放进来讲?
因为它也能引发OOM。而且很多人第一次碰到时会摸不着头脑——明明堆空间剩很多,怎么还报OOM呢?
直接内存主要通过java.nio.DirectByteBuffer来分配。NIO、Netty这些高性能框架大量使用它。
它的好处是:避免了堆内内存与本地内存之间的数据拷贝。读写速度快。
坏处是:不受JVM堆大小控制。它受本机物理内存限制。
避坑指南:我曾经监控一个Netty服务,发现RSS(物理内存)持续攀升。但是GC日志显示堆一直正常。很奇怪对吧?最后发现是DirectByteBuffer没有释放,每次请求都分配一小块直接内存,日积月累,系统被OOM杀死。解决方案:设置最大直接内存-XX:MaxDirectMemorySize,并在代码里显式调用cleaner.fakeClean()释放。
// 直接内存分配示例
public class DirectMemoryDemo {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) {
List<ByteBuffer> list = new ArrayList<>();
while (true) {
ByteBuffer buffer = ByteBuffer.allocateDirect(_1MB); // 每次分配1MB直接内存
list.add(buffer);
}
}
}
运行这段代码时,加上-XX:MaxDirectMemorySize=100m -Xmx256m。你会发现:OOM出现了,但堆内存根本没满。这就是直接内存爆了。
小结
| 区域 | 是否会发生OOM | 常见原因 | 排查方式 |
|---|---|---|---|
| 程序计数器 | 否 | — | — |
| Java虚拟机栈 | 是 | 递归太深、线程数过多 | 检查日志StackOverflowError;减少线程数,调小-Xss |
| 本地方法栈 | 是 | JNI层泄漏 | 配合Native内存分析工具 |
| Java堆 | 是(最高频) | 对象泄漏、大对象分配 | jmap dump + MAT分析 |
| 方法区/元空间 | 是 | 大量动态生成类 | 设置-XX:MaxMetaspaceSize;启用跟踪类加载 |
| 直接内存 | 是 | NIO/Netty泄漏 | 设置MaxDirectMemorySize;使用Native memory tracking |