JVM 内存模型详解:堆、栈、方法区、直接内存
聊 JVM 内存,有人会甩一堆术语,但你真的搞清楚这几个区域了吗?
我见过太多案例:有人往静态变量里塞数据,最后堆撑爆了;有人递归调用太深,栈挂了。要我说,定位内存泄漏之前,得先把内存模型掰扯清楚。
一、内存分区,底层逻辑
JVM 的内存可以切成几个独立区域,说白了就是按用途分。为什么要分?
控制不同数据的生命周期,方便回收。
二、堆(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:吃不到栈的线程确实见过。
日志里报的是 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 memory | NIO 分配过多、未及时释放 | pmap、Netty 内部统计 |
七、实战要点
- 堆溢出:先调大堆,再 dump 分析引用链。我曾经一个缓存用户 Session 的项目,就是靠 dump 找到泄漏的集合。
- 栈溢出:看异常堆栈是不是你自己的代码。如果是,检查递归或循环调用;如果是框架,检查切面。
- 方法区溢出:JDK 8 后比较少,但用了 Groovy、CGLIB、JSP 要格外注意。
- 直接内存溢出:NIO 框架下配置 MaxDirectMemorySize,加上监控。
最后发现是用户发消息太快,底层 ChannelOutboundBuffer 分配了海量直接内存又没及时释放。
解决办法:加 flow control。