StackOverflowError和OutOfMemoryError,这两个报错应该没有一个Java后端不眼熟。一个常在递归写得太深或者无限循环调用时出现,另一个常在批量导入、缓存加载、大查询结果集时出现。很多人第一反应是“递归改循环”“堆调大一点”,但这两个错误的名字背后,根源其实是两块不同的JVM内存区域:线程私有的虚拟机栈,和线程共享的堆。把这两块内存搞明白,你才能真正判断是调-Xss还是调-Xmx,是改代码还是加机器。下面我用实际排查经历,把这两个错误拆开讲透。
1. 先拆开“两块内存”:JVM到底把东西放哪了
1.1 JVM运行时数据区快速扫盲
JVM在运行Java程序时会把自己管理的内存划分成好几个区域,主要就是线程私有的区域和线程共享的区域。线程私有的包括程序计数器、虚拟机栈、本地方法栈;线程共享的包括堆、方法区(JDK 8之后叫元空间)。程序计数器极小,基本不用管;本地方法栈给native方法用,也不是这次的主角。真正会频繁报错的就是虚拟机栈和堆。
你可以把JVM的内存想象成一个宿舍楼。每个线程是一个房间,房间里有自己的“桌面”(栈)。谁在房间里调用方法,就往桌面上叠一张“任务卡”,方法返回就把任务卡拿走。这个房间的空间是有限的,任务卡叠得太高就会从门口挤出去,报StackOverflowError。而堆是整个宿舍楼的公共仓库,所有对象实例都堆在仓库里,仓库满了就报OutOfMemoryError。一个房间不够往往是个别线程的递归问题,仓库不够通常是整体对象太多的问题。
1.2 StackOverflowError的出生地是“虚拟机栈”
虚拟机栈里存的是什么?栈帧。每次方法调用都会创建一个栈帧,里面包含局部变量表、操作数栈、动态链接、方法返回地址等。一个线程里方法调用链越长,栈帧就越多,占用的栈空间就越大。默认情况下,JVM会给每个线程分配一固定的栈大小,在HotSpot里通常用-Xss控制,Linux上常见默认值是1MB,也有512KB的版本。
如果递归调用没有正确的终止条件,或者数据深度本身大得离谱,栈帧就会一直往上压,直到超过线程栈的容量,JVM无法继续分配栈帧,就直接抛出StackOverflowError。比如一个最简单的:
public static void recurse() { recurse(); }没有任何退出条件,跑不了几秒钟必然栈溢出。但更常见的是“有条件的递归”在边界场景下失效,比如二叉树的深度特别深、JSON嵌套特别深、文件目录层级特别深。此时每个递归调用都会占一点栈,不管逻辑对不对,深度一超就炸。
1.3 OutOfMemoryError的出生地是“堆”
堆是JVM管理的最大一块内存,几乎所有对象实例和数组都在这里分配。Java的垃圾回收器负责清理不再被引用的对象,把堆空间腾出来。如果程序持续创建新对象,而旧对象因为各种原因无法被回收,堆的可用空间就会不断下降。当一次Minor GC或者Full GC之后,仍然无法获得足够空间分配新对象,JVM就会抛OutOfMemoryError,最常见的提示是Java heap space。
所以,对象太多报OutOfMemoryError,本质上就是“堆里活对象的总大小”超过了“堆能容纳的大小”。有一种情况是代码一次性创建太多对象,比如一次查询把千万条记录全部映射成Java对象放进了List;另一种是内存泄漏导致对象本来该被回收却回收不掉,比如全局Map里只放不删,静态集合被越用越大。两种情况的表象一样,修复思路完全不同。
2. 两类错误的排查技巧:别上来就调参
2.1 栈溢出定位实操:jstack看线程栈
遇到StackOverflowError时,最直观的办法是看线程栈。如果是测试环境能复现,直接看日志里的异常堆栈,它通常会打印出一长串重复的调用链,比如:
java.lang.StackOverflowError at com.demo.TreeUtils.dfs(TreeUtils.java:88) at com.demo.TreeUtils.dfs(TreeUtils.java:91) at com.demo.TreeUtils.dfs(TreeUtils.java:91) at com.demo.TreeUtils.dfs(TreeUtils.java:91) ...这种重复出现同一行的情况,基本就是递归没有正确收敛。如果线上已经出现,但日志里只有异常没有上下文,可以用jstack <pid>输出线程快照,对照日志时间点找到抛异常线程的栈轨迹。
定位时有一个容易被忽略的点:StackOverflowError不一定出现在代码的递归入口,它只是栈被压满时抛出,可能发生在任意一次方法调用上。所以查的时候不要死盯最后一个调用位置,要看整条栈里是不是有同一方法反复压栈。像我之前排查过一个XML递归解析器,外部传入的节点层级两千多层,代码里的递归方法没有深度保护,日志里清一色全是同一个parseNode方法,问题一目了然。
2.2 堆溢出定位实操:保留现场并用MAT分析
堆OOM就不能光看堆栈了,Java异常堆栈只能告诉你在哪个地方分配对象失败,但真正让堆满的“凶手”可能在别处。所以我在生产环境一定会给JVM加上这两个参数:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/dump一旦发生OOM,JVM会马上把当前堆转储到一个.hprof文件,这个文件就是堆的快照,里面记录了所有对象实例、引用关系、线程栈等。拿到文件后用MAT(Eclipse Memory Analyzer)打开,重点看Dominator Tree和Leak Suspects。
举一个真实案例:一个定时任务每天拉取数据并缓存到静态Map,Map的key是用户ID,value是用户的所有订单列表。最初用户量少没事,后来用户量涨到几十万,Map里的对象越来越多,但任务又每天往里面追加,没有清理旧数据。某天报OutOfMemoryError: Java heap space,我用MAT打开dump,发现一个ConcurrentHashMap对象占用了堆的85%,点击进去看到里面全是历史订单对象,问题立刻定位到是缓存没有设置淘汰策略。这就是典型的“对象太多超过堆容量”。
2.3 一张表看清各种OOM对应的内存区域
线上不只存在堆溢出,“OutOfMemoryError”其实是个统称,JVM在不同内存区域都可能抛出OOM。理解这些变体很有用,不要一看到OOM就想着调堆。
| 错误提示 | 内存区域 | 常见原因 | 排查方向 |
|---|---|---|---|
| Java heap space | 堆 | 对象太多、堆太小、内存泄漏 | dump分析、对象数量、GC日志 |
| GC overhead limit exceeded | 堆 | GC回收率极低,98%时间回收不到2%内存 | 堆大小、代码泄漏、存活对象异常 |
| Metaspace | 方法区/元空间 | 动态生成类太多、类加载器泄漏 | 检查自定义类加载器、CGLIB/反射使用 |
| Direct buffer memory | 堆外内存 | NIO/DirectByteBuffer使用过量 | 设置-XX:MaxDirectMemorySize,检查DirectBuffer使用 |
| unable to create native thread | 操作系统层面 | 线程数超限、内存不足 | ulimit、减少线程数、降低栈大小 |
| StackOverflowError | 虚拟机栈 | 递归过深、无限方法调用 | 检查递归、循环调用、调整-Xss |
其中StackOverflowError严格说不叫OutOfMemoryError,但它也属于“StackOverflowError: null”的一种内存不足类错误,所以很多人会把这两个摆在一起聊。从排查角度看,先确定报错标语属于哪块区域,再决定看堆dump还是线程栈,效率会高很多。
3. 实战:两个最常见的“打爆内存”场景修复全过程
3.1 递归改迭代:以树的深度遍历为例
有一个阶段我被分到一个自动生成目录树的任务,树的最大深度超过了一万层。最初实现用的是经典递归DFS:
public void dfs(TreeNode node, List<String> result) { result.add(node.getName()); for (TreeNode child : node.getChildren()) { dfs(child, result); } }单机测试时深度几千还可以,上线之后有几次数据异常,深度超过一万,直接StackOverflowError。这种场景下把-Xss调大确实能撑到更深,但栈空间占用是线性的,调大只是延迟爆炸,而且每个线程的栈都调大,相同内存下能起的线程数就变少。更稳妥的解法是把递归改成显式栈(迭代):
public void dfs(TreeNode root, List<String> result) { Stack<TreeNode> stack = new Stack<>(); stack.push(root); while (!stack.isEmpty()) { TreeNode node = stack.pop(); result.add(node.getName()); // 注意:这里需要逆序入栈,保证处理顺序与递归一致 List<TreeNode> children = node.getChildren(); for (int i = children.size() - 1; i >= 0; i--) { stack.push(children.get(i)); } } }这里用到的栈是堆上的对象,不再消耗线程的虚拟机栈空间。递归深度再大,只要树节点总数不超过堆大小,就不会出现StackOverflowError。有人会问:这不还是用了“Stack”吗?是的,但这是数据结构上的栈,不是JVM的调用栈。它不受-Xss限制,受-Xmx限制,二者是两码事。明确这一点,标题里的“两块内存”你就算真正理解了。
3.2 对象太多导致OOM:批量处理改分页/流式
另一个常见场景是导出数据。曾经有一个导出全部用户报表的功能,代码大概是这样的:
List<User> users = userMapper.selectAll(); // 查了整张表 List<UserReportVO> reportData = users.stream() .map(UserReportVO::from) .collect(Collectors.toList());用户量一开始20万没问题,后来涨到500万,selectAll把500万行数据一次性加载到堆里,每条记录再映射成一个VO对象,再加上List的底层数组扩容,堆直接爆炸。报错就是OutOfMemoryError: Java heap space。
正确的处理方式是分页查询或者流式读取,每次只让一小部分对象存活:
int pageSize = 1000; long lastId = 0; while (true) { List<User> pageUsers = userMapper.selectByPage(lastId, pageSize); if (pageUsers.isEmpty()) { break; } for (User user : pageUsers) { process(user); } lastId = pageUsers.get(pageUsers.size() - 1).getId(); }注意这里我用lastId而不是offset,因为深分页时limit offset越大,数据库扫描越慢,使用“滚页”方式对数据库和内存都更友好。每页处理完,pageUsers变成不可达对象,下一轮循环开始前可以被GC回收,堆里的存活对象始终只占一小部分。
这个案例告诉我们:对象多到超过堆容量时,优先考虑“节流”,而不是只知道扩大堆。堆扩到一定程度还会受服务器物理内存限制,而且堆越大,Full GC暂停时间可能越长,这是一种性能和容量的博弈。
3.3 参数调整的度:-Xmx、-Xss到底怎么设
虽然两个错误的根源是两块区域,但很多时候还是需要调参数来缓解。下面这几个参数是我日常调优最常用的:
| 参数 | 控制的内存 | 合理设置建议 |
|---|---|---|
| -Xms | 堆初始大小 | 建议和-Xmx设为相同值,避免运行期堆扩容抖动 |
| -Xmx | 堆最大大小 | 根据业务峰值和容器内存计算,一般不超过物理内存的70%-80% |
| -Xss | 线程栈大小 | 默认512KB~1MB,除非递归深度确实有要求,否则不要超过8MB |
| -XX:MaxMetaspaceSize | 元空间上限 | 根据动态生成类的规模设置,防止无限占内存 |
| -XX:MaxDirectMemorySize | 直接内存上限 | 使用NIO时要显式设置,默认与堆最大大小等同 |
有一次我把-Xss设成16MB,因为内部测试时有个递归算法要跑到两万多层。结果压测时发现线程数稍微一多,JVM启动线程就失败,报unable to create native thread,查下来是线程栈太大,操作系统线程内存不够用了。后来我把递归算法改成了迭代,-Xss重新调回1MB,问题根除。所以我的原则是:能用算法解决的不要靠调大栈,栈大小只是兜底,不是常规手段。
4. 从根源反推:两个内存区域为何“互相连累”
4.1 GC Roots就是栈上的引用:栈是堆的入口
很多人以为栈和堆是两条平行线,实际上它们关系非常密切。JVM判断堆里的对象是否存活,用的是可达性分析,而分析起点(GC Roots)就包括虚拟机栈和本地方法栈中引用的对象。换句话说,当前正在执行的方法里,局部变量和参数引用了哪些堆对象,这些对象就是“根可达”的,不会被回收。
栈帧中局部变量表里存的就是一个个引用(或者基础类型值)。方法执行期间,它引用的对象在堆里不能被GC;方法返回后,栈帧被弹出,引用没了,堆里的对象失去一条路径,才可能被回收。所以,递归太深导致栈溢出之前,每一个栈帧里的局部变量都还“挡住”着对应的堆对象。如果一个递归方法在每一层都创建一个大对象并保存在局部变量里,栈溢出之前,堆可能先被撑爆。这种情况我在解析超大JSON时遇到过:每一层递归都持有当前节点的数据对象,深度几千时堆也被占得七七八八。
表面上看,一个是栈问题一个是堆问题,但底层是同一套引用网络。这也提醒我们,排查内存问题时不能只看监控面板上的堆使用率,线程栈深度异常同样值得警惕。
4.2 错误认知:StackOverflowError和OOM完全无关吗
网上不少文章告诉你“栈溢出是递归的问题,OOM是对象多的问题”,这话没毛病,但如果理解成二者互不相关,排查时会漏掉线索。我举一个例子。有一个文本解析程序,用递归处理嵌套括号,同时在递归函数中把每个片段截取后放到一个全局List里。输入文本层级特别深,不久后日志报了OutOfMemoryError: Java heap space。有些人会觉得很奇怪:“对象很多吗?为什么不是StackOverflowError?”实际原因就是:递归层数还没触到栈上限时,递归中创建的片段对象数量已经先让堆满了。所以这个系统里堆和栈都在承压,谁先到上限谁先爆。
反之,一个递归方法如果调用深度比较大,而每层栈帧的局部变量表里都塞着大量本地对象,那么栈和堆的使用率会同步上涨。启动时把-Xss调小,会让StackOverflowError更早出现;把-Xmx调小,会让Java heap space更早出现。调整不同参数,就相当于在改变这两个“引爆点”的位置。
4.3 内存泄漏与对象过多:谁在拖垮堆
对象太多的原因,除了业务确实产生了大量短期对象,还有一个常见原因是“本该回收的对象没回收”。这也就是反复提到的内存泄漏。内存泄漏的典型特征是堆使用率越来越高,即使请求量回落也降不下去。常见场景包括:
- 静态集合没有清理机制,不断塞数据;
- ThreadLocal没有调用remove,线程池复用线程导致ThreadLocal中的对象一直存活;
- 事件监听器注册后没有反注册,监听器被观察者强引用;
- 自定义类加载器被长期持有,导致方法区/元空间泄漏。
排查时我喜欢先用jstat -gcutil <pid> 1000观察GC情况,如果Old区占用持续不降,再用jmap -dump导dump找大对象。如果是Docker容器环境,还要注意JVM是否能感知容器内存限制,早期JDK 8不加UseContainerSupport参数,JVM可能直接拿到宿主机内存,导致容器OOM。这是另一类更隐蔽的问题,但同样会体现为对象太多、堆内存在错误隔离界限下被打爆。
5. 避坑手册:我在真实项目里总结的几个教训
5.1 日志/错误监控:StackOverflowError和OOM必须单独告警
StackOverflowError和OOM在日志里都算Error级别,但很多团队只关注Exception,Error容易被忽略。我建议在统一的异常监控平台里把这两类单独拉出来,只要出现就立刻告警。它们通常是系统不稳定或代码缺陷的强信号,不是偶发问题。对于OOM,我已经习惯把所有Java服务都加上HeapDump参数,同时定期清理dump目录,确保不占用过多磁盘。
5.2 设计阶段预防:递归深度限制、批量大小限制、缓存回收策略
很多错误其实在设计阶段就能预防。递归的地方,我会加一个深度计数,超过阈值就抛业务异常;批量处理的入口,限制每批最大条数,比如分批导入任务单批不超过2000条;缓存数据,统一走配置了最大容量和过期时间的缓存组件,比如Caffeine、Redis,不让你在业务代码里直接放静态Map。这些都是小改动,却能避免“上线三个月后数据涨了,内存先崩”的尴尬。
5.3 最后一个实用技巧:压测时多观察GC日志和内存拐点
我个人习惯在发布新功能前做一轮简单的压测,用-Xlog:gc(JDK 9+)或-verbose:gc启动应用,观察随着并发量增大,GC频率和堆占用曲线。如果发现Full GC出现得越来越频繁,说明对象积累速度大于回收速度,是OOM的前兆。再配合jmap -histo看实例数量排名TOP的类,就能提前知道哪些对象异常多。线上排查靠事后救火,但成熟的项目应该在压测阶段就把这类隐患按下去。
最后再分享一个我自己的体会:遇到StackOverflowError,先别急着把栈调大,先审视递归能不能写成迭代;遇到OutOfMemoryError,也先别惦记着加内存,先用dump看看对象都从哪来。绝大多数情况下,把两块内存的角色掰扯清楚,问题已经解决一半了。