做了这么多年 Java 开发,我发现自己每次排查线上内存问题,最花时间的不是看日志,而是搞清楚异常背后到底是哪块内存出了事。JVM 运行时数据区是面试必考的基础,更是调优和排查的必修课,很多新手一看到StackOverflowError和OutOfMemoryError就直接搜"怎么加内存",结果加完内存问题依旧,反而把服务越调越慢。这篇文章我会站在实战视角,把运行时数据区的五块内存逐一拆开讲清楚,再把对应的异常现场摆出来,最后给你一套可以直接照着做的排查流程和参数配置方案。不管你是刚入门的 Java 开发,还是正在啃 JVM 调优的进阶选手,这份总结应该都能给你提供一些参考。
1. 运行时数据区:先把职责边界划分清楚
JVM 运行时数据区本质上就是 Java 程序运行期间,虚拟机向操作系统申请到的一整块内存,再按用途划分成几个部分。很多人一上来就死记硬背各个区域的存放内容,我倒建议你先抓住一个核心分类:线程私有还是线程共享。这个分类决定了每种内存区域的回收时机和异常表现,也决定了你排查问题时该关注哪类指标。
1.1 两类内存的核心区分逻辑
线程私有的区域有三个:程序计数器、虚拟机栈、本地方法栈。线程共享的区域有两个:堆、方法区。还有一块容易被忽略的"第六区"——直接内存,它虽然不属于 JVM 运行时数据区的标准组成部分,但在 NIO 场景下同样会引发OutOfMemoryError,这是你在排查时不能漏掉的地方。
线程私有区域的特点是生命周期跟线程完全一致。线程启动时创建,线程结束时整个区域随之回收,完全依赖系统自身完成,不需要垃圾收集器介入。线程共享区域则不同,它们的存活周期是整个 JVM 运行期间,正因为共享,才需要 GC 去处理里面不断堆积的对象。
1.2 为什么先记这张内存图
如果你在脑海里建立不起来运行时数据区的结构图,后面所有的调优参数都会对不上号。举个例子:-Xmx控制的是堆的上限,-Xss控制的是每条线程虚拟机栈的大小,-XX:MaxMetaspaceSize控制的是方法区的上限。如果你连堆和栈都混在一起,看到-Xmx就觉得能解决所有内存问题,那就容易走弯路。记这张图的意义在于,一旦线上报出特定异常,你第一反应就能定位到对应区域,然后直接去查相关参数和 GC 行为,排查效率会高很多。
2. 五块区域逐一拆解:结构、原理与异常触发点
2.1 程序计数器:唯一不会抛 OOM 的区域
程序计数器,也叫 PC 寄存器,是一块很小的线程私有内存,作用是保存当前线程正在执行的字节码指令地址。分支、循环、跳转、异常处理、线程恢复这些基础操作,都依赖这个计数器来完成。在多线程场景下,CPU 在多个线程之间切换,每个线程都要有自己的程序计数器,才能在线程被重新调度回执行权时,准确回到之前中断的位置。
这块区域是运行时数据区里面唯一不会出现OutOfMemoryError的地方,也不需要垃圾回收。原因在于它的容量需求是稳定且有限的,只要 JVM 能启动,程序计数器的空间基本不会有压力。所以面试时如果有人问你"哪块区域不会 OOM",答案就是程序计数器。
2.2 虚拟机栈:每个方法调用都伴随一个栈帧
虚拟机栈描述的是 Java 方法执行的内存模型。每启动一个线程,JVM 就会为该线程分配一个虚拟机栈;每调用一个方法,就会往栈里压入一个栈帧;方法执行完毕,对应的栈帧就会弹出。栈帧里面装着局部变量表、操作数栈、动态链接和方法返回地址。
局部变量表存放的是方法的入参和内部定义的局部变量,容量在编译期就确定好了,这也是为什么局部变量的个数不会在运行期随意变化。操作数栈则像一个临时工作台,字节码指令从局部变量表加载数据到操作数栈,计算完再存回局部变量表或写入堆里的对象。动态链接用来在运行期把符号引用转换为实际引用,这个转换是 Java 多态能够实现的基础机制之一。方法返回地址则记录了方法结束后该回到调用方的哪条指令位置。
虚拟机栈的容量可以通过-Xss参数调整。栈大小越大,允许的方法调用层数就越深,也就是"栈深"越大。当线程请求的栈深度超过了 JVM 允许的最大深度,就会抛出StackOverflowError;当栈容量在动态扩展时无法申请到足够内存,则会抛出OutOfMemoryError。
2.3 本地方法栈:容易被忽视的旁路区域
本地方法栈服务的对象是 Native 方法,也就是用 C/C++ 实现、通过 JNI 调用的方法。在 HotSpot 虚拟机中,本地方法栈和虚拟机栈直接合并了,所以你在实际排错时往往感知不到它的存在,但这不代表它可以忽略。很多跟底层 IO、系统调用相关的操作,比如文件读写、Socket 连接,底层就有可能需要走 Native 方法栈。
本地方法栈同样会抛出StackOverflowError和OutOfMemoryError。当你看到栈溢出的日志,但 Java 调用栈并不深时,可以留意一下项目中是否有调用 Native 库的代码路径,排查方向要扩宽,不要死盯着递归不放。
2.4 堆:对象的主战场,绝大多数 OOM 都发生在这一块
堆是 JVM 管理的最大一块内存,也是 GC 的主要工作区域。几乎所有的对象实例都在堆上分配。为了提升回收效率,堆被分成了新生代和老年代,新生代内部又细分为 Eden 区和两个 Survivor 区(S0 和 S1)。新创建的对象先进 Eden,经历若干次 Minor GC 还存活的对象会被移到 Survivor,存活年龄达到阈值后最终进入老年代。
堆的初始大小和最大大小分别由-Xms和-Xmx控制。生产环境里我强烈建议把这两个值设成相同,避免运行时频繁扩容和缩容带来性能抖动。当堆内存被对象占满,GC 又无法回收出足够的新空间时,就会抛出java.lang.OutOfMemoryError: Java heap space。这是我遇到频率最高的一类内存异常,也是下面要重点演示的现场。
2.5 方法区与运行时常量池的演进
方法区用来存储类的元数据、静态变量、常量以及即时编译器编译后的代码。在 JDK 8 之前,方法区的实现叫永久代,使用 JVM 堆内存;JDK 8 开始,永久代被移除,方法区的实现改为元空间(Metaspace),直接使用本地内存。
这个演进的意义很大。原来永久代的大小是受限的,大量动态生成类或者持续加载类,很容易把永久代打满,报OutOfMemoryError: PermGen space。换成元空间后,默认情况下上限只受本地内存总量限制,一般不会这么容易爆。但这也带来了另一个问题:如果类加载器发生泄漏,或者动态代理类无限生成,元空间同样会被耗尽,抛出OutOfMemoryError: Metaspace。运行时常量池也在方法区内,Class 文件中的常量池在类加载后会被放进这里,String.intern()操作产生的字符串常量就和它直接相关。
3. 异常现场实录:亲手把三个经典错误跑出来
只看理论很难建立体感,这一节我用几个最简单的小程序,亲手把常见的 JVM 异常跑出来,让你直观看到每种异常长什么样、边界在哪里。
3.1 StackOverflowError:递归不是唯一触发路径
先写一个最简单的无限递归,虚拟机栈会很快被栈帧塞满。
public class StackOverflowDemo { public static void main(String[] args) { recurse(0); } private static void recurse(int depth) { System.out.println("depth = " + depth); recurse(depth + 1); } }运行之后,终端会刷出一长串 depth 输出,最终出现类似下面的异常:
Exception in thread "main" java.lang.StackOverflowError at StackOverflowDemo.recurse(StackOverflowDemo.java:10) at StackOverflowDemo.recurse(StackOverflowDemo.java:10) ...这就是虚拟机栈容量被耗尽的表现。栈帧里局部变量表越大、操作数栈越深,单个栈帧占用的空间就越大,同样的-Xss下能嵌套的方法调用层数就越少。比如一个方法定义了 100 个局部变量,它能递归的层数肯定远低于只定义两个变量的方法。
这里值得强调的是,递归只是触发栈溢出最典型的场景,并不是唯一场景。方法之间的正常嵌套调用过深也会触发,比如前面说的 Native 方法栈异常。另外,-Xss设置得特别小,哪怕普通代码也能栈溢出。我见过有项目为了省内存把-Xss压到 128k,结果每次启动没跑几步就报StackOverflowError,这个坑后面还会提到。
3.2 OutOfMemoryError: Java heap space:堆被打满的完整链路
堆溢出的模拟非常简单,不断往集合里塞数据,攒到堆无法容纳为止。
import java.util.ArrayList; import java.util.List; public class HeapOOMDemo { public static void main(String[] args) { List<byte[]> list = new ArrayList<>(); int count = 0; while (true) { list.add(new byte[1024 * 1024]); // 每次分配 1MB count++; System.out.println("已分配 " + count + " MB"); } } }用默认参数跑,等到堆空间不够时,会抛出类似信息:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space这个异常出现时,问题的本质不是"内存不够用",而是 GC 已经拼尽全力,仍然回收不出足够空间容纳新对象。要么是对象确实太多,要么是存在强引用让本应可回收的对象一直被拽住,形成内存泄漏。调参只是临时止血,找到泄漏根源才是关键。
我平时排查堆溢出,第一步是看 GC 日志,确认 GC 是否在频繁触发,以及每次回收后内存水位线是否在缓慢上升。如果 GC 后内存一直降不下去,很可能是存在内存泄漏;如果 GC 后能释放大量空间,但很快又满,那可能是业务并发太高、对象朝生夕灭,需要从分配策略和业务逻辑上优化。
3.3 Metaspace OOM:类加载器泄漏比想象中常见
元空间溢出不像堆溢出那么直观,通常出现在大量动态生成类或者反复加载类的场景里。比如 CGLIB 动态代理、反射生成类、JSP 热部署,如果类加载器不能被回收,元空间的使用量就会持续增长,直到把本地内存耗尽。
模拟示例可以循环生成代理类,不断往元空间塞类信息:
public class MetaspaceOOMDemo { public static void main(String[] args) { while (true) { // 模拟动态类生成逻辑 Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(Foo.class); enhancer.setUseCache(false); enhancer.create(); } } }运行一段时间后,报错可能是OutOfMemoryError: Metaspace。这种问题最棘手的点在于,元空间默认没有上限,你只有在启动参数里加上-XX:MaxMetaspaceSize才能限制它。很多线上服务没配这个参数,等元空间悄悄涨到物理内存极限,系统反而先被拖垮。
所以我的建议是,线上一定显式设置-XX:MaxMetaspaceSize,别让元空间无限制增长。设一个合理上限后,就算类加载泄漏,服务也只会抛Metaspace异常,而不是把整台机器打挂,这给运维留了一条逃生通道。
4. 排查流程与调优参数:从日志到 Dump 的完整实操
光知道异常长什么样还不够,真正考验功力的是快速定位问题源头。下面分享一套我平时排查内存异常的固定流程,以及核心参数的使用心得。
4.1 实战排查五步走
第一步,先用jps -l找到目标 Java 进程的 PID。这个命令会打印当前机器上所有 Java 进程的启动类和 PID,比ps -ef | grep java更精准,能直接看到主类名,避免把不同的 Java 服务搞混。
第二步,用jstat -gcutil <PID> 1000观察 JVM 的 GC 情况,每秒打印一次新生代、老年代的使用率,以及 GC 次数和耗时。如果看到老年代使用率长期维持在 90% 以上,GC 频繁且每次回收后水位不降,基本可以判定存在内存泄漏。
第三步,通过jmap -heap <PID>或者jcmd <PID> GC.heap_info查看堆的实时概览,确认新生代、老年代、元空间当前各用了多少。这个信息能帮你区分异常到底是堆溢出还是元空间溢出。
第四步,导出堆转储快照。堆溢出场景下,用jmap -dump:format=b,file=heap.bin <PID>导出堆文件。如果担心线上服务暂停,可以在启动参数里提前加上-XX:+HeapDumpOnOutOfMemoryError,JVM 在抛出 OOM 后会自动生成 dump 文件,不用等现场被破坏才手动导出。
第五步,用 VisualVM 或者 MAT 打开 dump 文件。MAT 的 Leak Suspects 报告会直接列出疑似泄漏的根节点和占用最大的对象链,配合业务代码定位问题很快。如果你的堆里有大对象是缓存、静态集合,或者 ThreadLocal 没有清理,MAT 基本一眼就能看出来。
4.2 核心 JVM 参数速查
我把排障时最常用到的几个参数整理成一张表,方便你快速对照。
| 参数 | 作用区域 | 含义与建议 | 使用建议 |
|---|---|---|---|
-Xms | 堆 | 堆初始大小 | 生产环境建议与-Xmx一致,避免扩容抖动 |
-Xmx | 堆 | 堆最大大小 | 通常设为物理内存的 1/4 到 1/2 左右,视业务而定 |
-Xss | 虚拟机栈 | 每条线程的栈容量 | 默认一般是 512k 或 1m,不要轻易调太小 |
-XX:MaxMetaspaceSize | 方法区/元空间 | 元空间上限 | 显式设置,防止无限制增长打爆本地内存 |
-XX:+HeapDumpOnOutOfMemoryError | 堆 | OOM 时自动导出堆快照 | 线上建议开启,排障利器 |
-XX:MaxDirectMemorySize | 直接内存 | 直接内存上限 | 使用 NIO 较多时需要设置,默认等于-Xmx |
4.3 调参与业务场景的匹配逻辑
调参不是越大越好,核心逻辑是:根据业务预估的对象存活量决定堆大小;根据线程总量决定栈大小;根据动态类生成的频率决定元空间大小。
举个例子,一个对内存敏感、需要高并发的微服务,如果业务对象量不大,设置 2g 堆绰绰有余,再大只会增加 GC 停顿时间。另一个需要缓存大量用户数据的服务,堆大小就该预留充足,同时要考虑对象进入老年代的速度,必要时调整新生代比例。看清楚业务形态再调参,才不会无脑加内存导致本地内存不足,最后被系统 OOM Killer 直接带走。
5. 常见问题与避坑经验:那些文档里不会写的教训
排查做多了,会发现很多问题并不是教科书上写的那么标准,这里专门聊聊我在项目中踩过的几个坑,以及对应的排查方法。
5.1 线程过多也可能导致 OOM
有个常见误区:只要报OutOfMemoryError,就觉得是堆不够。但有的时候控制台打出的异常信息是java.lang.OutOfMemoryError: unable to create new native thread,这其实是操作系统的线程数限制被突破,或者本地内存不足以再创建新的线程栈。每创建一个线程,JVM 就要从本地内存划出一块空间给它的虚拟机栈,线程数太多是会把整个系统的可用内存吃光的。
这种问题的排查方向就完全不一样了,要看的是进程的线程数上限(ulimit -u)、操作系统层面的线程统计,以及代码里是否有不断新增线程的逻辑。-Xss调小在这里可以缓解内存压力,让同样多的线程占用更少的栈空间,但它只是治标;治本要靠线程池复用、限制最大线程数。
5.2 把 -Xss 调太小引发的连锁问题
有次我接手一个系统,启动没几分钟就在日志里刷StackOverflowError,查了业务代码没有任何递归,排查了很久才发现,是启动参数里为了省内存把-Xss设成了 128k。系统框架本身调用链就比较深,加上某些方法局部变量多,128k 根本不够用。把栈容量恢复到 512k 之后,异常立刻消失。
这也是个很好的教训:栈大小关乎单线程的方法调用深度,太小会让原本正常的方法链直接崩掉,太大则浪费虚拟内存,尤其是在线程数上万的应用里,栈空间是笔试量内存,要谨慎权衡。
5.3 元空间泄漏比堆泄漏更隐蔽
我曾经遇到一个长时间运行的服务,GC 表现一直正常,堆稳定,但系统内存使用率却在悄悄爬升。查遍堆 dump 也没发现大对象,直到看了jstat -gcutil里 Metaspace 指标,才发现元空间占用一直在涨。最终定位到是动态代理类生成逻辑里持有了 ClassLoader 引用,导致类加载器无法被回收,元空间被永无止境地吃内存。
这类问题在代码审查阶段很难发现,只有在压测或者巡检时持续观察元空间的增长曲线才能暴露。所以监控 Metaspace,跟监控堆使用率一样重要,千万别只盯 GC 日志或堆 dump。
最后再分享一个小技巧:如果你在排查时不确定异常发生在哪个区域,把线程栈信息(jstack)、GC 日志、内存 dump 三样东西同时拉出来对比,绝大多数情况下都能把问题定位到具体区域。JVM 的内存管理本身是一个系统,区域之间会相互影响,单独看任何一项都容易误判方向。从区域出发,再结合工具和日志,才能真正把这些异常背后的逻辑理清楚。