1. 事故开场凌晨的告警与一个经典误判产线质检工位的Java视觉检测服务又挂了。这是当天上午的第三次OOM崩溃现场同事发来的截图里容器日志末尾就是一行刺眼的java.lang.OutOfMemoryError: Java heap space。客户已经把电话打到项目交付负责人那里质检系统的识别服务起不来产线上的产品就无法放行整条流水线只能停在物料缓存区。按合同约定连续停线超过三天单日赔付10万五天封顶——也就是大家嘴里常说的那道50万红线。我先快速交代一下背景。这套系统是一个工业缺陷检测项目Java 11后端服务调用YOLO模型识别产线照片上的缺陷区域。YOLO模型通过ONNX Runtime做推理图像解码和预处理用OpenCV完成整体以守护进程方式跑在现场工控机的容器里容器内存上限2GBJVM堆内存1GB。系统上线三个多月平常就像隐形人一样安静直到这个周一。第一次崩溃时现场反馈的原话是内存占用慢慢飙到顶然后进程被杀掉自动拉起后十几分钟又飙上去。这句话信息量很大重启后反复崩溃说明不是偶发抖动而是确定性泄漏十几分钟就能涨满说明泄漏速率不低。我最初的第一判断和大家一样——堆内存泄漏。某个集合无限增长、某个缓存没设上限这种问题我做过太多次代码评审闭着眼睛都能列出一串候选对象。我甚至已经想好要先怀疑哪几个模块了。结果这个直觉带我绕了整整一天弯路。后面这三天发生的事值得任何一个在Java里调过YOLO、调过OpenCV的人看一看。2. 排查第一天堆内存方向的努力全部落空2.1 堆转储与对象分析堆里干干净净当天下午我先登录工控机用jstat观察GC和堆占用jstat -gcutil 12345 1000 S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 14.22 78.35 62.10 95.12 94.80 18234 112.34 128 45.67 158.01GC日志里能看到老年代O列在缓慢爬升Full GC从隔几十分钟一次逐渐变成每隔几分钟一次。这个曲线太像堆内存在泄漏了。我立刻用jmap拉了一份堆转储jmap -dump:formatb,fileheap.hprof 12345然后用内存分析工具看直方图和支配树。结果出乎意料堆里没有一头独大的巨型对象。排在最前面的是正常的业务对象、byte[]和String没有任何一个集合在无上限增长支配树也很干净没有那种A引用B、B引用A的复杂引用链把大对象钉死在老年代。最可疑的byte[]数量多但单个都不大而且随着GC能够正常回收。换句话说JVM堆本身并没有泄漏。这是个非常关键的岔路口。堆内存泄漏通常会有明显特征——某个类的实例数持续增长或者某条引用链把大量对象固定在老年代。我这里两样都不占那就说明大概率找错了方向。当天晚上我做了一个正确决定给JVM加上-XX:HeapDumpOnOutOfMemoryError参数把现场重启后的堆转储保留下来同时反复提醒自己别被OutOfMemoryError: Java heap space这几行字框住。2.2 一个被忽略的关键数据RSS和堆的差额周二上午现场又崩了一次。我远程上去把所有能抓的指标都抓了一遍无意间发现一个被我忽略很久的事实top里进程的RES驻留内存在1.5GB上下波动而jstat显示堆实际使用只有500MB左右Metaspace也只有100MB上下。两者之间差了近900MB这部分内存既不在堆里也不在Metaspace里。到这一步我基本能确认问题出在堆外内存也就是Native Memory。真正的高明做法本应该第一天就把这个差额算出来——堆外内存的增长恰恰是JNI类应用内存泄漏的经典信号只是我当时被Java heap space的开头误导白白浪费了一天。2.3 一个额外教训堆转储差点引发二次事故这里插一个细节。第一天我在服务还活着的时候直接执行jmap -dump那个动作让服务卡了好几分钟Full GC被触发接着容器又被杀了一次。后来反思在内存已经接近极限的服务上做堆转储等于给一只脚已经悬空的骆驼再压一袋货。正确做法是先看jstat和top这类轻量指标确认堆外嫌疑之后再决定要不要转储真要转也最好挑在刚重启、内存还在低位的时间窗口做。3. 第二天三个命令锁定YOLO推理链上的Native泄漏3.1 用JVM自带的Native Memory Tracking看底层确定方向转向Native内存之后我做的第一件事是启用JVM的Native Memory TrackingNMT这个工具能看到JVM自己管理的各种native内存区域的占用# 先停服务在启动参数里加一行 -XX:NativeMemoryTrackingsummary等服务跑起来十几分钟后执行jcmd 12345 VM.native_memory summary输出大致是这样Native Memory Tracking: Total: reserved2451MB, committed1839MB - Java Heap (reserved1024MB, committed780MB) - Class (reserved120MB, committed110MB) - Thread (reserved80MB, committed80MB) - Code (reserved60MB, committed35MB) - GC (reserved220MB, committed130MB) - Internal (reserved90MB, committed70MB) - Symbol (reserved30MB, committed28MB)我把NMT里所有committed项目加起来大概1.2GB左右但进程的实际RSS已经涨到1.8GB。也就是说至少有500MB多的内存连JVM自己都不记账。OpenCV和ONNX Runtime这些通过JNI调用的原生库内存都是它们自己在mallocNMT根本看不见。这个账目对不上的差额就是最直接的破案线索。3.2 复现实验让泄漏在十分钟内现形光看线上数据还不够我得有一个能稳定复现的路径不然改完代码没法验证。我在本地写了段压测脚本对着检测服务的接口反复发图片一组全是正常图片另一组掺杂了故意损坏的图片模拟线上解码失败的情况while true; do curl -s -o /dev/null -X POST http://localhost:8080/inference \ -H Content-Type: application/octet-stream --data-binary good.jpg curl -s -o /dev/null -X POST http://localhost:8080/inference \ -H Content-Type: application/octet-stream --data-binary corrupted.jpg sleep 0.5 pid$(pgrep -f inspection-app.jar) echo $(date %H:%M:%S) RSS$(ps -o rss -p $pid) \ HEAP_O$(jstat -gcutil $pid | tail -1 | awk {print $4}) done跑了一百轮数据规律非常清楚只要掺杂损坏图片进程RSS每十轮涨大约三十到五十MB只发正常图片时RSS基本持平。而堆的老年代占用HEAP_O两种情况下都纹丝不动。这说明泄漏发生在异常图片处理这条路径上而且肯定是native层面的分配在泄漏。顺着这条路径去翻代码问题马上就浮出水面。3.3 真正的泄漏点异常分支里没人释放的Mat和OnnxTensor这是当时检测方法的一个简化还原版相信很多人能从中看到自己项目的影子public DetectionResult detect(byte[] frameData) { MatOfByte raw new MatOfByte(frameData); Mat frame Imgcodecs.imdecode(raw, Imgcodecs.IMREAD_COLOR); if (frame.empty()) { // 解码失败raw 和 frame 都没释放 return DetectionResult.empty(); } Mat resized new Mat(); try { Imgproc.resize(frame, resized, new Size(640, 640)); } catch (Exception e) { // 异常路径frame、resized、raw 都没释放 return DetectionResult.error(e); } Mat blob Dnn.blobFromImage(resized, 1.0 / 255.0, new Size(640, 640), new Scalar(0, 0, 0), true, false); OnnxTensor tensor null; try { tensor OnnxTensor.createTensor(env, blobData(blob), blobShape); OrtSession.Result result session.run(tensor); return parseResult(result); } catch (OrtException e) { // 异常路径tensor、blob、resized、frame、raw 都没释放 return DetectionResult.error(e); } finally { if (tensor ! null) tensor.close(); blob.release(); } }坑就藏在几个提前return里。正常路径确实有人记得release但异常路径直接return了前面已经创建的Mat、MatOfByte、OnnxTensor全被丢掉。这些对象的Java引用会进GC可它们背后持有的native内存块并不会被JVM自动回收——OpenCV的Mat必须显式调用release()ONNX Runtime的OnnxTensor必须显式close()GC帮不了这个忙。那为什么系统能稳定跑三个月因为线上解码失败率极低偶尔漏掉几个Mat完全看不出来。直到客户调整了产线速度、换了相机参数坏图比例上来之后泄漏速度从细水长流变成开闸放水内存两三个小时就顶到容器上限。这就是典型的平时小病不断一换环境就猝死的延时炸弹。4. 止血与根治重构代码后产线恢复4.1 止血方案主动重启脚本先撑住产线找到根因之后不可能立刻改完代码上生产。周三上午我先做了两件事给产线续命。第一在容器外面加了一个内存看护脚本RSS超过1.5GB就主动执行优雅重启而不是等OOM Killer粗暴地杀进程#!/bin/bash THRESHOLD_KB1500000 while true; do pid$(pgrep -f inspection-app.jar) if [ -n $pid ]; then rss$(ps -o rss -p $pid | awk {print $1}) if [ $rss -gt $THRESHOLD_KB ]; then systemctl restart inspection-app echo $(date) restart triggered, RSS${rss}KB /var/log/restart_guard.log fi fi sleep 60 done这个脚本的目的不是修bug而是把突然死亡变成计划内重启给客户一个能正常出货的窗口。第二我把监控告警阈值调到容器内存使用率的80%内存曲线一旦持续爬升超过两小时就立刻通知。这两步做完产线在当天中午先恢复了流转留出时间让我安心改代码。4.2 代码重构资源释放治理不再依赖人的记性根治方案的核心是把资源释放从记得写变成强制写。重构后的检测方法长这样public class YoloDetector implements AutoCloseable { private final OrtEnvironment env; private final OrtSession session; private static final Size INPUT_SIZE new Size(640, 640); public YoloDetector(String modelPath) throws OrtException { this.env OrtEnvironment.getEnvironment(); OrtSession.SessionOptions options new OrtSession.SessionOptions(); options.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT); options.setIntraOpNumThreads(2); this.session env.createSession(modelPath, options); } public DetectionResult detect(byte[] frameData) { MatOfByte raw new MatOfByte(frameData); Mat frame null; Mat resized null; Mat blob null; try { frame Imgcodecs.imdecode(raw, Imgcodecs.IMREAD_COLOR); if (frame.empty()) { return DetectionResult.empty(); } resized new Mat(); Imgproc.resize(frame, resized, INPUT_SIZE); blob Dnn.blobFromImage(resized, 1.0 / 255.0, INPUT_SIZE, new Scalar(0, 0, 0), true, false); try (OnnxTensor tensor createTensor(blob)) { return runInference(tensor); } } catch (Exception e) { log.error(detect failed, e); return DetectionResult.error(e); } finally { release(raw, frame, resized, blob); } } private static void release(MatOfByte raw, Mat... mats) { raw.release(); for (Mat mat : mats) { if (mat ! null) { mat.release(); } } } }几个改动背后各有讲究。第一所有Mat的release()统一收拢到finally里不管哪条路径返回资源都会被释放。这是最朴素也最可靠的做法比在每一处return前手工复制三行release强得多。第二OnnxTensor用上了Java的try-with-resources它本身实现了AutoCloseable用完自动关。这里踩过的坑是OnnxTensor不关闭除了native内存泄漏在某些版本里还会导致推理线程阻塞属于内存和性能双杀。第三整个OrtSession改成单例启动时创建一次之后每次推理都复用同一个session。重构成单例之后我再也不担心每个请求都重新加载模型这种恐怖写法了——那会直接把模型文件、算子图、权重全部重复加载进native内存。如果实在需要多路并发也应该是用线程池 固定数量的session而不是每个请求新建一个。4.3 边界问题glibc内存碎片和容器限额也别放过重构上线之后内存曲线确实平稳多了但我注意到一个现象RSS虽然不再暴涨却也没有完全回落到基线水平。后来又做了一次实验才确认这是glibc的malloc arena碎片在作祟。多线程Java应用频繁在JNI层做小型内存分配时glibc会创建多个内存arena来减少锁竞争每个arena都可能占用64MB以上的虚拟内存而且不会主动归还给操作系统。在容器环境里这种看起来像泄漏、其实不是泄漏的现象特别容易误判。解决方式不复杂在容器的环境变量里限制arena数量即可MALLOC_ARENA_MAX2注意这个值不是越大越好限制得太狠可能会让高并发JNI调用的分配性能下降。我们这个服务并发不高设成2实测很稳。如果你的是高并发服务可以从默认值开始逐步往下调观察CPU和内存的平衡点。另一个顺带修正的地方是JVM堆和容器限额的配比。原来容器2GB、堆固定1GB在native内存泄漏的时候堆和native互相挤压加速了崩溃。后来我把启动参数改成以容器内存为基础的百分比设置给native留出足够余量-XX:MaxRAMPercentage60.0 -XX:InitialRAMPercentage60.0这样至少保证native内存出现波动时堆不会把容器内存瞬间打满。4.4 监控与回归让同类泄漏在测试阶段现形代码修完只是第一步。为了不让这玩意儿换个马甲卷土重来我做了一套很轻量的防守。在监控侧接了一个内存趋势告警容器内存使用率超过80%持续十分钟或者两小时内上升超过15个百分点就直接报警。线性的内存爬升是泄漏和碎片问题的早期共性告警比堆dump好用得多。在回归侧把第三节那个复现实验直接固化成自动化测试每次CI跑完后循环执行一百次推理其中故意混入若干张损坏图片断言进程RSS增长不超过设定阈值。这个测试成本很低但它能拦住绝大多数异常分支没有释放资源的回归。当时我们团队内部流传一句话泄漏代码就像韭菜割完一茬还会长关键是把检查机制种在土里。5. 方法论沉淀内存泄漏排查的速查手册5.1 症状对应病因的对照表经过这三天我把Java应用原生内存问题的常见症状整理成了一张速查表后面几次排查基本靠它就能定位个八九不离十。现象可能方向验证手段堆Used正常RSS持续增长Native内存/JNI泄漏或线程栈增长jcmd VM.native_memorypmapFull GC之后RSS不降堆外内存或native分配未释放复现实验结合代码审查重启后短时间再次涨满确定性泄漏速率较快构造异常样本压测复现Heap Dump里找不到问题对象不是堆泄漏重点查JNI模块用排除法先看堆外RSS高但CPU占用低内存碎片而非计算繁忙检查glibc arena、容器限额5.2 排查工具清单这次实战用到的工具不多但每一个都在关键节点起了作用工具用途常用命令jstat观察堆、GC、老年代趋势jstat -gcutil pid 1000jmap堆转储分析堆内对象jmap -dump:formatb,filex.hprof pidjcmd查看JVM native内存统计jcmd pid VM.native_memory summary.diffpmap查看进程内存映射与匿名块pmap -x pidps/top观察RSS、CPU变化ps -o rss -p pid代码审查查JNI资源的创建与释放配对重点查异常路径和提前return5.3 一套可复用的检查清单最后把这套排查思路浓缩成清单我自己后来的项目评审和线上故障排查都在用先分堆内堆外RSS减去堆committed、Metaspace、线程栈、CodeCache之后还有大额缺口就是native泄漏。复现是前提没有稳定复现路径的排查都是猜。故意制造异常样本触发异常分支往往能加速泄漏。审查资源配对所有Mat、OnnxTensor、OrtSession、ByteBuffer的创建点逐一对上释放点特别注意catch和提前return路径。排查JNI模块项目里凡是接了OpenCV、ONNX Runtime、protobuf这类原生库的地方都要按谁分配谁释放的原则过一遍。区分泄漏和碎片MALLOC_ARENA_MAX这个环境变量可以快速验证是不是glibc arena碎片改完观察RSS是否回落到基线。监控先行、应急垫底内存告警阈值、主动重启脚本、CI泄漏回归测试这三样至少要有一套别再让问题只能靠人去盯着。6. 最后想说的一些实盘体会这三天熬下来我最深的一个体会是内存泄漏不是技术问题是流程问题。代码里没人会故意不释放资源但几乎每个项目都会在某个版本迭代里有人往异常路径里塞了一个提前return而恰好那条路径上有一张没被release的Mat。这种bug靠检查代码是查不完的靠这次记得了更不现实唯一可靠的兜底是把资源释放强制收进finally再配上一套能自动复现和告警的机制。顺便说一个后来才发现的细节重构之后我专门让团队把生产环境的坏图、超时图都收集了一批放进回归样本库。因为这世上最怕的不是漏一次而是漏在你自己根本想不到的输入上。产线环境的图片千奇百怪你以为只有损坏图片会触发异常路径实际上尺寸奇大的图EXIF方向异常的图纯黑纯白图全都能在某个OpenCV操作里抛出你想不到的异常。把这些真实样本收集起来让每次发布前都对着它们跑一遍比看多少篇排查教程都管用。这次差点赔进去的50万最终换来的是一套完整的资源释放规范和排查方法论也算没白交学费。如果你也在Java里调YOLO、调OpenCV希望你永远用不上这篇但真到了那一步记得先算RSS和堆的差额别像我一样在堆里白折腾一天。