news 2026/9/17 0:39:41

StackOverflowError与OOM:JVM堆和栈内存原理与排查实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
StackOverflowError与OOM:JVM堆和栈内存原理与排查实战

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 TreeLeak 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 exceededGC回收率极低,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看看对象都从哪来。绝大多数情况下,把两块内存的角色掰扯清楚,问题已经解决一半了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 0:38:51

gvim 写 Verilog 配置:AUTOARG 与 AUTOINST 实战

1. 我为什么把编辑环境从重型 IDE 换回 gvim 写 Verilog写 Verilog 这件事&#xff0c;工具链的舒适度直接决定你一天能推进多少行 RTL。我做了几年数字前端&#xff0c;从 Quartus 自带的编辑器、Vivado 的文本窗口&#xff0c;到后来的 VS Code 加插件&#xff0c;最后又绕回…

作者头像 李华
网站建设 2026/9/17 0:33:50

Java游戏服务网站高并发后端架构:Spring Boot与Redis实践

简介&#xff1a;这是一份基于Spring Boot的游戏服务网站Java源码包&#xff0c;专为计算机、电子信息等专业的学生打造&#xff0c;适用于毕业设计、课程设计或期末大作业。项目采用B/S架构与MVC分层&#xff0c;整合SpringBoot、Mybatis、Ajax、Vue等技术栈&#xff0c;前端与…

作者头像 李华
网站建设 2026/9/17 0:29:50

卡尔曼滤波入门:从概率统计与高斯分布理解状态估计

做RM电控这几年&#xff0c;最绕不开的算法就是卡尔曼滤波。不管是云台自瞄的陀螺仪数据融合&#xff0c;还是步兵车上的测距模块、弹道解算&#xff0c;甚至底盘里程计定位&#xff0c;到处都能看到它的影子。但很多队员第一次接触卡尔曼滤波&#xff0c;抄了一版代码、调了几…

作者头像 李华
网站建设 2026/9/17 0:25:58

SpringBoot+MyBatis实现企业员工信息管理系统开发实践

1. 项目概述与背景作为一名经历过多次企业信息化改造的Java开发者&#xff0c;我深知传统纸质化员工管理的痛点。去年参与某中型制造企业HR系统升级时&#xff0c;亲眼目睹人事部门用Excel表格管理300多名员工信息的混乱场景——考勤数据分散在5个不同文件&#xff0c;请假审批…

作者头像 李华
网站建设 2026/9/17 0:22:14

电力系统自适应控制:转动惯量与阻尼系数动态调节技术

1. 项目背景与核心价值在电力系统稳定性研究中&#xff0c;同步发电机的动态特性直接影响整个电网的暂态响应。传统固定参数的转动惯量(H)和阻尼系数(D)控制策略难以应对现代电网中日益复杂的运行工况。这个问题在新能源高比例接入的电力系统中尤为突出——风电、光伏等间歇性能…

作者头像 李华