先说结论
StackOverflowError 通常来自无限递归、递归层数过深或单栈帧过大,应从重复调用栈找终止条件和调用环。Metaspace OOM 表示类元数据无法继续分配,常见原因是动态生成大量类、类加载器泄漏或上限过小。
线程过多更常表现为无法创建本地线程或进程内存耗尽,不应和单个线程的 StackOverflowError 混为一谈。
排查栈溢出
保留完整异常栈,观察是否有固定方法序列反复出现。修复优先消除递归环、改为迭代或限制输入深度;盲目增大 -Xss 会增加每线程内存预算并降低可创建线程数。
常见错误类型对照
| 现象 | 典型异常/日志 | 首先关注 |
|---|---|---|
| 单线程递归过深 | StackOverflowError |
重复方法序列、输入深度、终止条件 |
| 线程太多 | unable to create native thread |
线程数、Xss、进程/容器限制 |
| 类元数据耗尽 | OutOfMemoryError: Metaspace |
类数量、ClassLoader 生命周期、动态生成 |
| 堆对象过多 | OutOfMemoryError: Java heap space |
堆转储、对象引用链、缓存与队列 |
先区分错误类型能避免完全错误的参数调整。例如把 StackOverflowError 当 heap OOM 调大 Xmx 通常毫无帮助。
递归问题的系统处理
递归可能来自树遍历,也可能来自 toString、序列化、AOP 代理互相调用或 equals/hashCode 循环。异常栈中重复的两三个方法常是最强线索。对可控输入设置最大嵌套层数;对图结构增加 visited 集合;对大树遍历用显式 Deque 改写为迭代。
Deque<Node> stack = new ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
Node node = stack.pop();
visit(node);
node.children().forEach(stack::push);
}
迭代不一定更优雅,但它把调用深度从线程栈转移到可显式控制的数据结构,便于设置容量、取消和监控。
元空间泄漏案例
运行时生成代理类本身不必然泄漏。危险在于每次热更新都创建新的 ClassLoader,旧加载器却被全局线程、JDBC Driver、静态单例、MBean、Timer 或 ThreadLocal 引用。Metaspace 随部署次数阶梯增长,Full GC 后也无法下降,是典型信号。
排查时比较不同时间点的 ClassLoader 实例数和已加载类数量,抓 Heap Dump 查看旧加载器的 GC Root。修复要断开引用链并停止旧线程,而不是只调用卸载或增大 MaxMetaspaceSize。
参数调整的边界
-Xss 要结合最大线程数算总预算;MaxMetaspaceSize 可作为保护上限和告警触发点,但太小会提前失败,太大可能掩盖泄漏直到容器被 OOM kill。任何参数调整后都要通过压力或多轮热部署验证曲线是否收敛。
排查元空间
检查已加载类数量、类加载趋势和 class loader 统计,使用类直方图、JFR 或堆转储寻找持有旧加载器的引用。热部署、脚本引擎、代理生成和缓存未清理都是高风险位置。
容易踩坑的地方
Metaspace 使用本地内存,但类加载器对象仍在堆上;只提高 MaxMetaspaceSize 可能延迟故障而不解决加载器泄漏。类卸载通常还依赖对应加载器不可达和 GC 条件。
常见问题
追问:为什么加大 Xss 不是首选?
它只让更深调用暂时可运行,无法修复无限递归,而且会放大每个线程的内存占用,应先确认调用深度是否合理。
追问:StackOverflowError 能被 catch 后继续运行吗?
技术上可捕获 Error,但栈空间已接近耗尽,继续在同一调用链做复杂恢复不可靠。应在最外层记录现场并终止当前请求或任务,根治递归原因。
追问:为什么动态代理会增加 Metaspace?
代理框架可能为每种代理形态生成新类定义,类元数据保存在元空间。正常复用加载器和代理定义时增长应受控,反复创建加载器或无限变化签名会持续增长。
追问:类卸载时是否会立即归还操作系统内存?
类元数据可被回收,但 JVM 可能保留已申请的本地内存供后续复用,进程 RSS 不一定立即等比例下降。判断泄漏看趋势和可达性,而非单次 RSS。