新闻详情

新闻详情

首页 / 资讯中心 / 详情

递归吃光内存?一次OOM事故的排查与递归改循环实践

发布时间:2026/9/30 11:45:43来源:尧图网络
递归吃光内存?一次OOM事故的排查与递归改循环实践
1. 事故现场一个递归干掉了64GB内存在周四下午临近下班的时候监控群突然开始连环告警。测试环境跑得好好的服务上了生产不到半小时内存曲线直接从4GB拉满到60多GBFull GC从一分钟一次变成十秒一次紧接着服务假死页面全部超时最终进程被系统杀掉了。拉上同事排查的时候他一脸无辜地回了一句“我下午就写了个递归怎么会把内存爆成这样”这句“我写了个递归”实在太经典了后来我几乎在每一次内存溢出事故复盘里都能听到类似的台词。它往往不是开玩笑而是真的——很多正在线上跑的代码内存溢出的根因就是一段看起来人畜无害、本地测试还很顺手的递归函数。这篇文章就把这次事故从头到尾拆一遍聊聊递归为什么能吃掉那么多内存现场怎么定位以及后续怎么彻底解决顺带把Excel大文件读取时的内存溢出问题也一起梳理清楚。1.1 爆炸之前代码写得还挺“顺手”要说清楚这次事故得先还原一下业务背景。同事接到的需求很常见把全公司组织架构从数据库里查出来大概5万条记录包含部门、子公司、岗位节点然后组装成一棵组织树返回给前端做树形展示。他在本地用几百条测试数据验证功能正常树形结构也完美于是没有多想就提交上线了。那段核心递归代码长这样public ListOrgNode buildTree(ListOrgNode allNodes, String parentId) { ListOrgNode children new ArrayList(); for (OrgNode node : allNodes) { if (parentId.equals(node.getParentId())) { node.setChildren(buildTree(allNodes, node.getId())); children.add(node); } } return children; }这段代码在数据结构上其实是一个经典的“每次递归都全表扫描”的实现。外层buildTree每次拿一个parentId就把整个allNodes列表从头到尾扫一遍找出所有parentId匹配的节点然后对每个匹配到的节点再递归调用一次buildTree再扫一遍全表。如果单看循环次数5万条数据、树深度假设只有5层扫描次数大概是25万次这还不会立刻爆掉64GB内存。但问题恰恰出在数据分布上——生产库里的组织和岗位数据并不是一棵标准树父子关系存在脏数据出现了环形引用。A节点的parentId指向BB节点的parentId又指回A这个递归就永远等不到结束条件。每次递归都会往children列表里追加节点原本只有5万个节点对象却因为环的存在被反复匹配、反复add进列表最终堆内存里堆积了数以亿计的对象64GB被一次性吃干抹净。1.2 第一反应先重启再找证据这种内存打满的情况第一反应千万不要是“我马上用jmap去看堆”因为内存已经接近耗尽jmap执行的时候需要触发STWStop The World大概率自己先卡死或者把服务彻底拖垮。正确顺序是先重启恢复业务再回头找证据。如果服务启动参数里配了-XX:HeapDumpOnOutOfMemoryError那么OOM的一瞬间JVM会自动生成一份堆转储文件比如这样-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/oom/heap.hprof这是最重要的保命配置有了它OOM的现场就被完整保存下来了。如果没配置就只能靠监控平台的GC曲线、应用日志和事后复现来推断。重启之后我通常会做三件事先看GC日志确认是不是真的堆溢出再抓一份线程堆栈看看业务线程卡在哪里最后结合代码仓库最近提交记录缩小嫌疑范围。常用的命令# 查看进程GC情况每秒输出一次 jstat -gcutil pid 1000 # 如果内存还够用抓堆转储 jmap -dump:live,formatb,file/data/logs/heap.hprof pid # 抓线程堆栈 jstack pid /data/logs/thread.dump那次事故里jstat先暴露了老年代增长异常回收后内存几乎不下降jstack抓到的线程栈里所有业务线程的栈顶都停在同一个方法——orgService.buildTree栈帧一层套一层全是一样的方法调用。看到这里基本就可以锁定问题了不是并发量高把内存打爆而是这段递归代码自己把自己玩死了。2. 递归为什么会把内存吃成这样很多开发对递归的直觉是“代码简洁、逻辑清晰”但对它背后的内存开销没有概念。要理解这次事故得先把JVM的栈和堆这两个内存区域讲透。2.1 每个函数调用都要“占座位”Java里每调用一个方法JVM就会在当前线程的虚拟机栈上压入一个栈帧。每个栈帧包含局部变量表、操作数栈、方法返回地址、动态链接等信息。一个线程的栈内存是有限的一块默认大小通常由-Xss参数控制常见取值是512KB到1MB之间。打个比方调用一个方法就像去餐厅占一个座位一个线程的栈就是这家餐厅的全部座位。递归调用不像普通调用那样调用完就释放座位而是不断地往同一个栈上压帧——递归100层相当于一个人占了100个座位。餐厅座位是固定的占满了自然就挤不下人了。当栈帧占满了线程栈的内存空间JVM抛出的就是StackOverflowError。栈帧本身占多大一个常规方法局部变量只有四五个引用或者几个基本类型栈帧大小通常在几百字节到1KB上下。以默认1MB栈内存、每个栈帧500字节来估算1024 * 1024 / 500 ≈ 2000层也就是说递归深度超过2000层就可能直接爆栈。如果一个方法里局部变量很多或者递归中创建了比较大的临时对象单帧大小可能超过1KB爆炸深度还会进一步缩短到几百层。很多线上的树形结构数据分层很深或者数据出现异常导致递归退化成“链表形”结构几千层递归堆叠下来栈溢出几乎成了必然。2.2 堆内存爆炸的另一个推手递归真正可怕的地方往往是和堆内存叠加起来一起爆。栈溢出只是“爆栈”而堆溢出会把整个服务干到OOM表现得更加猛烈。这次事故里真正把64GB内存吃掉的是堆不是栈。原因是递归里不断往children列表里add节点对象这些对象被ArrayList强引用着GC根本没法回收。只要死循环的递归不停下来new出来的节点对象、ArrayList扩容产生的底层数组、链表节点等等就会源源不断地往堆里塞。特别注意递归里每层创建的新集合对象如果一直被外层引用那它们也全部成了GC Roots可达对象。这就是为什么Full GC再怎么执行内存回收比例都接近于零——对象的引用链根本断不掉。这种情况比普通的“大对象一次性加载”更隐蔽因为普通的大对象还能通过调大JVM堆暂时缓解而死递归导致的对象无限堆积给多少内存都会被吃光。2.3 深递归栈溢出的计算公式与阈值如果你正在写递归建议动手前先估一下深度和对象驻留量。一个简单的估算模型线程栈可用空间 -Xss值比如1MB 单层栈帧大小 ≈ 局部变量数量 × 引用大小 方法调用额外开销 推荐搞个工具类 1. 记录当前递归深度 2. 深度超过阈值立即抛出业务异常 3. 用Set记录已访问节点ID发现重复直接阻断实际经验里还有一个很重要的坑本地调试环境和线上环境栈大小不一致。本地IDEA默认给的是-Xss1m甚至更大而线上容器可能只给了512KB加上JIT编译优化程度不同本地跑得好好的递归线上部署后稍微深一点就挂了。这就是为什么“我本地测过没问题”在OOM事故里几乎成了必现台词。3. 真实代码定位过程从日志到堆转储定位OOM根因最忌讳的就是凭感觉猜。从GC日志、线程堆栈、堆转储文件这条证据链往下走基本能把90%的递归内存溢出问题还原出来。3.1 现场证据链GC日志和线程堆栈首先看GC日志。如果启动参数里配置了GC日志那么OOM前几十秒的记录就是最直接的线索。打开gc.log重点看老年代Old Generation的使用趋势[Full GC (Allocation Failure) 48G-45G(48G), 10.3s] [Full GC (Allocation Failure) 45G-42G(48G), 12.1s]回收前48G、回收后45G回收只释放了3G然后马上又满了这种“回收不掉”的形态基本就是堆里驻留了大量不可释放对象。再加上Full GC执行时间从几秒到十几秒不断拉长说明堆里对象数量极其庞大遍历标记对象引用关系都非常吃力。接着看线程堆栈。线程堆栈里如果出现同一个业务方法大量重复出现一层套一层每一帧都是相同的方法名和行号这就是递归栈的直观形态。比如http-nio-8080-exec-7 #27 daemon prio5 os_prio0 cpu1234ms elapsed345.2s at com.demo.service.OrgService.buildTree(OrgService.java:38) at com.demo.service.OrgService.buildTree(OrgService.java:38) at com.demo.service.OrgService.buildTree(OrgService.java:38) at com.demo.service.OrgService.buildTree(OrgService.java:38)看到这样的堆栈第一反应就应该是“这里发生了深度递归”。下一步就是回到代码里看递归的终止条件以及每一层递归都在往内存里放什么。3.2 复现最小案例和堆转储分析为了验证同事的递归是不是在环上死循环了我写了一个最小复现用例。假设有两个节点A的parentId指向BB的parentId指向Apublic class RecursiveOomDemo { static class Node { String id; String parentId; ListNode children new ArrayList(); // 省略getter/setter } public static ListNode buildTree(ListNode allNodes, String parentId) { ListNode children new ArrayList(); for (Node node : allNodes) { if (parentId.equals(node.getParentId())) { node.setChildren(buildTree(allNodes, node.getId())); children.add(node); } } return children; } public static void main(String[] args) { Node a new Node(a, b); Node b new Node(b, a); ListNode nodes new ArrayList(); nodes.add(a); nodes.add(b); buildTree(nodes, a); } }小数据量下这段代码也会一直递归下去直到StackOverflowError而如果外层循环不断给集合添加节点则会在栈溢出之前先把堆撑爆。复现之后再用堆转储文件分析。MATMemory Analyzer打开heap.hprof后一般先看Leak Suspects它会自动提示哪些对象集合占据了最多堆空间。我那次分析的结果非常典型大量的ArrayList引用成环状结构ArrayList内部数组元素指向的都是同一个Node对象说明节点被反复加入不同层级的children集合引用完全乱套。再从Dominator Tree进去看那个递归方法创建的ArrayList实例占了整堆90%以上空间。3.3 问题代码特征识别经过这次和几次类似事故我总结出一个经验递归代码如果同时具备下面几个特征那它就是在内存爆雷的边缘试探终止条件依赖外部数据质量比如根据parentId是否为空来判断但脏数据里可能根本不存在“根节点”。递归内部存在环形引用风险却没有用Set等容器记录已访问节点。递归内往全局集合或者外层传入的集合里add对象且集合生命周期很长。递归内部每次都对全量数据做扫描时间复杂度从O(n)退化成O(n²)甚至更差。单次递归可能创建体积较大的临时对象、List、Map导致栈帧体积快速膨胀。4. 从根上解决递归改循环的四种写法排查清楚之后解决方向就很明确了能不用递归就不用递归一定要用递归必须加防护。下面四种方案按优先级排列你可以直接照着改。4.1 方案一加终止条件、深度限制与参数校验这是最简单、最保守的方案适合那些深度可控、数据质量有保障的场景。核心思路是在递归方法里增加三样东西最大深度限制、已访问节点集合、空值和边界检查。下面是一段改造示例private static final int MAX_DEPTH 50; public void buildTree(ListOrgNode allNodes, String parentId, int depth, SetString visited) { if (depth MAX_DEPTH) { throw new BizException(组织树层级超过最大深度限制); } if (parentId null) { return; } if (!visited.add(parentId)) { throw new BizException(检测到循环引用节点ID parentId); } ListOrgNode children new ArrayList(); for (OrgNode node : allNodes) { if (parentId.equals(node.getParentId())) { node.setChildren(buildTree(allNodes, node.getId(), depth 1, visited)); children.add(node); } } // 业务处理 }但提前说明这个方案只是防爆栈和防死循环并没有解决堆里对象驻留的问题。如果递归确实需要处理几万条节点对象还是要全部驻留在内存里只是不会被无限增添。对于更大规模的数据还是得看后面的方案。4.2 方案二手动栈模拟递归快速排序非递归例子很多人一听到“递归改循环”第一反应是“不可能”其实几乎所有递归都可以用显式栈来模拟。经典例子就是快速排序的递归版和非递归版。递归版快速排序public void quickSort(int[] arr, int left, int right) { if (left right) { return; } int pivot partition(arr, left, right); quickSort(arr, left, pivot - 1); quickSort(arr, pivot 1, right); }如果输入数据分布极端比如已经有序的数组选了第一个元素作为基准值递归深度就会退化成N排个10万条数据就可能栈溢出。改成显式栈模拟public void quickSortNonRecursive(int[] arr) { Dequeint[] stack new ArrayDeque(); stack.push(new int[]{0, arr.length - 1}); while (!stack.isEmpty()) { int[] range stack.pop(); int left range[0]; int right range[1]; if (left right) { continue; } int pivot partition(arr, left, right); if (pivot - 1 left) { stack.push(new int[]{left, pivot - 1}); } if (right pivot 1) { stack.push(new int[]{pivot 1, right}); } } }显式栈最大的优势是它放在堆上由我们自己控制容量和生命周期。递归调用是系统栈自动管理的深度一高就爆显式栈则完全掌握在代码手里随时可以弹出释放不会无限堆叠。如果你觉得用Deque装int[]不够直观也可以定义一个Range对象代码可读性会更好。4.3 方案三递归转迭代用循环状态机组织树组装这种场景其实是非常典型的“递归转迭代”机会。原来的问题是递归内全表扫描我们直接用Map把节点按parentId分组然后用循环一层层往下挂子节点顺便还能利用栈或队列控制遍历顺序。先看看迭代版的组织树构建逻辑public ListOrgNode buildTree(ListOrgNode allNodes, String rootParentId) { // 第一步把所有节点按照parentId分组 MapString, ListOrgNode childrenMap allNodes.stream() .collect(Collectors.groupingBy(OrgNode::getParentId)); // 第二步找到根节点 ListOrgNode roots childrenMap.getOrDefault(rootParentId, Collections.emptyList()); // 第三步用栈做深度优先遍历逐层挂子节点 DequeOrgNode stack new ArrayDeque(); stack.addAll(roots); while (!stack.isEmpty()) { OrgNode cur stack.pop(); ListOrgNode children childrenMap.getOrDefault(cur.getId(), Collections.emptyList()); cur.setChildren(children); children.forEach(stack::push); } return roots; }这个实现的收益非常明显时间复杂度从原来的O(n²)降到O(n)内存也完全可控所有节点对象只在集合里存在一份不会被重复add。而且循环版本天然不存在“递归深度”这个概念树再深也只是循环迭代的次数变多不会爆栈。4.4 方案四对象不落地流式处理XSSFWorkbook场景延伸还有一种常见的内存溢出虽然不直接是递归引起的但在开发中经常和递归场景一起出现就是Excel大文件读取。热搜词里的xssfworkbook内存溢出就是典型——很多同事写的导出逻辑先递归组装好一个巨大的对象树再一次性丢给XSSFWorkbook写入Excel结果两头内存Double爆。XSSFWorkbook读取一个10万行、20列的Excel时会在内存里创建几十万个Cell对象每个对象还要维护样式、类型、值引用等属性。粗略估算10万行×20列的单元格对象模型占用内存可能达到几百MB到数GB堆内存根本扛不住。POI的XSSFWorkbook是把整个工作簿全部加载进内存的不适合处理超大文件。解决办法是换用SXSSFWorkbook也就是POI官方提供的流式版本。SXSSFWorkbook通过“滑动窗口”机制只保留最近N行在内存更早写完的行会被刷新到磁盘临时文件里从而把内存峰值压下来SXSSFWorkbook workbook new SXSSFWorkbook(100); // 窗口100行 Sheet sheet workbook.createSheet(数据); try (FileOutputStream out new FileOutputStream(big-file.xlsx)) { for (int i 0; i 1_000_000; i) { Row row sheet.createRow(i); Cell cell row.createCell(0); cell.setCellValue(value- i); // 每写5000行打印一次进度 if (i % 5000 0) { System.out.println(已写入行数 i); } } workbook.write(out); } finally { workbook.dispose(); // 释放临时磁盘文件 }读大Excel同理别用XSSFWorkbook硬扛改用POI的EventModel或者EasyExcel流式解析。读一行处理一行不要把整个工作簿都加载到内存。这其实和递归场景共享同一个思路对象不落地数据流动起来内存峰值就下来了。5. 常见问题排查与团队防护清单一次OOM事故不只是解决一个bug的问题更需要把排查经验沉淀成团队规范和线上预案防止同类型问题反复出现。5.1 常见OOM场景速查表平时遇到最多的内存溢出我大致归类成下面几种具体排查路径也做好了速查场景典型表现排查入口解决方向无限递归/深递归线程栈溢出或堆内存持续飙升后OOMjstack看线程栈MAT看堆转储递归改循环、显式栈、加深度限制集合对象无限addFull GC频繁且回收不掉老年代持续增长jmap dump MAT分析ArrayList检查循环引用控制对象生命周期大Excel一次性加载XSSFWorkbook实例占用内存极大MAT查看Workbook对象大小改用SXSSFWorkbook或EasyExcel流式处理全量查询超大结果集堆被数据库查询结果对象占满GC日志看老年代增长SQL慢日志分页查询、游标式处理、限制条数中间件/容器OOMTongWeb等容器内应用堆耗尽应用日志、GC日志、堆转储统一分析和普通堆OOM排查一致先抓线程栈5.2 团队代码审查里的递归检查清单我后来在团队评审规范里加了专门针对递归的硬性检查项基本上review到递归代码都会逐条过一遍终止条件是否对输入数据完全成立有没有考虑脏数据、环状引用、null值递归深度预估是多少如果深度可能超过2000层必须给出替代方案。递归内部是否存在全量扫描集合、全量拷贝数据、往全局集合里add对象的操作递归中是否有I/O、网络调用、数据库查询这些操作会指数级放大耗时和资源占用。能否用循环、显式栈、Map分组等方式替代递归团队更倾向于可读性尚可的迭代实现。是否有最大深度限制和已访问节点记录没有就退回重新写。5.3 线上OOM预案与止损手段处理OOM不能只靠事后诸葛亮服务器上的JVM启动参数必须提前配置好。下面是团队现在所有Java服务统一执行的参数基线-Xms4g -Xmx4g -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/oom -XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/data/logs/gc.log这套参数保证OOM发生时必然留下堆转储文件和GC日志。同时监控平台要盯住老年代占用率和Full GC频率老年代超过85%且多次Full GC回收不足10%时自动告警提前发现潜在问题。止损层面服务一旦进入“内存爆满→频繁Full GC→假死”状态不要犹豫立刻重启恢复业务堆转储文件已经留存后面慢慢分析就行。每次OOM事故后我们还会强制做一次复盘把根因、修复方案、对应代码评审项记入共享文档避免同一个坑在别的项目里再踩一遍。我在实际踩过几次坑之后最大的体会是递归是一种需要“敬畏”的语法它让代码简洁但代价是把栈帧控制权完全交给了JVM。你必须在递归入口就猜到最坏的数据形态——深度会不会失控数据会不会有环每层会不会累积对象写完递归代码不妨多问自己一句这段逻辑如果换成循环是不是其实也没多麻烦递归深度不确定的时候宁可多写几行代码也别贪图那一时的简洁。最后再分享一个抢救经验如果线上OOM时jmap没来得及抓堆转储先去容器的日志目录找hs_err_pid开头的文件那个文件里通常也有JVM崩溃时的线程栈摘要可以快速确认是否有线程卡在同一个递归方法里。不过最稳妥的还是提前配好HeapDumpOnOutOfMemoryError让每次OOM都自动留下完整的现场证据别给排查留遗憾。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

【毕设避坑干货】挑选 AI 论文工具必看!一站式平台 Paperxie 实测分享 2026/9/30 14:57:07

【毕设避坑干货】挑选 AI 论文工具必看!一站式平台 Paperxie 实测分享

前言 临近毕业季,网上层出不穷的 AI 论文工具让人眼花缭乱。很多同学随便找一个网站就开始写论文,等到后期才发现功能不全、参考文献造假、文稿泄露等一系列问题,白白浪费大量时间。 挑选 AI 论文辅助工具,核心要看两点&#xf…

阅读更多 →
论文投稿前要自查哪些学术规范:署名、利益冲突与伦理声明逐项核对手册 2026/9/30 14:56:59

论文投稿前要自查哪些学术规范:署名、利益冲突与伦理声明逐项核对手册

在投稿系统点下"提交"的前一刻,多数人盯的是重复率和字数,而编辑在送外审之前逐条核对的是另一组信息:名字怎么排、有没有未说明的利益关系、涉及人的研究有没有伦理依据。这几处含糊的稿件,常在初审阶段被退回补正&…

阅读更多 →
WSL从安装到VS Code开发的全流程实践与踩坑记录 2026/9/30 14:56:48

WSL从安装到VS Code开发的全流程实践与踩坑记录

说实话,“配置WSL并在vscode里打开”这个标题看起来属于“十分钟入门”级别,但真正上手操作的时候,翻车的情况比想象中多得多。我从第一次在Windows上搭Linux开发环境到现在,反复折腾过WSL的安装、迁移、崩溃恢复、VS Code连接这些…

阅读更多 →
OpenStack Nova 16种核心操作全解析:从状态机到运维实战 2026/9/30 14:56:34

OpenStack Nova 16种核心操作全解析:从状态机到运维实战

1. 为什么说 Nova 的操作比你想的多得多 做过 OpenStack 运维的人都有这种体会:Nova 表面上是“管虚拟机”的组件,但真正上手之后才发现,它那套操作体系的复杂度远超预期。单说对一个 instance 的管理,官方 API 文档里列出的动作就…

阅读更多 →
尚硅谷Java教程笔记之变量与运算符 2026/9/30 14:55:45

尚硅谷Java教程笔记之变量与运算符

文章前言: 本文是笔者在学习的时候边学边敲,可能有疏忽,我们一起进步 ##1.关键字(在Java中严肃区分大小写,在任何场景下) 被赋予了专门的含义与用途 保留关键字是指目前官方先行占用这个作为关键字&#xf…

阅读更多 →
Linux内存泄漏排查实战:perf、Valgrind与Heap Profiler的递进用法 2026/9/30 14:55:26

Linux内存泄漏排查实战:perf、Valgrind与Heap Profiler的递进用法

内存泄漏这种问题,放到生产环境里基本都是先闹一段“越来越慢”“内存见底”“重启就好”的幺蛾子,然后大家才被迫认真查。查的时间往往比修的时间还长,就是因为好多人一上来就敲 valgrind,结果要么根本跑不起来,要么把…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉