0. 事故背景:用户提交的SQL,竟成了“对象繁殖器”
某天,我正在快乐地摸鱼(划掉)维护数据平台,突然收到告警:「内存占用99%,服务已熔断」。
打开日志一看,满屏都是 java.lang.OutOfMemoryError: Java heap space,仿佛在嘲笑我:「你的代码,是对象生产的永动机吗?」
经过排查,发现是用户提交的 SQL 触发了某个逻辑的死循环,导致对象像丧尸病毒一样疯狂复制,直到堆内存原地爆炸。
下面是我的「丧尸围城逃生指南」——教你如何用 Heap Dump 和线程分析,揪出那个“无限增殖”的元凶!
1. 第一步:生成案发现场“黑匣子”——Heap Dump & 线程快照
当服务器开始“口吐白沫”(频繁 Full GC),别急着重启!先保留两大证据:
1.1 Heap Dump(堆内存快照)
-
临终关怀式自动生成(推荐):
提前在 JVM 参数里埋下“遗嘱执行人”:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump.hprof这样 OOM 发生时,JVM 会自动生成 dump 文件,比遗嘱公证还靠谱。
-
手动紧急剖腹产(临时救场):
用jmap命令强行提取:jmap -dump:format=b,file=/path/to/dump.hprof <PID>警告:如果服务器已经“奄奄一息”,此操作可能直接送走它,建议配合祈祷使用。
1.2 线程快照(Thread Dump)
死循环的罪魁祸首,往往藏在某个发疯的线程里。用 jstack 抓取线程状态:
jstack -l <PID> > thread_dump.txt
关键线索:寻找线程状态为 RUNNABLE 且长时间卡在同一方法的“劳模线程”。
2. 第二步:法医鉴定——用 Jifa/MAT 解剖 Heap Dump
打开 Jifa(或 Eclipse MAT),加载 dump 文件,开启“法医模式”。
2.1 找“丧尸王”——内存中的最大对象家族
-
点击 Dominator Tree,按 Retained Heap 排序,找到占用内存最大的对象。
- 如果是死循环,这里可能会看到某种业务对象(如
SQLParser、ASTNode)的数量极其异常(比如 10 万个同类对象)。 - 示例:发现
com.yourplatform.sql.ParameterWrapper对象占据 80% 内存,数量高达 20 万+。
- 如果是死循环,这里可能会看到某种业务对象(如
-
右键查看引用链(Path to GC Roots),追踪是谁在“疯狂生产”这些对象。
2.2 检查“丧尸病毒”扩散路径
- 使用 OQL 查询(类似 SQL 的对象查询语言):
如果发现大量相同特征的对象(如相同 SQL 片段),说明解析逻辑中存在重复创建。SELECT * FROM com.yourplatform.sql.ParameterWrapper WHERE toString(context).like "%死循环特征字段%"
3. 第三步:捉拿真凶——线程快照与代码反推
3.1 分析线程快照
打开 thread_dump.txt,搜索 RUNNABLE 状态的线程:
"main-thread" #1 prio=5 os_prio=0 tid=0x00007f... nid=0x1e1 runnable
at com.yourplatform.sql.Parser.parse(SQLParser.java:666)
at com.yourplatform.sql.Compiler.compile(Compiler.java:233)
发现某个线程长期卡在 SQLParser.java:666 行(这行号真吉利)。
3.2 关联代码
查看代码文件 SQLParser.java 第 666 行:
// 解析 WHERE 条件时,处理嵌套逻辑的递归函数
private void parseCondition(Node node) {
while (node.hasChild()) {
// 此处忘记设置终止条件,导致无限递归创建子对象!
parseCondition(node.getChild());
}
}
恍然大悟:
「这递归函数,比甲方改需求的次数还多,根本停不下来!」
4. 第四步:修复与防御——给“丧尸病毒”打疫苗
4.1 紧急修复代码
-
终止递归:给递归函数增加深度限制
private void parseCondition(Node node, int depth) { if (depth > 100) { throw new SQLParseException("嵌套层数过多,你是想搞崩服务器吗?"); } while (node.hasChild()) { parseCondition(node.getChild(), depth + 1); // 传递深度参数 } } -
防御性熔断:在 SQL 解析层添加超时控制
ExecutorService executor = Executors.newSingleThreadExecutor(); Future<?> future = executor.submit(() -> sqlParser.parse(sql)); try { future.get(500, TimeUnit.MILLISECONDS); // 超时直接掐死 } catch (TimeoutException e) { future.cancel(true); throw new SQLTimeoutException("SQL 你是《信条》看多了吗?别再时间循环了!"); }
4.2 防御三件套
-
代码审查:
- 所有递归/循环逻辑必须带终止条件和深度计数器。
- 在 PR 描述里写:「此代码已通过《开端》剧组测试,不会进入死循环。」
-
压力测试:
用 JMeter 发送 1000 条复杂嵌套 SQL,观察内存曲线是否平稳。 -
监控告警:
- 对 JVM 内存设置 Prometheus 告警(如 Old Gen > 80% 持续 1 分钟)。
- 在 Grafana 看板上用红字标注:「距离下一次 OOM 还有 XX MB!」
5. 程序员冷笑话时间
-
Q:为什么程序员讨厌无限循环?
A:因为他们的人生已经是一个循环了——写 Bug、改 Bug、加班、写 Bug…… -
Q:递归函数和甲方有什么共同点?
A:都让你觉得「这 TM 什么时候是个头?」
6. 结语:让数据平台告别“丧尸围城”
通过这次事故,我明白了两件事:
- 死循环代码的破坏力,堪比老板突然要求“今晚上线”。
- Heap Dump 是程序员的“时间宝石”,能让你在崩溃中逆转未来。
最后送大家一句保命箴言:
递归千万条,终止第一条;
防御不规范,运维两行泪!
行动号召:
快去检查你的递归函数和循环逻辑,否则下次服务器炸的时候——
你的头发会和内存一样,消失得无影无踪! 💥