资讯动态

JVM运行时数据区与内存溢出排查实战:从StackOverflowError到OutOfMemoryError

发布时间:2026/10/24 7:13:15 来源:尧图企业网站定制
做了这么多年 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) { Listbyte[] 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:formatb,fileheap.bin PID导出堆文件。如果担心线上服务暂停可以在启动参数里提前加上-XX:HeapDumpOnOutOfMemoryErrorJVM 在抛出 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 较多时需要设置默认等于-Xmx4.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 的内存管理本身是一个系统区域之间会相互影响单独看任何一项都容易误判方向。从区域出发再结合工具和日志才能真正把这些异常背后的逻辑理清楚。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑