上周四下午我们组同事往群里甩了一条消息他那台开发机上的应用进程内存直接干到64GB整机卡死IDE都变成了PPT。他一开始完全不信这事是递归造成的——在他看来递归就是“函数自己调用自己”怎么能把内存吃成这样后来我们顺着JVM堆转储一层层查下去发现还真就是它。一梭子递归把几十个G的堆全塞满了只为让前端拿一棵组织架构树。这种场景在Java后端太常见了树形结构、父子关系、分类层级、菜单权限只要一递归配合数据量大一点的集合内存就噌噌往上飙。本文就围绕这个真实案例展开递归为什么会吃掉海量堆内存、栈溢出和堆溢出到底有什么区别、XSSFWorkbook解析大Excel时又为什么会和递归叠加成灾难、快排递归改非递归怎么落地以及内存爆掉之后完整的排查链路。适合所有被递归坑过、调大Xmx也救不回来、或者正准备处理大Excel和深层数据的Java开发者。1. 先复盘那台64GB内存是怎么被一个递归吃掉的1.1 递归调用链上的每一帧都抱着哪些对象不放如果想真正搞懂递归为什么费内存先把JVM的调用栈模型掰开看。Java线程执行方法时会在自己的虚拟机栈里压入一个栈帧每个栈帧里装着局部变量表、操作数栈、动态链接和方法返回地址。递归的本质是“还没返回就继续调用自己”也就是说前一层的方法栈帧根本没机会弹出必须等到最深层的递归返回之后才会一层层释放。关键就在这栈帧不弹出里面引用的对象就全是GC Root路径上的强引用垃圾回收器动不了它们。假如你的递归方法长这样private void transform(Node node, ListRowData result) { byte[] buffer new byte[64 * 1024]; // 每层递归新建一个64KB数组 // 做一些转换把结果塞进result result.add(convert(node, buffer)); for (Node child : node.getChildren()) { transform(child, result); } }这棵树深度只要到1000层每个栈帧里那个64KB的buffer就都活着光这些buffers就是64MB了。如果每条数据还转换成一个大对象并被List引用树节点越多、每层数据越大堆里滞留的对象越积越多。Java不像C那样可以在函数里手动释放局部变量GC再勤快也拿那些被存活栈帧引用的对象没办法。所以递归吃内存不是玄学它本质上是一个“并发存活对象数”的问题所有未返回的栈帧里持有的对象全部叠加在一起。递归深度越大、每层持有的数据越多堆消耗就越恐怖。我之前遇到过更夸张的情况一个统计报表的递归方法里每一层都临时new一个ArrayList并add进全局List处理到第几千层时堆内存直接肉眼可见地往上跳。这种代码一旦数据量上来64GB根本不够看。1.2 栈溢出和堆溢出是两码事别混为一谈很多人一听到“递归导致内存爆了”立刻想到StackOverflowError然后争论说“递归不是应该爆栈吗怎么会把64GB堆吃满”这里得把两个概念理清楚。StackOverflowError发生在虚拟机栈上。JVM默认的线程栈大小通常只有512KB到1MB每次方法调用都要在栈上压一个栈帧如果递归深度几万层、几十万层栈空间先被耗尽直接抛StackOverflowError。这跟你写多少堆没关系是“栈”维度的问题改大-Xss可以缓解一部分但治标不治本。OutOfMemoryError: Java heap space则发生在堆上。堆是存放对象实例的地方递归过程中不断new出的大对象、不断累积的集合把堆内存塞满时就会抛这个。文章开头那个64GB进程本质并不是栈的问题而是堆被上百个递归调用链上的对象、连同方法里的集合一起堆爆了。这两个错误经常被混在一起讨论但排查方向完全不同。栈溢出优先查递归深度、查-Xss配置堆溢出则要看对象的存活与累积路径。更麻烦的是很多团队为了省事一看内存溢出就把-Xmx从8G调到16G、32G、甚至64G然后继续爆。因为你把堆调大只是把油耗高的车换了个更大的油箱耗油的路段一点没修跑完照样趴窝。而且堆越大Full GC停顿时间越长问题更难定位。2. 最容易踩的3个“递归内存”实战翻车点2.1 组织架构树递归遍历边递归边查数据库后端写组织架构、菜单权限、分类层级时最容易出现这种写法递归方法里面查数据库每一次进入一个节点就执行一次查询再去遍历它的子节点。看起来逻辑很清晰先查根节点找到子节点对每个子节点再调用自己直到叶子节点。问题在于数据库不是内存每查一次都要经历网络往返、SQL解析、结果集构建。假设你的组织架构有5000个节点最理想是一层一次查询但如果递归里每个节点都查一次父级的子列表查询次数会被放大到上万次。这还不是最要命的最要命的是每次查询返回的ResultSet和中间实体对象在递归未返回时也全部存活。再加上连接池可能被并发请求拖垮整个应用会在某个瞬间同时出现数据库连接耗尽和内存飙升。我后来处理这类问题有一个很朴素的原则能一次查完的数据绝不在循环里查第二次。组织架构树这种结构完全可以先一次性把全表数据查出来在内存里用Map按父ID分组再去递归组装树结构。这样数据库只承受一次查询内存里也只有一份全量数据递归虽然还在但它处理的已经是纯内存对象风险小得多。如果连递归组装都觉得慢还可以直接改成迭代队列的BFS写法内存占用更加可控。2.2 递归解析Excel时XSSFWorkbook把堆内存吃干净另一个高频翻车现场和热词里的XSSFWorkbook内存溢出强相关。Apache POI是Java操作Excel最常用的库很多人用XSSFWorkbook读.xlsx文件然后递归处理里面的行数据。XSSFWorkbook的实现模型是把整个工作簿的对象全量加载进内存每个Sheet、每行、每列都要在堆里建出XSSFSheet、XSSFRow、XSSFCell对象还要保留共享字符串表、样式表、合并单元格等各种映射。这玩意儿的恐怖程度我实测过一个60MB左右的三万行Excel用XSSFWorkbook加载堆内存稳定吃掉3GB以上。如果这个文件有几十万行堆被吃满根本不需要任何额外操作。而有些人写递归处理时还会在递归方法里反复创建XSSFWorkbook或者读一个Sheet后不关闭流就继续递归下一个Sheet内存自然以几何级速度膨胀。更隐蔽的一个坑是POI的XSSFReader和XSSFWorkbook不一样前者可以用SAX模式逐行解析流式读取不会把整个Excel对象模型全塞进内存。所以遇到大Excel解析的需求优先考虑EasyExcel、Apache POI的SAX事件模型而不是XSSFWorkbook一把梭。哪怕是必须用XSSFWorkbook也要注意用完立刻关闭不要把打开的Workbook对象当作全局变量到处传。2.3 快速排序递归过深导致的算法灾难热词里有一条“快速排序非递归”这说明很多人在算法的递归实现上也被坑过。快排用的是分治思想正常平均递归深度是O(logn)也就是10万个元素大概十几层完全没问题。但快排的递归深度严重依赖基准值的选择如果数组基本有序你又固定取第一个或最后一个元素当pivot划分就极度不平衡递归深度直接退化到O(n)也就是数据量多大就递归多少层。有一年我处理过一个生产问题一个几十万条记录的排序任务数据是接口返回的近乎有序的ID集合代码里的快排固定取最右边元素当基准。几万层递归调用直接把线程栈打爆应用每隔几个小时就抛一次StackOverflowError。更麻烦的是排序这个动作是在请求线程里执行的栈一爆整个请求就挂了上游还疯狂重试把CPU和内存同时拖垮。这种场景下非递归快排就是非常合理的替代方案。用显式栈模拟递归调用把每次要处理的区间压入栈循环中弹出区间、分区、再压入两个子区间。递归深度问题变成堆上的栈大小问题而栈在堆上占用相比整条调用链要小得多更重要的是不会触发StackOverflowError。3. 实战改造把递归改成非递归的两套可抄作业方案3.1 显式栈模拟调用栈树的先序遍历非递归写法先说一下通用思路。递归之所以能工作靠的是系统调用栈自动保存每一层的状态。非递归化核心就是找一个显式的栈或队列来手动保存“接下来要处理什么”然后循环处理。以二叉树的先序遍历为例。递归版本是访问根节点递归左子树递归右子树。非递归版本用栈模拟注意栈先入后出的特性要想先处理左子树就得先把右子树压入栈底最后压左子树让它下一轮先被弹出。public void preorder(TreeNode root) { if (root null) { return; } DequeTreeNode stack new ArrayDeque(); stack.push(root); while (!stack.isEmpty()) { TreeNode node stack.pop(); if (node null) { continue; } visit(node); // 先压右子树再压左子树弹栈时左子树先被处理 stack.push(node.right); stack.push(node.left); } }这个写法简单但要说透两个点第一为什么用ArrayDeque而不用Stack因为Stack继承自Vector每个方法都带同步锁单线程场景下纯属性能浪费Deque接口的ArrayDeque是官方推荐的替代品。第二显式栈里存的是TreeNode引用而不是复刻了整个调用栈帧所以每个节点的处理状态更轻量内存占用远小于递归时一整套局部变量表加操作数栈。这种把系统栈迁到业务栈的做法在树形结构场景下几乎是万能套路。前序遍历、中序遍历、后续遍历、层次遍历都能用显式栈或者队列改造。遇到深层树结构优先考虑这种方案而不是无脑调大-Xss。3.2 快速排序非递归化从递归到迭代的完整代码快排的非递归化是另一个经典样板。递归版本每次调用时把当前数组分区的左右边界压入系统栈非递归版本则把这些边界压进显式栈。每次从栈里弹出一个区间执行partition然后把划分后生成的左右子区间再压回栈中直到栈为空。import java.util.ArrayDeque; import java.util.Deque; public class QuickSortNonRecursive { public static void quickSort(int[] arr) { if (arr null || arr.length 2) { return; } 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 pivotIndex partition(arr, left, right); // 先压右区间再压左区间下一轮先处理左区间 stack.push(new int[]{pivotIndex 1, right}); stack.push(new int[]{left, pivotIndex - 1}); } } private static int partition(int[] arr, int left, int right) { int pivot arr[right]; int i left - 1; for (int j left; j right; j) { if (arr[j] pivot) { i; swap(arr, i, j); } } swap(arr, i 1, right); return i 1; } private static void swap(int[] arr, int i, int j) { int temp arr[i]; arr[i] arr[j]; arr[j] temp; } }这套代码跑起来效果和递归版完全一致区别在于递归版的调用深度受数组长度和分区平衡度影响最坏情况下可能等于数组长度非递归版的“待处理区间”存在堆上的ArrayDeque里最多同时保存O(logn)个区间内存占用稳定根本不存在栈深耗尽的问题。如果你想保留递归简洁性的同时又想控制深度还有一个折中方案递归版的快排里当递归深度超过一定阈值比如log2(n)的2倍时直接切换到堆排序或插入排序。这也是很多工业级排序库里会用到的混合策略不过那是另一个话题了后面有机会单独写。3.3 改非递归时最容易翻车的3个细节我自己在把递归改成非递归时踩过好几次坑分享三个细节。第一个坑是入栈顺序影响遍历顺序。快排代码里我明确写了先压右区间再压左区间因为栈是后进先出后压的左区间会被先弹出处理。如果你把顺序写反结果不会错但处理顺序和递归版本不同某些依赖处理顺序的业务逻辑可能出问题。树的遍历同理想先处理左子树就一定要先压右子树。第二个坑是区间边界极易算错。递归版本里左右边界天然由参数传递不用自己维护改显式栈后你手动压入的每个区间都必须保证闭合区间的正确性。快排里partition返回pivotIndex后左区间是[left, pivotIndex-1]右区间是[pivotIndex1, right]两个区间都要跳过pivot本身。如果多包一个或少包一个轻则多一次无效分区重则死循环。第三个坑是显式栈里存对象时的引用问题。如果你压入的是对象引用要注意对象是否会被外部修改否则从栈里弹出来处理的可能已经不是你以为的状态。最稳妥的做法是压入不可变的值类型或边界数组比如new int[]{left, right}而不是直接压一个可变对象。4. 内存爆到64GB后我是怎么从jmap到MAT逐步定位的4.1 先用jps、jstat和GC日志确认堆与GC状态内存问题排查切忌一上来就抓堆转储先把系统和JVM的状态摸清楚。第一步是找到那个吃掉64GB内存的Java进程用jps或者jps -l列一下JVM进程列表记住目标PID。第二步用jstat实时观察GC情况。这个命令我建议你记牢jstat -gcutil pid 1000 20它会每秒输出一次各内存池的使用率和GC时间连续采样20次。如果看到Eden区、Old区疯狂增长Full GC次数频繁飙升而且每次GC之后内存占用几乎不回落基本可以断定堆里有大量不可回收的对象。如果Full GC后内存能降下来说明是单纯的内存需求量大如果降不下来那就是引用链把对象死死拽住典型的泄漏特征。第三步查GC日志。线上JVM建议提前打开GC日志别等出问题再补。JDK 8可以用-Xloggc:/opt/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStampsJDK 11及以后建议用统一日志-Xlog:gc*:file/opt/logs/gc.logGC日志里能看到每次Young GC和Full GC前后的堆占用对比出内存是缓慢增长还是一瞬间被灌满。那次我们排查64GB进程就是先从GC日志里发现Old区在几十秒内从4GB飙到60GB以上而且GC后完全回收不动才确定问题方向是“对象被长期引用”而不是单纯内存不足。4.2 抓堆转储用MAT找出持有内存的大头确认堆有异常后就该抓堆转储了。命令很简单jmap -dump:formatb,file/opt/heap.bin pid如果是JDK 9以上也可以考虑用jcmdjcmd pid GC.heap_dump /opt/heap.bin抓下来的堆转储可能非常大64GB的堆你dump下来的文件甚至都有几十GB所以能加live参数就尽量用jmap -dump:live只保留存活对象文件能小不少。但要注意live dump会触发一次Full GC在线环境谨慎使用尽量找业务低峰期操作。拿到dump文件后用Eclipse MAT打开先看Overview页面的Leak Suspects。MAT会自动帮你圈出几个最可疑的“内存大胃王”然后进Dominator Tree按Retained Size排序看哪些对象保留的内存最多。我们当时一眼就看到一棵巨大的树结构对象通过引用链往上追发现它被一个递归遍历方法里声明的局部变量持有整个调用链的栈帧全都没返回所有中间节点都被List收集着。这里有个实用技巧在MAT里右键某个可疑对象选择Path to GC Roots勾选exclude all phantom/weak/soft etc. references就能看到从GC Root到这个对象的完整强引用链。这个引用链通常就是内存泄漏的“案发现场”顺着它就能定位到具体类、具体方法。那次我们顺着引用链走到了那个递归方法发现它把每一层的返回值都add进了一个类级字段的List递归不清空越积越多。4.3 中间件环境下的JVM参数配置和上线前检查热词里提到的TongWeb是国内常见的应用服务器中间件。很多项目跑在Tomcat之外的这类容器里出问题后容易手忙脚乱因为平时不熟悉它的配置入口。其实思路一样先确认JVM参数应用到了哪个进程再确认GC日志和堆转储能不能正常落盘。TongWeb这类中间件一般会通过管理控制台或启动脚本指定JVM参数。你可以在启动脚本里设置JAVA_OPTS把堆大小、GC参数、日志路径都固定好重启后生效。常见配置示例JAVA_OPTS-Xms4g -Xmx16g -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/opt/logs/heap.hprof我个人强烈建议生产环境把-XX:HeapDumpOnOutOfMemoryError这个参数加上。它的意思是一旦JVM因为OutOfMemoryError挂掉自动把当时的堆转储写到指定路径省得你在事后再想办法对濒死进程执行jmap。很多内存问题之所以难复盘就是因为进程一OOM就被运维重启了现场全没了。有了这个参数至少能留下案发现场的照片。上线前我还习惯做一个“递归压力自测”构造最大深度的测试数据预估递归产生的对象规模用JFR或者简单的计时打点观察内存曲线。如果发现内存曲线陡增就趁早改非递归或批处理方案别等上了生产再让用户帮你踩雷。5. 写在最后写递归之前先问自己4个问题5.1 四个自测问题经历了那次64GB事故后我现在写任何递归代码之前都会先过四个问题。这些问题看起来简单但每一个都是真金白银换来的。第一问最大递归深度是多少如果数据量是10万最坏情况下递归可能有多少层如果深度可能达到几千甚至几万就别用递归了改迭代或显式栈方案。第二问每一层递归会持有哪些对象这些对象有多大在递归不返回的情况下它们会一直存活和深度相乘后再心里估算一下总量级。如果单层几KB、深度上千就已经是几十MB甚至GB级别了。第三问递归结束后期间产生的集合和中间对象能不能及时释放如果持有者是类级字段、静态字段或者被传入了某个全局缓存那就要格外警惕因为GC根本回收不了它。第四问这个问题真的需要递归吗很多树的遍历、树的转换、目录扫描用显式栈或队列完全可以等价实现代码量没大多少内存行为却稳定得多。这四问没有一个是复杂的理论但放在一起就是递归内存问题的完整防线。问题只要在写代码前花两分钟想过一遍就能避免上线后花两小时查堆转储的窘境。5.2 几条用真金白银换来的避坑经验最后再分享几条习惯性的经验都是踩过坑后沉淀下来的。第一条永远不要靠无脑调大Xmx来“解决”内存问题。堆调大只是把症状往后推迟而且会让Full GC停顿时间更长线上故障影响面更大。除非你非常确定内存需求就是业务增长的正常结果否则调完Xmx一定要同步做堆转储分析找到真正的根因。第二条递归和外部资源访问是最危险的组合。递归方法里查数据库、读Excel、调用远程接口每一个都意味着副作用和延迟叠加在递归深度上会放大几十倍。把外部资源访问统统提前到递归之前完成递归方法里只做纯内存操作这会大幅降低事故概率。第三条POI的XSSFWorkbook这类大对象操作千万不能依赖“用完整理一下”这种侥幸心理。能流式就流式能分批就分批用完一定要在finally里关闭workbook和InputStream。大对象的释放比小对象更难因为对象图越庞大GC对它做可达性分析的时间就越长。回到文章开头那台64GB机器。其实那次我们最后做的改动很简单把递归遍历组织架构树的逻辑改成了一次性查库内存组树的方案再配合递归方法里的临时对象清理堆内存峰值从60多GB直接降到了2GB以内。一个递归代码如果能在开发阶段就搞清楚它背后的内存模型和引用链逻辑根本不至于让整台开发机卡成PPT。这就是我一直坚持把这些细节写下来的原因希望看到这篇文章的人能少走一点我们当时走过的弯路。