最近接了个智能质检的项目,业务那边要求在生产线上实时识别出特定外观缺陷。算法同事花了一周,用YOLOv8训练了一个模型,准确率做到九成多,效果挺理想。但他交付的是一个 .pt 文件,而我们的后端是 Java,服务要嵌进现有的 Spring Cloud 体系里。传统做法是单独起一个 Python 推理服务,可这样一来,每次模型更新要让算法团队配合上线,还要维护两套环境的依赖和监控,光想想运维成本就头疼。
所以我就开始折腾“Java 工程师能不能自己把 YOLO 模型接进业务系统”这件事。从模型转换、Java 推理、并发优化到最终丢进产线,踩了不少坑,也总结了一套可以复用的流程。这篇东西适合那些被算法同事“丢”过来一个模型文件、又得自己搞定线上服务的后端工程师,也适合想深入了解模型部署链路、但不想被 Python 生态绑死的人。
1. 整体思路与方案选型:Java 里跑 YOLO,到底有哪些路子
先说结论:Java 调用 YOLO 完全可行,而且生产环境里已经有很成熟的方案。关键是选对路线,别一上来就想着自己造轮子。
1.1 为什么 Java 工程师需要直面模型部署
在很多团队里,模型训练和业务后端是两拨人。算法工程师用 Python 做实验、出模型,交付物往往是一堆 checkpoint 文件。但真正的业务系统跑在 Java 上,需要把模型能力做成接口,比如“传入一张图片,返回检测框坐标和类别”。
如果每次都要靠 Python 服务中转,就会遇到几个现实问题:
- 链路变长,多一跳就多一层延迟和故障风险。
- 模型更新需要跨团队协调,上线窗口难对齐。
- 运维要同时盯性能和资源,Python 服务的进程管理、依赖隔离都要额外处理。
- 调试问题时,Java 侧拿不到 Python 侧的中间结果,排查链路很长。
所以,在 Java 进程内直接完成推理,是降低系统复杂度的关键一步。只要推理延迟可控,Java 侧直跑模型在工程上是最顺的。
1.2 四条主流技术路线的横向对比
我在选型的时候,认真对比过四种方案。先给结论:最推荐的是 ONNX Runtime Java 版,其次是 DJL,OpenCV DNN 适合轻量场景但不适合产线,自己写 JNI 封装 C++ 框架适合极致性能但对团队要求太高。
| 方案 | 部署复杂度 | 推理性能 | 模型格式 | 适合场景 |
|---|---|---|---|---|
| Python 独立服务 | 高,多一套环境 | 取决于框架本身 | PyTorch/TensorRT 均可 | 已有 Python 基础设施的团队 |
| ONNX Runtime Java | 低,一个 jar 依赖 | 高,有 CPU/GPU 加速 | ONNX | 大多数 Java 后端的首选 |
| DJL | 低,但抽象层偏重 | 中高 | PyTorch/TensorFlow/ONNX | 需要多种框架支持的项目 |
| OpenCV DNN | 低 | 中低,优化一般 | ONNX、Caffe 等 | 简单 Demo、边缘小场景 |
| JNI 封装 TensorRT | 高,需同时写 Java/C++ | 最高 | TensorRT 引擎 | 性能要求极端的产线 |
我最终选了 ONNX Runtime Java。原因有几点:
- 它支持 CPU、GPU、TensorRT 多种执行后端,换硬件不用改代码。
- ONNX 是开放的模型交换格式,算法同事从 PyTorch 导出很方便,不用迁到特定框架。
- Java API 封装得比较完整,张量创建、运行、释放都有清晰接口,内存管理可控。
- 社区活跃,遇到问题能找到不少资料。
1.3 选型之外的三个判断标准
选型不能只看技术指标,还要考虑这几个问题:
- 团队会不会 C++:如果没人会写 JNI,那 TensorRT 直调就别想了。
- 模型更新频率:如果频繁小版本迭代,选 ONNX Runtime 这种“换文件就行”的方案最省心。每次模型更新只需要替换 ONNX 文件,然后做一下回归验证,不需要重新编译和打包整个服务。
- 线上是 CPU 还是 GPU:CPU 方案能跑,但要控制并发;GPU 方案吞吐高,但显存管理要谨慎。如果公司暂时没有 GPU 资源,ONNX Runtime 的 CPU 优化也能扛住中等规模的请求量。
提示:如果团队里已经有人维护独立的 Python 推理服务,而且链路稳定,不一定非要迁到 Java 内部推理。工程上没有银弹,“多一轮服务就多一轮故障”,但也有“专业服务更聚焦”的优势。我这边选择 Java 直跑,是因为要嵌入现有的分布式链路,不想再增加一个独立部署单元。
2. 模型准备:从 PyTorch 权重到 ONNX 的转换细节
拿到了算法同事给的 best.pt,第一步是转成 ONNX。这一步看似简单,但坑不少,转换参数对不对,直接决定后面 Java 端能不能用、用起来顺不顺。
2.1 YOLOv8 的官方导出命令与关键参数
如果用 ultralytics 框架训练,导出 ONNX 的命令非常简洁。我个人推荐在 Python 环境里执行,版本尽量和训练时保持一致:
pip install ultralytics onnx onnxsimfrom ultralytics import YOLO model = YOLO("best.pt") model.export( format="onnx", imgsz=640, opset=12, dynamic=False, simplify=True, )几个参数的取舍,我拆开讲:
format="onnx":输出 .onnx 格式,这是后续 Java 推理的基础。imgsz=640:训练时如果用的就是 640x640,这里保持一致。如果训练时用了 1280,那导出也要写 1280,否则输入尺寸变了会影响精度。opset=12:ONNX 算子集的版本。ONNX Runtime 对 opset 12 支持很成熟,不用追求更高的版本,兼容性反而重要。dynamic=False:固定输入尺寸。如果希望支持动态宽高的图片,可以设成dynamic=True,但推理时动态尺寸会带来额外的显存分配和性能损耗,固定尺寸在产线更稳。simplify=True:用 onnxsim 对计算图做简化和常量折叠,能去掉一些冗余算子,减少推理时的计算量。
导出之后,用下面的脚本检查一下模型结构,确认输入输出的 shape 是否符合预期:
import onnx model = onnx.load("best.onnx") print(model.graph.input[0]) print(model.graph.output[0])正常情况,YOLOv8 的输入是一个[1, 3, 640, 640]的 float32 张量,输出是[1, 84, 8400]。这个84的含义是4 个坐标值 + 80 个类别置信度,8400是不同尺度下 anchor 点的总数。
2.2 输入输出张量结构解析
理解这个张量结构非常重要,因为它决定了 Java 端后处理怎么写。
YOLOv8 是 anchor-free 的检测模型。它会在三个不同尺度(80x80、40x40、20x20)的特征图上生成预测。每个尺度的每个位置,网络预测:
- 4 个值:目标框的中心点坐标和宽高(xywh,基于特征图坐标归一化后的值)
- 80 个值:每个类别的置信度分数(COCO 80 类场景;如果是自定义数据集,这个数字就是你的类别数)
输出[1, 84, 8400]中,8400就是把三个尺度的网格点加起来:80*80 + 40*40 + 20*20 = 6400 + 1600 + 400 = 8400。
维度顺序别搞错,输出是[batch, channels, anchors],也就是[1, 84, 8400]。在 Java 端处理时,需要转成[8400, 84]的视角,才能逐行解析每个预测框。
2.3 预处理与后处理的职责边界
模型本身只负责“图像 -> 原始预测结果”的映射。图像缩放、归一化、坐标映射、置信度过滤、NMS 这些都需要在代码里实现。
预处理要做这些事:
- 读图,将输入图片转换为 RGB 三通道。
- Letterbox 缩放:保持宽高比,将图片缩放到 640x640,剩余区域用灰色(通常 114、127 或 128)填充。这一步很重要,直接拉伸会让目标变形,影响精度。
- 归一化:像素值从 0-255 缩放到 0-1,即除以 255.0。
- 排列张量:把 HWC 转为 CHW,也就是
[height, width, channels]变成[channels, height, width]。 - 增加 batch 维度,最终变成
[1, 3, 640, 640]。
后处理要做这些事:
- 从
[1, 84, 8400]中提取每个预测框的坐标和类别分数。 - 计算每个框的置信度,过滤低于阈值(比如 0.25)的框。
- 将 xywh 坐标映射回原图尺寸,注意要减去 letterbox 的 padding 值。
- 按类别进行 NMS(非极大值抑制),去除重叠的重复检测框。
- 输出最终的检测框、类别 ID、置信度。
注意:有些人导出 ONNX 时会在模型内部带 NMS 算子,这样输出直接就是过滤后的坐标和分数。简化了后处理,但灵活性差,想要调整 NMS 阈值就得重新导出。我建议把 NMS 留在应用层自己实现,方便线上调参。
3. Java 端推理实现全流程
模型转换好了,接下来就是重头戏:用 Java 把 ONNX 跑起来。这里我用 ONNX Runtime 的 Java API,结合一个实际可运行的示例逐步讲解。
3.1 引入依赖与初始化 Session
Maven 依赖很简单:
<dependency> <groupId>com.microsoft.onnxruntime</groupId> <artifactId>onnxruntime</artifactId> <version>1.17.1</version> </dependency>如果要用 GPU,就用onnxruntime_gpu替代onnxruntime。GPU 版本的 jar 体积更大,但包含 CUDA 相关依赖库。
初始化推理会话:
import ai.onnxruntime. OrtEnvironment; import ai.onnxruntime. OrtSession; public class YoloDetector implements AutoCloseable { private final OrtEnvironment env; private final OrtSession session; public YoloDetector(String modelPath) throws OrtException { this.env = OrtEnvironment.getEnvironment(); OrtSession.SessionOptions options = new OrtSession.SessionOptions(); // 开启所有优化级别 options.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT); this.session = env.createSession(modelPath, options); } }几个容易忽略的细节:
OrtEnvironment是整个进程共享的,只创建一次。不要每个请求都新建,否则性能和内存都会出问题。OrtSession是线程安全的,可以多个线程同时调用run方法。setOptimizationLevel建议设成ALL_OPT,ONNX Runtime 会做计算图优化,推理速度有明显提升。
3.2 前处理:图片缩放、归一化与张量创建
前处理我用 Java 原生BufferedImage实现,不依赖 OpenCV,方便大多数后端项目直接复用。
import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; import java.io.InputStream; import java.nio.FloatBuffer; public class ImagePreprocessor { private static final int INPUT_SIZE = 640; private static final float[] MEAN = {0f, 0f, 0f}; private static final float[] STD = {1f, 1f, 1f}; public static float[] preprocess(InputStream imageStream) throws Exception { BufferedImage img = ImageIO.read(imageStream); return preprocess(img); } public static float[] preprocess(BufferedImage img) { // 1. Letterbox 缩放 int origW = img.getWidth(); int origH = img.getHeight(); float ratio = Math.min((float) INPUT_SIZE / origW, (float) INPUT_SIZE / origH); int newW = Math.round(origW * ratio); int newH = Math.round(origH * ratio); int padX = (INPUT_SIZE - newW) / 2; int padY = (INPUT_SIZE - newH) / 2; BufferedImage resized = new BufferedImage(INPUT_SIZE, INPUT_SIZE, BufferedImage.TYPE_3BYTE_BGR); Graphics2D g = resized.createGraphics(); g.setColor(new Color(128, 128, 128)); // 填充灰色 padding g.fillRect(0, 0, INPUT_SIZE, INPUT_SIZE); g.drawImage(img, padX, padY, newW, newH, null); g.dispose(); // 2. 转 float 数组,CHW 排列,归一化 float[] data = new float[3 * INPUT_SIZE * INPUT_SIZE]; for (int y = 0; y < INPUT_SIZE; y++) { for (int x = 0; x < INPUT_SIZE; x++) { int rgb = resized.getRGB(x, y); // BufferedImage 是 BGR 顺序,而 YOLO 训练用的是 RGB float b = ((rgb >> 0) & 0xFF) / 255.0f; float g_ = ((rgb >> 8) & 0xFF) / 255.0f; float r = ((rgb >> 16) & 0xFF) / 255.0f; int idx = y * INPUT_SIZE + x; data[idx] = r; // C=0 data[idx + INPUT_SIZE * INPUT_SIZE] = g_; // C=1 data[idx + 2 * INPUT_SIZE * INPUT_SIZE] = b; // C=2 } } return data; } }这里有一个非常关键的坑:YOLO 在训练时用的是 RGB 三通道顺序。但 Java 的BufferedImage.TYPE_3BYTE_BGR以及getRGB返回的 int 值,低字节是蓝色分量。如果不转换,会导致模型的检测结果大幅下降,甚至什么都检测不出来。我第一次跑通时没注意这个,出来的框全是乱的,排查了很久才发现。
3.3 后处理:坐标解码、置信度过滤与 NMS
推理完成后,拿到的是原始输出张量。接下来要解码。我用一个简单的实现,方便说明核心逻辑:
import ai.onnxruntime.OnnxTensor; import ai.onnxruntime.OrtSession; import java.nio.FloatBuffer; import java.util.*; public class YoloPostProcessor { private static final int NUM_CLASSES = 80; private static final float CONF_THRESHOLD = 0.25f; private static final float NMS_THRESHOLD = 0.45f; public static List<Detection> process(float[] output, int origW, int origH, float resizeRatio, int padX, int padY) { // output 长度为 84 * 8400,但 ONNX 输出是 [1, 84, 8400] 的内存连续排列 // 需要先转成 [8400, 84] 的视角读取 int anchors = output.length / (4 + NUM_CLASSES); List<Detection> candidates = new ArrayList<>(); for (int i = 0; i < anchors; i++) { int offset = i * (4 + NUM_CLASSES); float cx = output[offset]; float cy = output[offset + 1]; float w = output[offset + 2]; float h = output[offset + 3]; // 从 80 个类别分数中取最大值 float maxScore = 0; int bestClass = -1; for (int j = 0; j < NUM_CLASSES; j++) { float score = output[offset + 4 + j]; if (score > maxScore) { maxScore = score; bestClass = j; } } if (maxScore < CONF_THRESHOLD) { continue; } // 解码:坐标基于 640x640 的输入图像,映射回原图 float x1 = (cx - w / 2 - padX) / resizeRatio; float y1 = (cy - h / 2 - padY) / resizeRatio; float x2 = (cx + w / 2 - padX) / resizeRatio; float y2 = (cy + h / 2 - padY) / resizeRatio; candidates.add(new Detection(x1, y1, x2, y2, maxScore, bestClass)); } return nms(candidates, NMS_THRESHOLD); } }NMS 的实现也不复杂,核心逻辑是:先按置信度排序,从最高分框开始,删除所有与它 IoU 超过阈值的同类别框,不停迭代。
private static List<Detection> nms(List<Detection> detections, float threshold) { detections.sort((a, b) -> Float.compare(b.score, a.score)); List<Detection> result = new ArrayList<>(); while (!detections.isEmpty()) { Detection best = detections.remove(0); result.add(best); detections.removeIf(d -> d.classId == best.classId && iou(d, best) > threshold); } return result; } private static float iou(Detection a, Detection b) { float x1 = Math.max(a.x1, b.x1); float y1 = Math.max(a.y1, b.y1); float x2 = Math.min(a.x2, b.x2); float y2 = Math.min(a.y2, b.y2); float interW = Math.max(0, x2 - x1); float interH = Math.max(0, y2 - y1); float inter = interW * interH; float areaA = (a.x2 - a.x1) * (a.y2 - a.y1); float areaB = (b.x2 - b.x1) * (b.y2 - b.y1); return inter / (areaA + areaB - inter); }注意:后处理里的 NMS 如果写在 Java 里,数据量大时(8400 个框)确实会有一些 CPU 开销。但实测下来,单张图的 NMS 时间在 1~3ms 以内,完全可接受。如果追求极致性能,可以用 ONNX 原生的 NMS 算子替代,但我觉得现阶段没必要引入额外的复杂度。
3.4 完整推理调用示例
把前处理、推理、后处理串起来,一个最小的检测服务就出来了:
import ai.onnxruntime.*; import java.nio.FloatBuffer; import java.util.*; public class YoloInference { private final OrtEnvironment env; private final OrtSession session; private final ImagePreprocessor preprocessor = new ImagePreprocessor(); public YoloInference(String modelPath) throws OrtException { env = OrtEnvironment.getEnvironment(); OrtSession.SessionOptions options = new OrtSession.SessionOptions(); options.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT); session = env.createSession(modelPath, options); } public List<Detection> detect(BufferedImage image) throws OrtException, InterruptedException { float[] input = preprocessor.preprocess(image); long[] shape = {1, 3, 640, 640}; // 创建张量 OnnxTensor tensor = OnnxTensor.createTensor(env, FloatBuffer.wrap(input), shape); // 运行推理 String inputName = session.getInputNames().iterator().next(); // 注意:输出名通常是 "output0" OrtSession.Result result = session.run(Collections.singletonMap(inputName, tensor)); // 提取输出 OnnxTensor outputTensor = (OnnxTensor) result.get(0); float[] output = outputTensor.getFloatBuffer().array(); // 后处理 int origW = image.getWidth(); int origH = image.getHeight(); float ratio = Math.min(640f / origW, 640f / origH); int padX = Math.round((640 - origW * ratio) / 2); int padY = Math.round((640 - origH * ratio) / 2); List<Detection> detections = YoloPostProcessor.process(output, origW, origH, ratio, padX, padY); // 资源释放很重要!避免内存泄漏 tensor.close(); result.close(); return detections; } public void close() throws OrtException { session.close(); env.close(); } }tensor.close()和result.close()这两行一定要记得。ONNX Runtime 的张量数据在堆外内存,JVM 的 GC 帮不了太多,必须手动释放。如果漏了,线上跑久了堆外内存会持续上涨,最后 OOM。
3.5 异常处理与日志设计
推理服务集成到业务系统里,异常处理不能草率。我一般会在方法里捕获两类异常:OrtException表示模型推理失败,IOException表示图片读取或预处理失败。这两类异常要分别记录:
try { List<Detection> detections = detector.detect(image); if (detections.isEmpty()) { log.warn("no objects detected, traceId={}", traceId); } return detections; } catch (OrtException e) { log.error("onnx runtime inference failed, traceId={}", traceId, e); throw new BizException("model inference error"); } catch (Exception e) { log.error("image preprocessing failed, traceId={}", traceId, e); throw new BizException("invalid image"); }这里我强烈建议打日志时带上 traceId,方便接入分布式链路追踪。图片的字节流要限制大小,比如不超过 5MB,避免超大图片导致 OOM 或推理耗时爆炸。我一般在 Controller 层做限制,然后在 service 层再做一次防护。
4. 落到产线:性能、稳定性与运维细节
本地跑通 Demo 只是第一步。真正把模型部署到产线上,要考虑的工程问题更多。性能、稳定性、可运维性,三者缺一不可。
4.1 高并发下的线程池与队列削峰策略
推理是一个 CPU/GPU 密集操作。如果直接使用业务线程池跑推理,遇到突发流量,线程数飙升,会直接把 CPU 打满,反而拖垮整个服务。
我的做法是单独为推理建立一个有界线程池,并用一个有界队列做削峰:
ThreadPoolExecutor inferencePool = new ThreadPoolExecutor( 4, // corePoolSize 8, // maximumPoolSize 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(200), // 有界队列 new ThreadPoolExecutor.CallerRunsPolicy() );这里几个参数的经验:
- corePoolSize 设置多少:如果是 CPU 推理,建议等于机器物理核数的一半到三分之二。比如 8 核机器,设 4~6 个线程比较合适。因为推理本身会占满一个核,设置的太多反而增加上下文切换开销。如果是 GPU 推理,CPU 只负责预处理和调度,可以少一些,4 个线程就够。
- maximumPoolSize 不要设置太大:别因为请求多就疯狂加线程。推理服务的瓶颈在 CPU/GPU 计算能力,线程池再大也只是排队。
- 队列有界 + 拒绝策略:
CallerRunsPolicy表示队列满了之后,由调用方线程执行。这其实是一个天然的背压机制:生产速度会变慢,而不是无限堆积导致 OOM。
用信号量做并发防护也是常见做法。比如Semaphore限制同时推理的请求数,超出直接返回 503,让上游重试或降级。
4.2 GPU 推理与显存管理
如果公司有条件上 GPU,ONNX Runtime 的 CUDA 执行后端能带来几十倍的性能提升。配置方式很简单,初始化 Session 时指定 provider:
options.addCUDA(0);但 GPU 推理有几个额外的坑:
- 显存泄漏:每次
run之后如果张量未关闭,显存会持续增长,直到触发 OOM。我线上遇到过凌晨显存占满导致推理结果全部超时的问题,最后定位到是某个分支没走try-with-resources。 - 预热:CUDA context 初始化和 kernel 编译需要时间,服务启动后前几个请求会很慢。我通常在启动时跑 10 张图预热,让 CUDA 完成初始化。
- 动态 batch 的取舍:ONNX Runtime 支持动态 batch 输入。比如攒 4 张图一次性推理,T4 这样的卡吞吐能翻一倍。但动态 batch 也会增加显存占用和延迟抖动。我建议先在单图模式下把整体链路跑稳,再考虑 batch 优化。
我在某个安防项目里实测过,T4 卡上 YOLOv8n 单图推理耗时约 2-4ms,CPU 上则要 30-60ms。GPU 带来的性能提升非常明显,代价是需要维护显存开销和 CUDA 环境。如果预算允许,高频推理场景绝对值得上 GPU。
4.3 模型热更新与灰度发布
产线模型不是一成不变的。算法同事每个月可能迭代一次模型。如果每次更新都要重启服务,成本太高,还影响在线业务。
我的做法是用AtomicReference持有当前模型 Session,更新时加载新 Session 成功后再替换引用,实现无感热更新:
public class ModelHolder { private final AtomicReference<OrtSession> sessionRef; private final OrtEnvironment env = OrtEnvironment.getEnvironment(); private final String modelPath; public ModelHolder(String modelPath) throws OrtException { this.sessionRef = new AtomicReference<>(loadSession(modelPath)); this.modelPath = modelPath; } private OrtSession loadSession(String path) throws OrtException { OrtSession.SessionOptions options = new OrtSession.SessionOptions(); options.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT); return env.createSession(path, options); } public void hotSwap(String newPath) throws OrtException { OrtSession newSession = loadSession(newPath); // 先加载新模型,成功后再替换 OrtSession oldSession = sessionRef.getAndSet(newSession); // 关闭旧 Session,释放资源 oldSession.close(); } public OrtSession getSession() { return sessionRef.get(); } }热更新有几个细节:
- 先加载,后替换:如果先替换再加载失败,线上就是无模型可用,直接全挂。
- 新旧共存一段时间:替换后不建议立刻 close 旧 session,因为可能还有请求正在用。可以延迟 10 秒再关闭,或者用引用计数管理。
- 验证新模型:热更新前,先在新 Session 上跑几张三张测试图,确认检测精度没有明显下降再切换。我一般会在代码里内置一个验证用例,启动时和热更新时自动执行。
4.4 可观测性:指标埋点与告警设计
线上跑模型,最怕的就是模型“悄悄变差”。没有指标监控,出了问题根本不知道是服务挂了还是模型精度下降了。
我用 Micrometer 做了这些指标:
| 指标 | 用途 |
|---|---|
| inference.histogram | 推理耗时分布,观察 p95/p99 |
| inference.qps | 每秒检测请求数 |
| queue.depth | 推理队列当前堆积数 |
| detect.total / detect.empty | 检测总数和空检测数 |
| model.version | 当前模型版本号 |
空检测数非常重要。正常情况下,生产场景的图片不会全是空白。如果一段时间内空检测率异常升高,大概率是模型精度下降、输入异常或预处理出错。我遇到过预处理 BGR/RGB 翻转之后测试集上看着没问题、线上数据分布一变就全部空检测的情况,如果没有这个指标,根本发现不了是模型出了问题。
告警策略:
- p99 推理耗时超过 200ms 持续 5 分钟,告警。
- 队列深度超过阈值,说明处理不过来,告警并考虑扩容或降级。
- 空检测率超过 20%,告警提示模型或预处理可能异常。
这些指标都通过 Micrometer 接入 Prometheus,Grafana 出面板,比较省事。
4.5 部署形态与资源建议
说到部署,最开始我们也是打成一个 Spring Boot 服务直接怼到机器上,后来量上来了再用容器化。
如果是 Docker 部署:
FROM eclipse-temurin:17-jre WORKDIR /app COPY target/app.jar app.jar COPY models/best.onnx models/best.onnx EXPOSE 8080 ENTRYPOINT ["java", "-Xms2g", "-Xmx2g", "-jar", "app.jar"]需要留意的是容器里 ONNX Runtime 的动态链接库。官方 jar 包里已经带了各平台的 so 文件,一般不用额外处理。但如果用了 GPU,需要挂载 NVIDIA 的 runtime,且镜像里要有 CUDA 相关库。
内存配置上,我建议-Xms和-Xmx设成一样,避免堆扩容抖动。堆外内存要考虑 ONNX Runtime 的张量缓冲,我通常留出至少堆内存的 25% 给堆外。
5. 踩坑实录与经验小结
这一节纯粹是实战里摔出来的经验。每个坑都花了时间排查,写出来希望大家少走弯路。
5.1 最容易翻车的五个细节
第一个坑是BGR/RGB 通道顺序。前文提过,Java 的 BufferedImage 默认 BGR 读取,如果不转成 RGB,模型检测直接失效。最可怕的是它不会报错,而是静默地输出低置信度或是空结果。排查时如果不了解训练时的通道顺序,很容易以为模型有问题。
第二个坑是letterbox 的 padding 没正确映射回原图。如果推理时用了 letterbox,后处理时忘了减 padding,检测框就会“集体偏移”。这个错和通道顺序错不一样,它是有一半结果是对的,但位置是偏的。我建议把整条链路的中间结果打印出来,对比一下某个框的前后坐标,能快速定位。
第三个坑是OnnxTensor 没有关闭导致堆外内存泄漏。Java 开发者习惯了对象被 GC 回收,但堆外内存 GC 管不到。每次推理创建的张量不关,10 万次请求后堆外内存可能涨到几个 GB,服务直接崩溃。我现在要求所有推理代码必须用 try-with-resources 或者显式 close。
第四个坑是动态 batch 导出后反而变慢。ONNX Runtime 的动态尺寸支持是有代价的,每次需要查询输入输出的实际 shape,并且显存分配会变复杂。固定输入尺寸的模型在服务端更稳定,也不影响绝大多数业务。我建议不到万不得已不要开动态尺寸。
第五个坑是线程数设置过于“野心勃勃”。一开始我觉得核心多,线程也设多,结果推理性能反而下降。因为 CPU 密集操作一旦线程数超过核心数,线程切换的开销会让整体吞吐下降。线程数设为物理核数的 1/2 到 2/3 是最稳的区间。
5.2 性能调优后的参考数据
我整理了一份常见硬件下的性能参考,基于 YOLOv8n(默认 COCO 类别),640x640 输入,仅供参考:
| 硬件 | 单次推理耗时 | 并发线程数 | 备注 |
|---|---|---|---|
| 8 核 CPU 虚拟机 | 35-60ms | 4-6 | 可以支撑单机几十 QPS |
| 16 核 CPU 物理机 | 20-35ms | 8-10 | 加上 batch 能再提升 |
| T4 GPU | 2-4ms | 2-4 | 吞吐提升明显,显存占用约 1GB |
| 3080 级别 GPU | 1-3ms | 2-4 | 类似 T4,取决于驱动版本 |
调优思路:先测单线程耗时,然后通过线程数和队列深度找到最大吞吐点。不要盲目相信别人给的参数,不同模型大小、图片分辨率、CPU 架构差异很大。
5.3 我的个人体会与后续扩展
做了几次模型落地之后,我最大的体会是:模型部署的难点,往往不在“推理”本身,而在周边的工程链路。通道顺序、坐标映射、内存释放、并发控制,这些才是线上真正会翻车的地方。在本地 Demo 跑通的模型,到产线上要经历一次系统的“工程化洗礼”,才能真正稳定运行。
如果你们团队也是第一次搞 Java + YOLO,我建议先别追求复杂的容器编排和 GPU 弹性伸缩,先用单机 + 固定线程池 + 监控指标把整条链路跑稳,观察一两周线上的指标表现,再逐步引入 batch 推理、GPU 加速和热更新能力。这个节奏相比直接上全套架构,成本更低,踩坑也更容易定位。
另外,模型部署之后不是一劳永逸的。新数据进来,模型精度会漂移,定期用线上样本做评测,及时把效果变差的模型撤下,才是后续真正要持续投入的事情。如果后续想把性能做得更极致,可以再研究 TensorRT 的 Java 封装方案,或者把预处理阶段用 JNI 下发到 GPU 并行处理。但那些都属于进阶玩法,先把基础链路跑扎实,比什么都重要。