资讯详情

JVM运行时数据区全景:栈溢出与堆OOM的排查地图

📅 2026/10/24 3:26:15 | 华诺云谱 👁 阅读
JVM运行时数据区全景:栈溢出与堆OOM的排查地图
做Java开发这些年要说哪个知识点最绕不开JVM运行时数据区一定排在前三。不管是面试还是线上排查你迟早会和它打交道——程序跑着跑着报StackOverflowError堆内存莫名触发OutOfMemoryError或者元空间被动态生成的类撑爆。这篇文章是我基于实际调试经验整理的运行时数据区全景把每块区域里存什么、什么时候出错、出错了怎么定位一次讲清楚。适合刚接触JVM的新手理解整体结构也适合写过一段时间Java、想系统梳理异常处理思路的开发同学参考。与其说它是一篇原理科普不如说是一份排障地图按图索骥大部分内存问题都能找到入口。1. 运行时数据区全景先画一张内存地图很多人在学JVM的时候第一反应就是背口诀线程私有的是程序计数器、虚拟机栈、本地方法栈线程共享的是堆、方法区。背下来不等于理解。我自己更愿意把运行时数据区理解成Java程序运行时的“内存房间”每个房间有固定分工和独立容量谁出问题会通过不同的异常信号报警。你不需要把每个英文单词都刻进脑子但必须知道哪块区域归谁管、出了异常往哪查。为什么要先从整体地图开始因为线上遇到的大部分内存问题表现形式都是OutOfMemoryError但异常提示后面的附注完全不同Java heap space、Metaspace、Direct buffer memory、GC overhead limit exceeded。这些附注就是你判断“哪一个房间爆了”的最直接线索。把地图刻在脑子里看到附注的那一刻你的排查方向就定了不会像无头苍蝇一样乱试参数。1.1 线程私有区域程序计数器、虚拟机栈、本地方法栈先看程序计数器。它是当前线程执行字节码的行号指示器分支、循环、跳转、异常恢复、线程切换都需要它帮忙记录“执行到哪一行了”。每个线程都有一个独立的程序计数器各记各的互不干扰。需要注意的是执行Java方法时计数器记录的是正在执行的虚拟机字节码地址执行本地方法时计数器值是Undefined。这块区域非常小只存一个地址信息不会动态扩容所以在JVM规范里它是唯一不会抛OutOfMemoryError的区域。平时排查内存问题几乎不用管它但理解它能帮你把线程模型串起来。然后是虚拟机栈。每个方法调用都会创建一个栈帧栈帧里装着局部变量表、操作数栈、动态链接、方法返回地址。栈帧可以理解成一张“方法执行任务单”方法压栈就是任务单被放上桌面方法返回就是任务单被拿走。桌面空间是有限的任务单叠得太高就会倒对应就是栈溢出。虚拟机栈的异常有两种如果线程请求的栈深度大于虚拟机允许的最大深度抛StackOverflowError如果栈支持动态扩展但申请不到足够内存抛OutOfMemoryError。HotSpot虚拟机里栈空间不支持动态扩展所以绝大多数情况下你看到的只有前者。本地方法栈就是为native方法服务的。HotSpot虚拟机直接把本地方法栈和虚拟机栈合并成了一块所以平时我们很少单独讨论它。但有个常见报错要提一嘴创建线程时如果本地内存不足可能报unable to create new native thread这本质上属于操作系统无法给新线程分配栈空间的范畴和本地方法栈沾边但又不完全是一回事排查时要跳开栈本身去看系统线程数限制。1.2 线程共享区域堆与元空间堆是JVM管理的最大一块内存几乎所有对象实例和数组都在这里分配GC的主战场也在这里。为了方便回收堆内部会分成新生代和老年代新生代里还能再分出Eden区和Survivor区。绝大多数短命对象都会在新生代“出生即死亡”活过几轮GC之后才进入老年代。堆的大小可以动态扩展默认的初始大小是物理内存的1/64最大大小是物理内存的1/4但线上环境几乎都会显式设置-Xms和-Xmx避免运行时频繁扩缩容。方法区在JDK8之后换了实现方式变成了元空间但“方法区”这个概念仍然保留在JVM规范里。元空间存储类的元信息类型信息、字段、方法、常量、静态变量、JIT编译产物。和堆最大的区别是元空间使用本地内存默认上限受操作系统可用内存约束而不是受-Xmx约束。运行时常量池是方法区的一部分保存类文件中的常量池信息和符号引用。字符串常量池比较特殊JDK7开始被移到了堆里后面讲异常时这个历史差异特别容易把人绕晕。1.3 容易被忽略的直接内存直接内存不属于JVM运行时数据区的规范范围但它是NIO框架的基础。在JDK8及之前NIO的DirectByteBuffer通过Unsafe直接分配堆外内存不经过堆所以-Xmx管不到它。JDK9之后有了Foreign-Memory Access等新能力但直接内存在很多高性能通信场景里依然是标配。这块内存用多了会报OutOfMemoryError: Direct buffer memory。某个模拟项目X里我用过一个自研的报文解析模块每个请求都创建一个ByteBuffer.allocateDirect()缓冲区却忘记在finally里释放。堆内存看起来一切正常GC也特别平静但进程的RSS常驻内存一路疯涨最后整个服务无响应。这种问题不看堆要看堆外所以排查时候一定要记得把直接内存纳入视野。整个运行时数据区的基本划分我用一张表整理一下区域JDK8后实现线程归属主要存储内容典型异常提示程序计数器无私有字节码行号无虚拟机栈栈私有栈帧、局部变量表等StackOverflowError本地方法栈合并至虚拟机栈私有native方法调用线程创建失败等堆堆共享对象实例、数组Java heap space方法区元空间共享类元信息、运行时常量池Metaspace直接内存堆外共享NIO缓冲区Direct buffer memory2. 栈溢出的本质与复现StackOverflowError的不只是递归在异常排查里StackOverflowError是最好定位的一类因为异常堆栈会直接告诉你“在哪一行不断重复”。但好定位不代表好解决我见过不少团队把这个错误归咎于“递归太深”一股脑调大-Xss结果问题没根除反而带来了新的性能隐患。要真正搞定它得先弄明白栈帧里到底发生了什么。2.1 栈帧里到底装了啥前面说过一次方法调用压入一个栈帧。局部变量表存放方法参数和方法内部定义的局部变量注意long和double类型会占用两个槽位方法参数也会被算进去。操作数栈是字节码指令的临时运算空间数值计算、方法调用的参数传递都是在这里压栈弹栈。动态链接保存的是指向运行时常量池的引用方便方法调用时做符号引用到直接引用的解析。方法返回地址则是方法执行完之后回到调用方该继续执行的位置。这四者共同决定了一个栈帧占用多大内存。局部变量越多、操作数栈越深单个栈帧就越大。在栈总大小不变的前提下单个栈帧越大允许压入的栈帧数量就越少。所以两个人用同样的默认-Xss1m跑同样的递归逻辑可能一个人的栈深度是五千另一个人只有两千差别就在于方法里声明的变量数量和类型。这个细节很多人没意识到但它在“为什么我调大了栈还是溢出”的问题里非常关键。2.2 用一段代码把栈压爆定位栈溢出最直接的方法就是自己亲手复现一次。下面这段代码足够简单但非常有代表性public class StackOverflowDemo { private static int depth 0; public static void main(String[] args) { try { recurse(); } catch (StackOverflowError e) { System.out.println(栈溢出递归深度 depth); } } private static void recurse() { depth; recurse(); } }用默认参数跑我这边测出来的递归深度大约是几千到一万多每次运行可能还会略有差异因为JIT编译在不同阶段会改变栈帧的布局。如果你把recurse()方法里加上一个很大的局部变量数组深度会明显下降这就是单帧变大的直接证据。2.3 调整-Xss能解决问题吗栈大小通过-Xss参数配置比如-Xss512k、-Xss2m。需要说明的是线程栈使用的是本地内存-Xss设置的栈大小对每个线程都生效。如果你开几百个线程又把-Xss从1m调到2m光线程栈就可能多吃掉几百MB的内存严重时直接触发unable to create new native thread。我整理过不同栈大小下的递归深度参考数据不同的JVM版本和平台会有浮动但量级可以参考栈大小递归深度参考简单递归方法128k数百256k数千512k数千到一万1m默认一万到两万2m两万到四万调大-Xss很多时候只是让崩溃点来得更晚一些。如果是业务代码里无意识的循环调用比如对象A调用对象B对象B再调用对象A调多大栈都没用迟早还是爆。更合理的做法是写迭代、限制递归深度、检查调用链里有没有循环依赖。只有那些确实需要深递归且无法改写的算法比如某些树形结构遍历才值得用牺牲线程栈内存的方式来换取深度。2.4 定位栈溢出的实战套路第一看异常堆栈最前面那一帧找到反复出现的方法用grep把堆栈里重复的方法名拉出来。第二判断是无限递归还是单帧过大无限递归的堆栈通常只有几个方法在反复循环单帧过大则表现为堆栈里方法很多、各不相同。第三用jstack打印线程快照核对对应线程当前执行到了哪个方法。第四回到代码里去查集合、对象的相互引用尤其警惕无终止条件的递归遍历。模拟项目X里遇到过一起实际问题一个对象图里存在循环引用某个工具类在做JSON序列化时递归嵌套没有做深度保护导致StackOverflowError。这类问题经常发生在实体互相持有对方引用的场景解决方式一般是序列化框架配置循环引用处理策略或者在业务上切断双向引用。3. 堆内存异常OutOfMemoryError的几种面孔堆是OutOfMemoryError出现频率最高的地方。但“堆OOM”并不是一种表现同一个堆里有好几种不同的报错方式每一种背后的原因和排查侧重都不一样。把这些面孔全部认清遇到问题才不会慌乱。3.1 Java heap space最常见的大仓库爆仓java.lang.OutOfMemoryError: Java heap space是最直白的堆OOM提示意思是堆里已经无法再分配新对象了。触发原因无非三类一次性申请了一个大对象比如超大数组、业务代码内存泄漏导致老年代塞满、频繁创建生命周期过长的对象导致堆不断被耗尽。用一段代码快速复现public class HeapOOMDemo { public static void main(String[] args) { Listbyte[] list new ArrayList(); int index 0; while (true) { // 每次分配 512KB list.add(new byte[512 * 1024]); System.out.println(已分配第 (index) 块); } } }启动时加上-Xms16m -Xmx16m很快就能看到Java heap space报错。这里我把-Xms和-Xmx设为完全相同的值这是线上很常见的实践好处是JVM启动后直接锁定堆大小避免运行期频繁扩容带来的性能抖动。有些团队为了方便会把两个值设得不一样比如初始256m、最大2g这在流量忽高忽低的场景下问题不大但对追求稳定的服务来说尽量保持一致更省心。3.2 GC overhead limit exceeded另一种形态的堆耗尽代码不变把堆调得更小一些比如-Xms10m -Xmx10m你可能会先遇到另一个报错GC overhead limit exceeded。这个错误是JVM的自我保护机制当GC回收效率极低、近乎死循环时JVM认为“与其这样慢性死亡不如直接报错”。简单说GC时间占比超过98%回收到的堆内存占比不足2%JVM就会触发这个异常。出现这个报错常见原因有两类堆相对业务需求来说确实太小代码里有内存泄漏或对象创建峰值导致GC每次只能回收一点点。遇到它很多人第一反应是调大堆但调大堆只能缓解背后的内存黑洞不挖出来换个时间点还会爆。正确做法是开GC日志看回收后的剩余空间再配合堆转储分析对象分布。3.3 数组长度超限Requested array size exceeds VM limit还有一种相对少见的报错是Requested array size exceeds VM limit意思是申请数组时请求的长度超过虚拟机允许的最大值。比如你试图创建一个长度接近Integer.MAX_VALUE的数组JVM会直接拒绝。这看起来像一个防御性错误但实际业务里也会遇到比如从配置文件读了一个错误的数组长度、数据量估算失误。排查时先确认是不是代码里有个变量传了异常值再去考虑JVM层面是否限制了最大数组长度。3.4 堆OOM排查方法按顺序做完整套堆OOM的排查路径我习惯按固定顺序走一遍而不是一上来就dump堆。先开GC日志。JDK8可以用-verbose:gc -Xloggc:gc.logJDK9之后用-Xlog:gc*:filegc.log。GC日志能告诉你GC频率、单次GC耗时、回收前后堆使用量这是判断“堆太小”还是“代码有泄漏”的第一手证据。然后用jstat -gcutil pid 1000 5连续观察几次GC统计看看Eden区和老年代使用率的变化曲线。如果老年代使用率只增不减基本可以断定有对象一直在被引用、无法回收。接着做对象分布快照jmap -histo:live pid | head -30能快速看到当前堆里数量最多的对象类型。这一步很有用如果排名靠前的全是某个业务实体对象那么嫌疑就指向了创建这些对象的地方。最后有条件再做堆转储jmap -dump:formatb,fileheap.hprof pid用MAT打开重点看Leak Suspects和Dominator Tree。MAT会帮你把“谁占着最大的内存”算出来配合代码里持有这些对象的集合基本就能锁定根因。模拟项目X里有个典型的案例一个库存统计模块某开发者为了方便用static Map当缓存key是不断变化的商品IDvalue是商品快照数据没有清理策略也没有过期时间。流量一涨Map越攒越大最后堆被撑爆。用MAT看Dominator Tree的时候排名第一的就是这个Map实例。这种问题的修复不是调大堆而是引入Caffeine这类带淘汰策略的本地缓存或者直接把缓存改成按业务维度分片清理。压测时把堆调到崩溃阈值再调回合理值你会更清楚系统的真实容量边界。4. 方法区、元空间与常量池容易混淆的另一类OOM堆OOM大家普遍比较熟悉元空间OOM就经常被搞混了。不少人看到OutOfMemoryError就以为堆满了直接去看堆dump查了半天没结果再去翻GC日志才发现原来是元空间爆了。要避开这个坑必须把元空间、运行时常量池、字符串常量池这三者的关系理清楚。4.1 元空间OOM动态生成的类才是主凶JDK8之后方法区由元空间实现默认最大空间受系统可用内存限制也就是“没有上限”。没有上限不代表永远不会报错当动态生成的类越来越多把本地内存吃光就会抛java.lang.OutOfMemoryError: Metaspace。最常见的触发场景是CGLIB代理、反射动态生成类、热部署反复加载类。有些框架在运行期会给每个业务对象生成代理类如果框架设置不当每一次调用都可能生成一个新类而类元数据一旦加载到元空间正常情况下不会再被回收。下面用CGLIB复现一下public class MetaspaceOOMDemo { public static void main(String[] args) { while (true) { Enhancer enhancer new Enhancer(); enhancer.setSuperclass(SampleClass.class); enhancer.setUseCache(false); enhancer.create(); } } static class SampleClass { } }配合-XX:MaxMetaspaceSize64m跑不了多久就会看到Metaspace OOM。这里的关键是setUseCache(false)让CGLIB每次都生成全新的类。假如没有这个设置CGLIB会复用缓存的类反而不容易复现。这也提示了一个排查方向看到元空间OOM先去查是不是有框架在无限生成代理类以及类加载器是否被长期持有导致类无法卸载。4.2 运行时常量池与字符串常量池的历史差异运行时常量池在方法区里存放类文件中的常量池信息与符号引用。字符串常量池在JDK7开始从方法区移入堆这个变动直接改变了异常表现。在JDK6时代不断intern新的字符串会把永久代撑爆报PermGen space。到了JDK7及以后同样的代码换成Java heap space。public class InternDemo { public static void main(String[] args) { ListString list new ArrayList(); int i 0; while (true) { list.add(String.valueOf(i).intern()); } } }如果为了复现JDK6时代的错误你需要专门跑老版本环境。但在当前绝大多数线上环境这个代码会以堆OOM收场。理解这段历史的意义在于排查时看到异常提示里的区域名称不要过度依赖教科书里旧版本的行为先用当前JVM版本确认区域归属再对症下药。4.3 类加载器与元空间泄漏元空间OOM还有一种隐蔽场景类加载器泄漏。典型情况是应用容器反复部署、热加载每次部署都会创建新的类加载器并加载大量类。如果旧的类加载器仍被某个线程的ThreadLocal或者动态代理持有它加载的类就无法被回收元空间使用量只增不减。排查元空间增长先看类加载统计jstat -class pid可以观察已加载类数量变化配合-XX:TraceClassLoading -XX:TraceClassUnloading可以把每个加载/卸载的类打出来。如果看到大量类被反复加载却没有卸载日志就重点检查所有持有类加载器引用的地方。元空间本地内存一旦耗尽系统会先出现频繁swap进而拖垮整个进程比堆OOM更难恢复所以不能大意。4.4 一块表区分各类区域异常为了在实际排查时快速对照我把容易混淆的异常和区域整理成了速查表异常提示所属区域常见触发场景优先排查方向PermGen space永久代JDK7及以前字符串量大、动态代理多老版本兼容、参数调整Metaspace元空间JDK8及以后CGLIB、反射、热部署类加载器、动态生成类Java heap space堆大对象、泄漏、堆过小堆转储、对象分布Direct buffer memory直接内存NIO未释放、分配过大ByteBuffer、堆外监控unable to create new native thread本地内存线程数超限、栈过大线程数、-Xss、系统限制5. 一套通用的内存异常排查流程与常见误区有了区域划分和异常识别基础最后要沉淀下来的是一套可以重复执行的排查流程。我踩过几次坑之后发现很多内存问题之所以耗时长不是因为问题本身难而是排查路径不对东看一眼西看一眼最后才回到正确的轨道上。5.1 先定性再定位按顺序走这六步第一步看异常全名和附注。StackOverflowError、Java heap space、Metaspace、Direct buffer memory这个附注直接告诉你区域。第二步确认JVM参数。用jinfo -flags pid或者启动脚本核对-Xms、-Xmx、-Xss、-XX:MaxMetaspaceSize等关键参数防止有人改过配置。第三步看系统层指标CPU、内存、swap、进程RSS排除掉“JVM本身没报错但系统内存已被吃光”的情况。第四步开GC日志和必要的Trace参数定位是GC效率问题还是某个区域持续增长。第五步针对对象类型或类加载情况做定向分析比如jmap -histo:live或jstat -class。第六步如果需要做堆转储或类加载轨迹分析结合业务代码确认根因。这个顺序的精髓是先定性后定位先判断是配置问题、代码问题还是系统问题再决定是调参数、改代码还是优化部署。很多人倒过来一上来就dump堆结果dump出来的文件几百兆打开后却找不到重点。5.2 五个常见误区第一个误区是“无脑加内存”。堆OOM就调大-Xmx元空间OOM就调大-XX:MaxMetaspaceSize。如果真是代码问题加内存只是把崩溃时间往后推代价是机器压力更大、故障影响面更广。第二个误区是“只看jmap -histo的数量而不看类型”。一个byte[]实例可能占据几百MB排名第二的业务对象几百万个也不如它占得多必须结合真实内存占用看。第三个误区是“忽略直接内存”。堆外内存不归堆管堆dump看不出问题要盯着RSS和Native Memory Tracking。第四个误区是“把元空间OOM当堆OOM查”。看到OutOfMemoryError就去做堆转储转储完了分析半天没结论回头才发现是Metaspace异常。第五个误区是“生产环境不预埋诊断参数”。等出了OOM再想办法现场已经很难还原。5.3 两个非常实用的预防性参数强烈建议所有线上Java服务都加上这两个参数-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/app-oom.hprof第一个参数让JVM在堆OOM发生时自动生成堆转储文件第二个参数指定转储文件的位置。很多团队等到真出了OOM才临时用jmap去dump但那时候进程可能已经卡死或者被重启了根本dump不下来。有这两个参数至少能保住一次故障现场。对于元空间OOM也可以追加-XX:TraceClassLoading -XX:TraceClassUnloading这样即使进程崩溃也能从日志里看到类加载轨迹。GC日志参数也一并配上JDK8用-verbose:gc -Xloggc:gc.logJDK9之后用-Xlog:gc*:filegc.log。这些都是“平时不起眼故障时救命”的配置。5.4 一份可以直接抄的启动参数模板最后给一份我常用的Java服务启动参数模板适用于绝大多数中后台应用java -Xms4g -Xmx4g \ -Xss1m \ -XX:MaxMetaspaceSize512m \ -XX:UseG1GC \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/logs/app-oom.hprof \ -Xlog:gc*:file/data/logs/gc.log:time,uptime,level \ -jar app.jar这里有几个地方值得说一下-Xms和-Xmx保持一致避免运行期扩容-Xss1m对绝大多数业务线程足够没必要改大G1GC是JDK8之后的默认垃圾回收器参数简洁适合大堆场景。元空间512m的上限给一般微服务足够用能有效阻止异常情况下元空间无限膨胀。当然具体数值要根据业务调整但模板的骨架完全可以直接抄。我自己这几年排障下来最大的一个心得是先确认是哪个区域的内存爆了再谈调参和改代码。很多人上来就想着加内存其实有时候只是代码把对象的生命周期拉得太长。JVM给每块区域都设计了独立的容量和对应的异常信号充分利用这些信号比凭感觉猜测靠谱得多。最后分享一个小技巧线上压测之前务必先把GC日志和堆转储参数打开否则真的OOM之后再想复现现场成本会高出好几倍。希望这些从坑里爬出来的经验能帮你少走一点弯路。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑