JVM StackOverflowError与OutOfMemoryError:栈和堆内存的排查调优实战
2026/9/16 21:28:16 网站建设 项目流程

先别急着调大 JVM 参数。很多刚接触 Java 的同事一看到StackOverflowErrorOutOfMemoryError就开始无脑加内存,结果往往是该解决的没解决,反而把线上服务搞得频繁 Full GC。这两个报错放在一起看特别有迷惑性——一个叫“栈溢出”,一个叫“内存溢出”,仿佛都是内存不够用,但其实它们各自对应的那块内存区域完全不同,处理思路也完全相反。

这篇文章想跟你把一件事彻底讲透:递归太深报的 StackOverflowError,和对象太多报的 OutOfMemoryError,根源确实是两块不同的内存。一块是线程私有的栈内存,一块是线程共享的堆内存。理解这两块内存各自管什么、怎么分配、怎么耗尽,你才能在看报错的第一时间就判断出问题出在哪儿,而不是拿着 JVM 参数瞎试。

搞懂这两个报错,对任何写 Java 的人来说都属于基本功。不管你是刚学递归的初学者,还是已经在维护线上系统的老手,这篇文章都会给你一套能直接抄作业的思路:从 JVM 内存模型讲起,到手把手复现两个报错,再到线上排查手段和参数调优,最后聊几个我踩过的坑。

1. 先把“两块内存”放进 JVM 全局地图里看

1.1 为什么只要你盯住栈和堆就够了

JVM 的内存区域比大多数人想象的要细,官方规范里列出了程序计数器、虚拟机栈、本地方法栈、堆、方法区(以及常量池)等好几个区域。但在实际开发里,你真正需要时刻放在心里的,只有两个:虚拟机栈(通常直接叫栈)和

原因很简单:这两个区域是程序运行时的主角。每个线程执行方法时,方法调用的“现场”都记录在线程的虚拟机栈里;所有 new 出来的对象实例,都住在堆里。程序计数器只是一块很小的记录地址的空间,本地方法栈普通业务代码接触不到,方法区存放的是类元信息。真正会在日常开发里被撑爆、让你看到上面那两个 Error 的,几乎只有栈和堆。

把这两块分开记,是理解一切 JVM 内存问题的起点。栈是线程私有的,堆是线程共享的。栈是管“方法怎么执行”的,堆是管“对象怎么存放”的。这句话你记住,后面所有分析都能顺着它展开。

1.2 栈像一串临时工位,堆像一个共享仓库

我平时给新人讲的时候,喜欢用一个比喻:栈是每个线程自己的临时工位,堆是整栋楼共用的仓库

你每调用一个方法,系统就会在你的工位上摆一张“任务卡片”,这张卡片上写着这个方法的参数、局部变量、中间计算结果,还有当前执行到哪一行代码。这张任务卡片,专业术语叫栈帧(Stack Frame)。方法调用是层层嵌套的——A 调 B,B 调 C,C 调 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 List<Order> 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) { List<byte[]> 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:format=b,file=heap.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:+UseG1GC(JDK 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% 都不是靠加内存根治的,而是靠找到那个“挡住去路”的引用关系或调用路径。

另一个很实用的经验:在代码里主动给可能深递归的方法加一个深度计数器,超过阈值直接抛业务异常,比让它自己爆栈要优雅得多。这个习惯在维护长期演进的项目时特别值钱,因为今天看着安全的递归深度,半年后可能就被别人的一次改动踩爆了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询