先别急着调大 JVM 参数。很多刚接触 Java 的同事一看到StackOverflowError和OutOfMemoryError就开始无脑加内存,结果往往是该解决的没解决,反而把线上服务搞得频繁 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 你真的分清了吗?两个报错的快速判断
我把这两个报错的关键差异整理成一个表,排查的时候可以直接对照:
| 对比项 | StackOverflowError | OutOfMemoryError |
|---|---|---|
| 出错的内存区域 | 线程私有的虚拟机栈 | 线程共享的堆 |
| 出错原因 | 方法调用层级过深,栈帧超过栈内存上限 | 对象太多,堆空间耗尽,GC 无法回收 |
| 是否影响整个进程 | 只影响当前线程,其他线程通常不受影响 | 可能影响整个 JVM 进程 |
| 触发场景 | 递归、深层方法调用链 | 大量创建对象、内存泄漏、缓存膨胀 |
| 错误消息关键字 | 无额外消息,就是 StackOverflowError | Java 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 space | Java 堆空间耗尽 | 对象太多、内存泄漏、堆太小 |
| GC overhead limit exceeded | GC 光干活不收效 | 堆接近耗尽,GC 效率极低 |
| Metaspace | 元空间耗尽 | 动态生成类过多、类加载器泄漏 |
| Direct buffer memory | 堆外直接内存耗尽 | NIO 使用 ByteBuffer 没释放 |
| Unable to create new native thread | 无法创建本地线程 | 线程数超系统限制 |
| insufficient memory | JVM 启动或运行时无法获取足够内存 | 容器配额过小、物理内存不足 |
其中最容易被误判的是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% 都不是靠加内存根治的,而是靠找到那个“挡住去路”的引用关系或调用路径。
另一个很实用的经验:在代码里主动给可能深递归的方法加一个深度计数器,超过阈值直接抛业务异常,比让它自己爆栈要优雅得多。这个习惯在维护长期演进的项目时特别值钱,因为今天看着安全的递归深度,半年后可能就被别人的一次改动踩爆了。