JVM 内存模型详解:堆、栈、方法区、直接内存

聊 JVM 内存,有人会甩一堆术语,但你真的搞清楚这几个区域了吗?

我见过太多案例:有人往静态变量里塞数据,最后堆撑爆了;有人递归调用太深,栈挂了。要我说,定位内存泄漏之前,得先把内存模型掰扯清楚。

一、内存分区,底层逻辑

JVM 的内存可以切成几个独立区域,说白了就是按用途分。为什么要分?
控制不同数据的生命周期,方便回收。

JVM 运行时内存分区 堆 (Heap) 新生代 Eden Survivor0 Survivor1 老年代 长期存活对象 方法区 / 元空间 类信息 · 常量 · 静态变量 · JIT 代码 Java 虚拟机栈 栈帧 1 栈帧 2 … 栈帧 N 直接内存 (Direct Memory) NIO · Unsafe.allocateMemory 说明 线程私有:栈、程序计数器 线程共享:堆、方法区 注意 直接内存不算 JVM 规范内的区域 但 OOM 时一定要查它

二、堆(Heap):多数 OOM 的源头

堆是所有线程共享的区域,对象和数组在这分配。嗯,这里要留意一下:堆是 GC 的主战场。
我遇到过不少情况,明明代码看着没问题,线上监控一抓,堆使用率像坐火箭一样往上涨。

2.1 堆分代

为什么分代?加速 GC。我刚开始做调优时也不理解,觉得分不分代能差多少。后来压测了一下,不分代时 Full GC 的频率能把人逼疯。

分区比例存储对象GC 频率
新生代(Young)默认 1/3 堆刚创建的对象高(Minor GC)
老年代(Old)默认 2/3 堆长期存活的对象低(Major GC / Full GC)
元空间(Metaspace)-类元数据(替代永久代)取决于 -XX:MaxMetaspaceSize
调优建议: 调整新生代比例时,我习惯先压测再改。
比如 -Xmn2g 设置新生代大小为 2G。改完观察 Minor GC 停顿时间。

2.2 堆溢出实战

一个经典案例:缓存数据全放静态 Map 里,时间一长,老年代满了。

// 模拟堆溢出,别在线上跑!
java -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError

配上这个参数,OOM 时自动 dump 堆快照。我调过很多次,没有一次是冤枉的——不是集合被撑爆,就是某个对象持有引用链不释放。

三、栈(Stack):线程的私有记忆

每个线程配一块栈。栈里存什么?局部变量、操作数栈、方法出口。说白了,方法调用栈帧。

栈里最容易出事的有两个:

  • StackOverflowError:递归过深或方法调用层级太大。
  • OutOfMemoryError:吃不到栈的线程确实见过。
我记得有一次排查: 一个同事写了个无限递归,返回 JSON 时循环嵌套,堆栈层级直接破千。
日志里报的是 StackOverflowError,定位到 com.fasterxml.jackson.databind 序列化代码。
改递归为迭代后,就稳了。

3.1 栈帧结构

组成部分作用
局部变量表存放方法参数、局部变量
操作数栈做运算时用的临时存储
动态链接指向运行时常量池的引用
方法返回地址方法执行完回到哪继续
避坑指南: 我曾经给人擦过一种奇怪的栈溢出——循环调用自己写的工具类,但代码看起来没事。
最后发现是 AOP 切面里又调了同一个方法,形成死循环。
检查栈溢出时,先看调用链路里有没有切面或代理。

四、方法区(Method Area):元空间前身

方法区存储:类信息、常量、静态变量、JIT 编译后的代码。

JDK 8 之前叫做永久代。我个人习惯先确认 JDK 版本再定位问题。
永久代有大小限制,元空间直接使用本地内存,更灵活。

4.1 方法区溢出

以前遇到过 CGLIB 动态生成类太多的案例:

-XX:MaxMetaspaceSize=128m

如果这个值设太小,代理类一多,直接 OOM。
打印元空间使用情况的命令:

jstat -gcmetacapacity <pid>

典型的表现:

  • 加载类过多(比如 Spring + CGLIB 疯狂生成代理)。
  • 字符串常量池溢出(JDK 7 之前常见)。
  • JSP 频繁编译。
可以用这个参数设置默认大小:
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m
元空间初始 256m,不够再扩容,超出即抛出异常。

五、直接内存(Direct Memory):藏在规则之外

直接内存不算 JVM 规范定义的内存区域,但用的是 native 堆外内存。
NIO、Netty 用得比较多。我在项目里被这玩意儿坑过——Java 堆正常,但 OS 内存涨疯了。

5.1 为什么会撑爆?

直接内存通过 ByteBuffer.allocateDirect() 或 Unsafe 分配。
GC 只负责回收 DirectByteBuffer 的引用对象,不负责立即释放底层内存。等到 Young GC 或 Full GC 才回收。

ByteBuffer buffer = ByteBuffer.allocateDirect(10 * 1024 * 1024);
// 分配 10 MB 直接内存

如果一直分配,又不触发 GC,就很危险。

5.2 配置参数

-XX:MaxDirectMemorySize=1g

如果不设,默认等于堆最大值。设这个参数要谨慎,我曾经在生产环境忘记调它,结果 NIO 扛不住并发。

实测方法:
用 pmap 或 top 看 RES 列,对比堆使用量,超出 Java 堆的部分就是直接内存 + 线程栈。
如果堆使用量 2G,但系统内存占用了 6G,嗯,直接内存嫌疑很大。

六、对比:各区域的 OOM 特征

区域OOM 错误信息常见原因排查工具
堆Java heap space对象泄漏、集合膨胀jmap、MAT、JProfiler
栈unable to create new native thread / StackOverflowError线程过多、递归太深jstack、线程转储
元空间Metaspace类加载过多、JSP 编译jstat -gcmetacapacity
直接内存Direct buffer memoryNIO 分配过多、未及时释放pmap、Netty 内部统计

七、实战要点

  • 堆溢出:先调大堆,再 dump 分析引用链。我曾经一个缓存用户 Session 的项目,就是靠 dump 找到泄漏的集合。
  • 栈溢出:看异常堆栈是不是你自己的代码。如果是,检查递归或循环调用;如果是框架,检查切面。
  • 方法区溢出:JDK 8 后比较少,但用了 Groovy、CGLIB、JSP 要格外注意。
  • 直接内存溢出:NIO 框架下配置 MaxDirectMemorySize,加上监控。
曾经踩过一个坑: 一个 Netty 应用,堆内存报 OOM,我 dump 下来,堆里面只有少量 DirectByteBuffer 引用,但实际内存早被撑爆了。
最后发现是用户发消息太快,底层 ChannelOutboundBuffer 分配了海量直接内存又没及时释放。
解决办法:加 flow control。