JVM StackOverflowError与OutOfMemoryError:栈和堆内存的排查调优实战
发布时间:2026/9/16 21:28:21 锦皓数字建站

先别急着调大 JVM 参数。很多刚接触 Java 的同事一看到StackOverflowError和OutOfMemoryError就开始无脑加内存结果往往是该解决的没解决反而把线上服务搞得频繁 Full GC。这两个报错放在一起看特别有迷惑性——一个叫“栈溢出”一个叫“内存溢出”仿佛都是内存不够用但其实它们各自对应的那块内存区域完全不同处理思路也完全相反。这篇文章想跟你把一件事彻底讲透递归太深报的 StackOverflowError和对象太多报的 OutOfMemoryError根源确实是两块不同的内存。一块是线程私有的栈内存一块是线程共享的堆内存。理解这两块内存各自管什么、怎么分配、怎么耗尽你才能在看报错的第一时间就判断出问题出在哪儿而不是拿着 JVM 参数瞎试。搞懂这两个报错对任何写 Java 的人来说都属于基本功。不管你是刚学递归的初学者还是已经在维护线上系统的老手这篇文章都会给你一套能直接抄作业的思路从 JVM 内存模型讲起到手把手复现两个报错再到线上排查手段和参数调优最后聊几个我踩过的坑。1. 先把“两块内存”放进 JVM 全局地图里看1.1 为什么只要你盯住栈和堆就够了JVM 的内存区域比大多数人想象的要细官方规范里列出了程序计数器、虚拟机栈、本地方法栈、堆、方法区以及常量池等好几个区域。但在实际开发里你真正需要时刻放在心里的只有两个虚拟机栈通常直接叫栈和堆。原因很简单这两个区域是程序运行时的主角。每个线程执行方法时方法调用的“现场”都记录在线程的虚拟机栈里所有 new 出来的对象实例都住在堆里。程序计数器只是一块很小的记录地址的空间本地方法栈普通业务代码接触不到方法区存放的是类元信息。真正会在日常开发里被撑爆、让你看到上面那两个 Error 的几乎只有栈和堆。把这两块分开记是理解一切 JVM 内存问题的起点。栈是线程私有的堆是线程共享的。栈是管“方法怎么执行”的堆是管“对象怎么存放”的。这句话你记住后面所有分析都能顺着它展开。1.2 栈像一串临时工位堆像一个共享仓库我平时给新人讲的时候喜欢用一个比喻栈是每个线程自己的临时工位堆是整栋楼共用的仓库。你每调用一个方法系统就会在你的工位上摆一张“任务卡片”这张卡片上写着这个方法的参数、局部变量、中间计算结果还有当前执行到哪一行代码。这张任务卡片专业术语叫栈帧Stack Frame。方法调用是层层嵌套的——A 调 BB 调 CC 调 D——所以这些卡片是叠在一起的最上面的卡片就是当前正在执行的方法。方法一返回这张卡片就被丢弃工位又空了。而堆这个仓库存放的是所有 new 出来的对象实体。局部变量本身是一张卡片上的一个格子格子里放的不是对象本体而是一个引用类似门牌号。你通过门牌号去仓库里找对应的对象。仓库是公用的谁都能往里面搬东西但也正因为公用里面堆满了没人清理的废料时整栋楼就转不动了。这个比喻对应到标题里的两个报错就特别清晰栈溢出是工位上的任务卡片叠得太高超出了天花板堆溢出是仓库被对象塞满了而且垃圾清理工GC怎么清都清不出空间。2. StackOverflowError不是内存不够而是栈被“递归”塞满了2.1 一个栈帧里到底装了什么想搞懂为什么递归会爆栈得先看清一张任务卡片栈帧的构成。一个栈帧通常包含四块内容局部变量表、操作数栈、动态链接、方法返回地址。局部变量表存放方法的参数和方法内部定义的局部变量。注意基本类型直接存值引用类型存的是指向对象的“门牌号”。操作数栈方法执行时真正干活的地方。比如计算a b虚拟机会把 a 和 b 压入操作数栈执行加法指令再把结果压回去。它是方法内部的临时计算区。动态链接指向运行时常量池中该方法的引用用于支持方法调用过程中的符号引用解析。方法返回地址方法执行完后回到调用者的哪一条指令继续执行。一个栈帧的大小不是固定的它取决于方法的参数个数、局部变量数量、操作数栈深度。但每一个栈帧都会占内存这是确定的。栈内存的总量由 JVM 参数-Xss控制HotSpot 在 Linux x64 下默认通常是 1MB。也就是说一个线程的栈帧占用总和不能超过这个值。你可以这样理解工位的大小是固定的 1MB每一张任务卡片都要占一点面积。卡片太多、叠得太高就会顶到天花板。2.2 递归为什么会搞炸栈递归之所以是 StackOverflowError 的头号杀手不是因为递归有什么特殊魔力而是因为它的调用深度在运行期是没法静态判断的。每一次“自己调自己”都会生成一个全新的栈帧压到当前线程的栈上。比如这段代码public class StackOverflowDemo { public static void main(String[] args) { recursion(1); } static void recursion(int n) { System.out.println(第 n 次调用); recursion(n 1); } }每进入一次recursion方法JVM 就往当前线程的栈上压入一个栈帧方法不返回栈帧不释放。当压入的栈帧总大小超过-Xss设置的值时JVM 就抛StackOverflowError。实测下来默认 1MB 栈大小的情况下像我上面这样简单的递归一般调用几千次到一万多次就扛不住了具体次数取决于每个栈帧有多大。这里有个特别多初学者踩的坑以为栈溢出是“把内存用光了”。其实不是。StackOverflowError是当前只有一个线程的栈区域超限跟其他线程、跟堆都没关系。你哪怕机器有 64GB 内存一个线程的栈默认也就 1MB超了照样报错。所以调-Xmx对 StackOverflowError 完全没用你得调-Xss或者改写代码。2.3 哪些代码容易不知不觉搞到栈递归不一定都像上面这样“裸奔式无限调用”很多看起来很正常的算法在极端输入下也会爆。最典型的就是快速排序的递归实现。快排的平均递归深度是 O(log n)听起来很安全但如果你每次选的基准值都是最大或最小值比如对一个已经排好序的数组做经典快排递归深度就退化成 O(n)。我试过对一个 10 万元素的有序数组跑单轴快排直接 StackOverflowError。处理办法是改用随机基准、三数取中或者干脆用非递归实现栈模拟/循环。还有二分树的深度遍历、JSON 解析器对深层嵌套对象递归解析、文件目录的递归扫描以及热词里提到的“递归最小二乘法”“verilog 递归二分树”这类算法一旦输入规模上来递归深度都可能成为一个隐藏炸弹。用递归前最好先估算一下最坏情况下的递归深度以及你的栈大小能不能扛住。3. OutOfMemoryError堆里的对象“只进不出”3.1 对象在堆里的完整生命周期堆这块区域是 JVM 管理的最大一块内存。它的默认大小受两个参数控制-Xms初始堆大小-Xmx最大堆大小。64 位服务器上默认-Xmx通常是物理内存的 1/4。所有new出来的对象实例和数组原则上都在堆上分配先不考虑逃逸分析后面聊到再说。一个对象的生命周期大致是这样的加载类元信息在堆里划出一块内存存放实例字段然后在栈帧里的局部变量表放入这个对象的引用。接下来对象被业务代码使用等它不再被任何地方引用了就成了垃圾。GC 线程会在合适的时机把这些垃圾回收掉释放堆内存。听起来很美好对吧但这套机制有个致命的弱点它假设对象会“死”。如果对象一直有人引用着GC 就永远动不了它。当新对象还在源源不断产生、旧对象又占着坑不放时堆就会满GC 拼命回收也收不出空间最终抛OutOfMemoryError: Java heap space。3.2 对象太多怎么爆的堆让我用一个非常经典的例子说明。假设你用一个静态集合来缓存订单信息public class OrderCache { private static final ListOrder ORDERS new ArrayList(); public static void addOrder(Order order) { ORDERS.add(order); } }如果业务代码不停地往里 add而又没有任何机制移除不再需要的订单那么这个列表就会一直膨胀。每个Order对象里的字段可能还引用着其他对象形成一大片对象图全都堆在堆里不肯走。等到堆耗尽JVM 就会抛OutOfMemoryError。热词里的“内存泄露”“对象数组去重”“map集合膨胀”都是这个问题的变体。特别是缓存、静态集合、监听器列表、数据库连接没关、IO 流没关这些场景很容易产生“对象只进不出”的效应。还有另一类情况更隐蔽你确实不再用这些对象了但某个长生命周期的对象还持有它们的引用。比如一个全局的单例里存了线程池执行上下文、ThreadLocal 变量没有 remove、观察者模式里被观察者一直持有观察者引用。这些都是业务代码层面的内存泄漏堆会被一点点蚕食。3.3 你真的分清了吗两个报错的快速判断我把这两个报错的关键差异整理成一个表排查的时候可以直接对照对比项StackOverflowErrorOutOfMemoryError出错的内存区域线程私有的虚拟机栈线程共享的堆出错原因方法调用层级过深栈帧超过栈内存上限对象太多堆空间耗尽GC 无法回收是否影响整个进程只影响当前线程其他线程通常不受影响可能影响整个 JVM 进程触发场景递归、深层方法调用链大量创建对象、内存泄漏、缓存膨胀错误消息关键字无额外消息就是 StackOverflowErrorJava heap space、GC overhead limit exceeded 等调参方式调大-Xss但治标不治本调大-Xmx或优化代码/GC根本解决改递归为循环/栈模拟/限制深度排查对象积压原因修复内存泄漏调整 GC判断的一个快捷方法就是看错误消息报错的类名是StackOverflowError那基本就是栈的事报错消息里带Java heap space那是堆的事还有一类GC overhead limit exceeded意思是 GC 花了大量时间却收不回多少内存99% 也是堆内存不够用属于 OOM 的变体。还有启动时报的insufficient memory热词里有通常是 JVM 在启动时连初始堆都分配不出来多见于物理内存不足、虚拟内存被限制、容器内存配额太小的情况又是另一套排查方向。4. 动手复现两个报错看看 JVM 到底怎么倒4.1 快速复现 StackOverflowError复现栈溢出最简单。上面那段recursion(n)就是。但为了观察每个栈帧的“堆叠”过程可以给方法加一个局部变量数组人为放大栈帧这样递归次数会更低问题更明显public class StackOverflowDemo { public static void main(String[] args) { try { recurse(1); } catch (StackOverflowError e) { System.out.println(栈溢出发生); throw e; } } static void recurse(int depth) { // 用一个 1KB 的数组放大每个栈帧的体积 byte[] block new byte[1024]; System.out.println(depth depth); recurse(depth 1); } }运行后你会看到 depth 打印到某个值就中断了然后抛StackOverflowError。如果你想验证-Xss的影响可以这样运行java -Xss256k StackOverflowDemo递归深度会显著变小改成java -Xss2m StackOverflowDemo深度会变大。这个实验能直观地告诉你栈帧大小固定栈内存越大、能压的栈帧就越多。但不管你怎么加内存只要递归不停止终究会溢出所以加参数只是应急不是解药。4.2 快速复现 OutOfMemoryError堆溢出的复现也简单。写一段一直创建对象的代码并保证对象能被引用住不让 GC 回收public class OutOfMemoryDemo { public static void main(String[] args) { Listbyte[] list new ArrayList(); while (true) { // 每次分配 10MB list.add(new byte[10 * 1024 * 1024]); } } }运行之前建议先用-Xmx限制一下堆大小比如java -Xms64m -Xmx64m OutOfMemoryDemo这样几十个小循环就会爆不用等太久。你会看到报错信息Exception in thread main java.lang.OutOfMemoryError: Java heap space这个报错信息里明确写了Java heap space说明就是堆满了。顺带一提如果一直循环创建对象但每个对象用完就丢JVM 反而可能一直不 OOM因为 GC 在不停地回收。真正干爆堆的往往是对象被强引用持有GC 的扫荡停不下来。这也解释了为什么 OOM 的排查重点永远在“谁持有了这些对象”。4.3 线上排查三板斧复现只是开胃菜线上遇到 OOM 怎么查我自己的三板斧是先看日志拿错误消息再用 jstat 看 GC 状态最后 dump 堆文件做分析。第一板斧拿到完整错误信息。如果是Java heap space确定是堆问题如果是GC overhead limit exceeded还是堆问题但往往发生在堆还没完全满、但 GC 已经近乎无效的时候非常值得警惕如果是Direct buffer memory那就是堆外内存问题不在今天这篇文章的主线里但需要知道有这回事如果是Unable to create new native thread那是线程数超了系统限制跟堆栈都无关。第二板斧用jstat -gcutil pid 1000观察 GC 频率和堆使用率。如果 Old 区一直在 99% 以上Full GC 频繁触发那基本坐实了堆里活着大量对象。第三板斧也是最重要的用jmap -dump:formatb,fileheap.bin pid导出堆转储线上操作要谨慎大堆导出本身影响性能可能的话配合阿里 Arthas 或亚马逊的堆分析工具做在线排查然后用 MAT 或 JProfiler 分析。打开 dump 后直接看 Dominator Tree谁占的内存最大、谁引用了它答案一眼就能看出来。5. 把两块内存调成适合你的业务5.1 栈大小到底怎么设置很多文章一上来就让你调-Xss但我想先泼一盆冷水生产环境里绝大多数 StackOverflowError 的正确解法是改代码不是调参数。把递归改成循环、用显式栈模拟递归、限制递归深度才是根治。调大-Xss就像把鞋子调大一号能缓解一时但如果脚还在继续长早晚还会顶破。但确实有些场景必须调栈大小。比如你在写一个深度优先遍历库递归深度可控且经过严格估算此时栈太小导致业务无法展开再比如你用了某些依赖库内部自带了深层递归你没法改它的代码。这时候可以给应用设置更大的-Xss比如-Xss2m或-Xss4m。有一点必须提醒-Xss设置的是每个线程的栈大小不是整个 JVM 的栈总大小。线程数很多的服务如果把每个线程的栈都调很大光线程栈占用的内存就会非常可观。一个 300 线程的应用-Xss1m就是 300MB 内存-Xss4m直接飙到 1.2GB。这还没算堆和元空间。所以调栈大小之前一定先算一下你的线程数。通常建议线程栈不要超过 2m业务服务用默认 1m 或 512k 反而更稳。5.2 堆大小和 GC 策略怎么搭配堆参数虽然没有标准答案但有个底线认知不要为了“不 OOM”就把堆调得无限大。堆越大GC 单次停顿时间越长特别是 CMS 和 G1 时代大堆带来的延迟敏感度完全不一样。我见过很多团队一遇到 OOM 就把-Xmx从 2G 调到 8G结果 OOM 确实少了但接口 RT 涨了好几倍因为 Full GC 从每秒几次变成每次好几秒——这个代价有时候比 OOM 本身还可怕。合理的堆大小要结合业务对象的存活周期来定。核心原则是尽量让短命对象在年轻代就死掉别让它们跑进老年代。所以年轻代大小、晋升阈值、GC 收集器都要搭配着调。举个例子如果你的服务是读多写少、大量临时对象一次性用完就丢那就让年轻代相对大一些减少晋升如果你的服务有较大的长生命周期缓存那老年代要预留足够空间。我个人在配置 Docker 容器里的 Java 应用时习惯显式指定-Xms和-Xmx相等避免运行期堆动态伸缩带来的性能抖动同时设置-XX:UseG1GCJDK 11 基本是默认并给-XX:MaxGCPauseMillis设定具体值比如 100ms 或 200ms让 GC 停顿控制在业务可接受范围。但这只是起步配置具体数值必须结合压测结果调整。关于 JVM 在容器里的内存感知也需要留个心眼。老版本 JDK 在容器里看不到 cgroup 限制可能按物理机内存去算默认堆大小结果容器配额 1G它却以为内存有 32G直接把堆撑爆或者被 OS 杀掉容器退出码 137。这就是热词“java: outofmemoryerror: insufficient memory”的常见来源之一。解决办法是升级到 JDK 8u191或 JDK 10默认就能识别 cgroup或者显式设置-Xmx别让 JVM “自作聪明”。6. 常见误判与实战避坑6.1 StackOverflowError 不只有递归一种来源递归是栈溢出最常见的来源但绝对不是唯一来源。有时候你没有写任何递归只是普通方法调用链特别深也会爆栈。比如模板引擎渲染一个多层嵌套的对象图、ORM 框架级联加载多层关联实体、调用链特别长的 AOP 代理都可能在运行时产生很深的调用栈。还有更隐蔽的同一个方法在多个场景下被调用而某些场景的调用栈本身就深。我遇到过一个大 query 方法里面套了好几个服务调用每个服务调用又经过几层代理最后整个调用链深度达到上千层。天热值不够的时候没事某个异常分支多调用了几层就爆了。排查这类问题不用猜直接jstack pid看线程栈一眼就能看出栈帧在哪一层出问题。6.2 OutOfMemoryError 的报错消息决定排查方向OutOfMemoryError 是一个统称在 HotSpot 里有好几种常见变体报错消息不同排查方向天差地别。我梳理几个常见的报错消息含义常见场景Java heap spaceJava 堆空间耗尽对象太多、内存泄漏、堆太小GC overhead limit exceededGC 光干活不收效堆接近耗尽GC 效率极低Metaspace元空间耗尽动态生成类过多、类加载器泄漏Direct buffer memory堆外直接内存耗尽NIO 使用 ByteBuffer 没释放Unable to create new native thread无法创建本地线程线程数超系统限制insufficient memoryJVM 启动或运行时无法获取足够内存容器配额过小、物理内存不足其中最容易被误判的是Metaspace。很多人以为 OOM 就是堆的事看到 Metaspace 报错就不认识。这类问题多出现在用了 CGLIB 动态代理、大量动态生成类、热部署反复加载 class 的场景里。排查方向是看类加载统计和元空间使用率而不是看堆。6.3 一个最容易被忽略的坑把 OOM 当成 CPU 打满的并发问题线上服务的故障往往不是单一的内存问题而是连锁反应。JVM 堆快满的时候GC 线程会疯狂运转导致整机 CPU 飙升、接口大面积超时。这时候如果你只看监控面板最先看到的是 CPU 100%很容易误判成“流量突增、线程池不够用”然后去加机器、加线程。结果就是死循环加线程 - 更多线程分配对象 - 堆压力更大 - GC 更拼命 - CPU 更高。所以遇到 CPU 高、接口慢的故障先花两分钟看一眼 GC 日志和堆使用率再下结论。sc 层面看jstat -gcutil的三秒采样基本能定位是不是 GC 压力导致的。这个习惯救过我很多次也省过很多次半夜加班。再分享一个调参避坑经验别单独调大年轻代容量解决 OOM。年轻代变大意味着老年代变小如果对象最终要晋升到老年代老年代反而更容易满。堆的各个分区是此消彼长的关系动任何一个参数前先想清楚你希望改变的是“对象的存活路径”还是“对象的清理效率”否则很容易按下葫芦浮起瓢。最后再说两句实在话文章到这里我把标题里“两块内存”的底层逻辑、触发机制、复现实验、调参手段和排查套路都过了一遍。有些东西书上讲得理论味道太重我尽量用实际踩过的坑去解释希望你在自己的项目里踩到类似问题时不至于抓瞎。如果你现在正被 StackOverflowError 或 OutOfMemoryError 折磨我个人的建议是先别急着加参数把报错信息完整抄下来判断到底属于哪块内存区域然后该 dump 堆就 dump 堆该看线程栈就看线程栈。把问题定位到“具体哪个方法、哪一行、持有哪个引用”这个粒度再动手改代码或调参数。这两类错误90% 都不是靠加内存根治的而是靠找到那个“挡住去路”的引用关系或调用路径。另一个很实用的经验在代码里主动给可能深递归的方法加一个深度计数器超过阈值直接抛业务异常比让它自己爆栈要优雅得多。这个习惯在维护长期演进的项目时特别值钱因为今天看着安全的递归深度半年后可能就被别人的一次改动踩爆了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。