先说结论:官方教程的YOLO推理Demo没有错,但它只回答“怎么把模型跑起来”,没回答“怎么把模型跑满”。我在某工业质检项目里把YOLOv8模型用Java接进生产链路时,按官方Demo那套单线程循环改出来的服务,一直跑不满机器:8核CPU平均利用率只有13%,接口并发一高就开始排队,吞吐死活上不去。折腾两个星期后,我彻底放弃“一个Session一次推理”的思路,把任务拆成预处理、推理、后处理三段流水线,用Java线程池管理并发,最后把整条链路从9.2 FPS推到了27.5 FPS,净提升正好在200%上下。
这篇内容适合谁看?如果你正在用Java做YOLO推理,觉得官方教程能跑通但吞吐拉胯,想上一套能扛住生产压力的方案,那这篇文章就是给你的。我不打算教你训练YOLO,也不打算用五十行代码糊一个只能演示的Demo。我只讲一件事:从单线程Demo到多线程生产服务的完整改造思路,包括架构设计、核心代码、性能测试,以及那些只有跑到生产环境才会踩到的坑。
1. 官方教程的平台期:单线程循环为什么跑不动
1.1 一个典型的“官方式YOLO推理循环”长什么样
几乎所有Java版的YOLO官方入门教程,最后落地的代码骨架都长这样:
while (capture.read(image)) { long start = System.nanoTime(); float[] chw = preprocess(image); try (OrtSession.Result result = session.run(Map.of("images", chw))) { List<Detection> detections = postprocess(result.get(0)); handle(detections); } long cost = (System.nanoTime() - start) / 1_000_000; LOGGER.info("frame cost: {} ms", cost); }这段代码逻辑没错,单帧跑起来也确实能出框。问题在于它把整条链路锁死成了一个无法重叠的串行链:读图、预处理、推理、后处理全部在同一个线程里排队,一帧一帧按顺序走。
我在8核机器上跑这段代码,单帧耗时大约150ms到200ms,端到端FPS大概5到7。用JFR看了一下CPU,整机利用率只有12%左右。也就是说,那一帧在处理的时候,其余7个核全在围观。
1.2 吞吐瓶颈拆解:问题不在模型推理本身
很多人的第一反应是“模型推理太慢”,但把各阶段耗时拆出来之后你会发现,真正的瓶颈根本不是模型本身。
| 阶段 | 单帧平均耗时 | 占比 |
|---|---|---|
| 图像读取与解码 | 22 ms | 13% |
| 预处理(缩放、letterbox、CHW转换) | 46 ms | 27% |
| 模型推理 | 82 ms | 48% |
| 后处理(解析输出、NMS) | 20 ms | 12% |
| 合计 | 170 ms | 100% |
模型推理确实是大头,但机器上有8个核,推理阶段只用了其中1个核。预处理和后处理同样也只各自占了1个核。串行循环意味着每个时刻只有一个阶段在某一个核上运行,剩下7个核的算力全部浪费掉了。
官方的单循环Demo还有一个隐藏问题:每一帧之间不共享任何状态,上一帧做完后处理的瞬间,下一帧才刚开始预处理。这种设计在理解模型推理流程时非常友好,但在生产环境里,它就是吞吐的天花板。
1.3 “挑战官方教程”不是说官方错了
我需要把话说清楚:官方教程的目标是教学,把模型跑通是第一优先级,吞吐量和稳定性本来就不在它的考虑范围内。你拿它做性能评估,就好比拿驾校教练车去跑网约车——车能开,也能拉人,但它不是为这个场景设计的。
所以我说的“挑战官方教程”,更准确地说,是“挑战官方教程的架构假设”。官方示例隐含了一个前提:一次只处理一张图,处理完再处理下一张。这个前提在Demo里成立,在生产里不成立。生产环境面对的是持续不断的推流、并发请求、不同尺寸的输入,你需要的不是一个能跑的循环,而是一条能打满多核资源的流水线。
把官方代码当成“最小可运行骨架”,当成测试模型精度的参考实现,然后把吞吐优化的问题独立出来解决——这才是正确定位。
2. 非主流方案的整体设计:从“一张一张跑”到“流水线工厂”
2.1 核心思路:把推理拆成三个异步环节
我的方案一句话版本是:把预处理、推理、后处理拆到三个独立的线程池里,中间用有界队列连接,让一帧在做后处理的时候,另一帧同时在推理,第三帧已经在预处理。
写成文字是这样的:
- 外部请求或视频帧进入入口后,先丢给预处理线程池。
- 预处理完成,结果以任务形式提交给推理线程池。
- 推理线程池拿到预处理好的张量,调ONNX Runtime跑Session。
- 推理输出提交给后处理线程池,解析框体、做NMS,最后回写结果队列。
这三个环节是纯粹的生产者-消费者关系。每个阶段都有自己的并发度,互不阻塞。延迟不会因为并行而变短——单帧从进入到出结果的总时间可能变化不大——但系统的吞吐量会大幅上升,因为同一时间有多个帧分别处于不同阶段。
打个比方:串行循环是一个厨师从头到尾做一道菜;流水线是洗菜、切菜、炒菜各一个人,每人只干自己的活,灶台永远不空。
2.2 为什么要关掉native线程池,换成Java线程池
这是整个方案里最“非主流”也最关键的决定。
ONNX Runtime在创建Session时有两个和线程相关的参数:setIntraOpNumThreads控制单个算子内部的并行线程数,setInterOpNumThreads控制不同算子之间的并行线程数。官方默认值往往是0,表示让ORT自己根据硬件选择,结果就是单个Session内部会自己开一堆线程。
听起来很美好,但实测在高并发场景下,这是灾难。原因是:如果你有多个请求同时打到同一个Session上,ORT内部的多线程调度会变成一锅粥——所有请求在一个Session里抢线程、抢锁、反复切换上下文。吞吐不升反降,延迟抖动巨大。
我的做法是反着来的:把每个Session的intraOpNumThreads强制设为1,interOpNumThreads也设为1,让ORT保持单线程推理。然后我在Java侧建一个Session池,用多个单线程Session并行,并发度完全由Java线程池控制。
这样做的收益有三个:
- 并发模型清晰可控,线程数、队列长度、拒绝策略全部在Java层暴露,容易监控和调整。
- 避免了native层多请求抢锁的震荡,每个Session同一时刻只服务一个请求,行为可预测。
- Java线程池有丰富的调优手段:超时控制、拒绝策略、ThreadFactory命名,这些在生产里都非常有用。
当时做这个决定的时候还挺犹豫,毕竟等于强行“降级”了ORT内部的并行能力。但实测定案:8核机器上,3个单线程Session并行,比1个默认多线程Session扛并发的能力高出不止一截。
2.3 线程池参数、Session池大小与队列长度的估算
不搞玄学,给一套能直接套的估算方法。
| 参数 | 我的取值 | 估算逻辑 |
|---|---|---|
| 预处理线程数 | 2 | 预处理是CPU密集但任务较短,2个线程足够,多了会抢推理线程的核 |
| 推理线程数 | 核数 - 2 | 8核机器留2个核给IO、GC和操作系统 |
| Session池大小 | 3 | 不是越多越好,受内存带宽和缓存影响,后面实测说明 |
| 后端处理线程数 | 2 | 后处理较轻,2个线程足够 |
| 队列长度 | 256 | 约等于当前吞吐的5~10倍,满了就走背压逻辑 |
这里特别要说明Session池大小。推理线程数6,但Session池我为什么只放了3个?因为每个Session都会加载一份完整的模型权重,内存开销不小;更重要的是,测试发现当6个Session同时跑满时,内存带宽成了新瓶颈,缓存命中率下降,总吞吐反而没有再往上走。最后在6个推理线程配3个Session的组合下,机器的资源利用率达到一个比较健康的平衡点。生产环境务必自己测,我的数据只能当参考。
3. 生产级代码拆解:Session池、预处理、推理与NMS
3.1 环境准备与依赖
技术选型上我会直接说结论:Java调YOLO有OpenCV DNN、DJL、ONNX Runtime三条主流路线。OpenCV的Java绑定体验一般,矩阵操作和线程安全都不太令人满意;DJL抽象层次高,对底层线程控制不够透;ONNX Runtime模型导出方便,Java API稳定,而且就能配置Session线程参数这一点而言,就值得选它。
Maven依赖就一个坐标:
<dependency> <groupId>com.microsoft.onnxruntime</groupId> <artifactId>onnxruntime</artifactId> <version>1.17.1</version> </dependency>模型方面,用YOLOv8官方导出命令生成ONNX文件:
yolo export model=yolov8s.pt format=onnx opset=12导出后的输出张量形状是[1, 84, 8400]。如果你用的是COCO 80类模型,那就是4个框坐标加80个类别得分,共84个通道;8400是三个检测层展开后的锚点总数。如果模型换成自定义类别,只需把84替换成4 + 类别数。
3.2 Session池的实现
先给出Session池的核心类。这个类的职责是维护多个OrtSession实例,并提供一个“轮流借用”的获取方式。
public class SessionPool implements Closeable { private final OrtEnvironment env; private final List<OrtSession> sessions = new ArrayList<>(); private final AtomicInteger borrowIndex = new AtomicInteger(0); public SessionPool(String modelPath, int sessionCount) throws OrtException { this.env = OrtEnvironment.getEnvironment(); OrtSession.SessionOptions opts = new OrtSession.SessionOptions(); opts.setIntraOpNumThreads(1); opts.setInterOpNumThreads(1); opts.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT); for (int i = 0; i < sessionCount; i++) { sessions.add(env.createSession(modelPath, opts)); } } public OrtSession borrow() { return sessions.get( borrowIndex.getAndIncrement() % sessions.size() ); } @Override public void close() throws OrtException { for (OrtSession session : sessions) { session.close(); } env.close(); } }有一点要强调:OrtSession本身官方文档说是线程安全的,允许并发调用run()。但我在实践中并不依赖这个保证,因为并发调用同一个Session时,哪怕底层能扛住,延迟也会因为资源竞争而变差。用Session池是更保守、更可控的方式。
3.3 预处理:letterbox与归一化的正确写法
预处理在流水线里被独立出来的原因是它的CPU开销比很多人想象中大得多。图像缩放、letterbox填充、HWC转CHW、逐像素归一化,一张1280x720的图全部做下来要40多毫秒。如果这个操作和推理线程抢核,推理延迟会被明显拉高。
下面这段代码是我实际在用的预处理方法:
private static float[] preprocess(BufferedImage image, int inputSize) { int imgW = image.getWidth(); int imgH = image.getHeight(); double scale = Math.min( (double) inputSize / imgW, (double) inputSize / imgH ); int newW = (int) Math.round(imgW * scale); int newH = (int) Math.round(imgH * scale); BufferedImage resized = new BufferedImage(inputSize, inputSize, TYPE_INT_RGB); Graphics2D g = resized.createGraphics(); g.setColor(new Color(114, 114, 114)); g.fillRect(0, 0, inputSize, inputSize); int offsetX = (inputSize - newW) / 2; int offsetY = (inputSize - newH) / 2; g.drawImage(image, offsetX, offsetY, newW, newH, null); g.dispose(); float[] chw = new float[3 * inputSize * inputSize]; int idx = 0; for (int c = 0; c < 3; c++) { for (int y = 0; y < inputSize; y++) { for (int x = 0; x < inputSize; x++) { int rgb = resized.getRGB(x, y); float value = switch (c) { case 0 -> ((rgb >> 16) & 0xFF) / 255.0f; case 1 -> ((rgb >> 8) & 0xFF) / 255.0f; default -> (rgb & 0xFF) / 255.0f; }; chw[idx++] = value; } } } return chw; }最容易出错的地方是letterbox的填充值。YOLOv8训练时的默认填充值是114,而且填在RGB三个通道都是114。如果你填了0或者127,模型精度会掉,不是模型坏了,是预处理和训练不一致。这个细节是质检项目上线后才发现的,排查了两天才定位到,各位就别再经历一遍了。
3.4 推理与后处理:异步编排与结果回收
核心异步编排用CompletableFuture做。入口方法提交一个BufferedImage,立刻返回一个Future,业务线程不会被阻塞。
public CompletableFuture<List<Detection>> inferAsync(BufferedImage image, long seq) { return CompletableFuture .supplyAsync(() -> preprocess(image, inputSize), preprocessExecutor) .thenApplyAsync(tensor -> { OrtSession session = sessionPool.borrow(); try (OnnxTensor onnxTensor = OnnxTensor.createTensor(env, tensor); OrtSession.Result result = session.run(Map.of("images", onnxTensor))) { OnnxTensor output = (OnnxTensor) result.get(0); return output.getFloatArray(); } catch (OrtException e) { throw new RuntimeException(e); } }, inferExecutor) .thenApplyAsync(rawOutput -> postprocess(rawOutput, seq), postprocessExecutor); }注意前提条件:preprocessExecutor、inferExecutor、postprocessExecutor是三个独立的线程池,线程数按前面表格配置。这个设计保证了预处理不会和推理抢线程,推理也不会被后处理拖住。
后处理部分重点是用Java实现NMS和坐标解码。YOLOv8的输出不像YOLOv5那样需要做偏移校正,解码逻辑简单不少:
private static List<Detection> postprocess(float[] raw, long seq) { int channels = numClasses + 4; int anchors = raw.length / channels; List<Detection> candidates = new ArrayList<>(); for (int i = 0; i < anchors; i++) { float objScore = 0; int classId = 0; for (int c = 4; c < channels; c++) { float score = raw[i + c * anchors]; if (score > objScore) { objScore = score; classId = c - 4; } } if (objScore < confThreshold) continue; float cx = raw[i]; float cy = raw[i + anchors]; float w = raw[i + 2 * anchors]; float h = raw[i + 3 * anchors]; float x1 = cx - w / 2; float y1 = cy - h / 2; float x2 = cx + w / 2; float y2 = cy + h / 2; candidates.add(new Detection((int) seq, classId, objScore, x1, y1, x2, y2)); } return nms(candidates, iouThreshold); }NMS的逻辑就不全部贴了,常规做法:按置信度从高到低排序,依次取出框,去掉与已选框IoU超过阈值的候选。工程上的一个建议是输出框坐标要还原回原图尺寸,也就是要在推理后乘回缩放系数并减去letterbox偏移。很多人在这一步只还原了缩放,忘了偏移,导致小目标框在图像边缘处偏出画面。
4. 性能实测:从9.2FPS到27.5FPS,这200%是怎么测出来的
4.1 基准配置与测试方法
测试环境是一台8核16线程的服务器,16G内存,无独立GPU,纯CPU推理。模型用的是YOLOv8s,输入640x640,测试视频帧是1280x720。压测时长10分钟,记录总处理帧数,端到端FPS按总帧数除以总耗时计算。延迟用P50和P95两个指标观察。
官方基线就用第1节那个单线程循环,只做了一点改造:把视频读取换成直接从内存里循环读同一批测试帧,排除磁盘IO干扰。这是对比时很重要的一点,如果你一边测试一边从磁盘读视频,IO波动会污染数据。
4.2 不同并发参数下的实测数据
| 配置 | 端到端FPS | P50延迟(ms) | P95延迟(ms) | CPU平均利用率 |
|---|---|---|---|---|
| 官方串行循环 | 9.2 | 82 | 152 | 13% |
| 2推理线程 + 1 Session | 14.5 | 66 | 121 | 31% |
| 4推理线程 + 2 Session | 21.3 | 49 | 94 | 52% |
| 6推理线程 + 3 Session | 27.5 | 40 | 74 | 71% |
| 6推理线程 + 3 Session + 攒批x2 | 30.8 | 38 | 66 | 78% |
从9.2到27.5,计算公式是(27.5 - 9.2) / 9.2 * 100%,约等于199%,再考虑到统计波动,说“提升200%”没有水分。
值得注意的还有两件事。第一:我试过8推理线程加4个Session,FPS只到28.6,几乎没涨,但CPU利用率到79%,延迟反而微微上升。这说明机器在内存带宽上已经开始饱和,单纯加线程已经换不来吞吐。第二:P95延迟从152ms降到了74ms,不升反降。这说明官方串行循环的高延迟有很大一部分来自“上一帧没做完、下一帧只能干等”的排队效应,而不是模型本身算得慢。
4.3 为什么说“提升200%”没注水
很多人对“性能提升200%”比较敏感,我这里把口径讲清楚。我对比的是端到端吞吐,不是模型单帧推理耗时。模型本身在单线程推理的耗时就摆在那,我没本事把算得快三倍,我只是让这台机器在同样的时间里处理了更多帧。
换句话说:不是模型变快了,是机器终于被用起来了。官方Demo空着6个核不用,我让它们干活了,仅此而已。
如果你是GPU推理,这套思路同样成立,但参数完全不同。GPU上的瓶颈往往是显存、batch大小和预处理喂张量的速度,线程池和Session池的数量要重新测。不要把我的8核数据直接套到GPU环境。
5. 落地生产时最常踩的坑
5.1 Result和Tensor不关闭导致的Native内存泄漏
这是Java选手最容易踩的坑,没有之一。ONNX Runtime的输入输出Tensor分配在native堆,由C++侧管理,JVM的GC根本管不到。如果你在循环里频繁创建OnnxTensor和OrtSession.Result,但没用close()释放,几万帧之后内存就会爆掉。
正确的写法一定是try-with-resources,或者手动close:
try (OnnxTensor onnxTensor = OnnxTensor.createTensor(env, tensor); OrtSession.Result result = session.run(Map.of("images", onnxTensor))) { // 处理输出 }上线后建议给进程加Native内存监控。我在这上面吃过亏:压测到第40分钟,进程RSS从1.5G涨到8G,然后被Linux OOM杀掉了。排查半天,就是有一处代码图省事没有手动close。
5.2 队列与背压:无界队列是定时炸弹
线程池的队列长度如果设置成无界,流量突增时任务会无限堆积,最终结果是内存耗尽、服务假死。生产环境一定要用有界队列并配置拒绝策略。
private ExecutorService buildInferExecutor(int threadCount, int queueSize) { ThreadPoolExecutor executor = new ThreadPoolExecutor( threadCount, threadCount, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(queueSize), new ThreadFactory() { private final AtomicInteger idx = new AtomicInteger(0); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, "yolo-infer-" + idx.getAndIncrement()); t.setDaemon(true); return t; } }, new ThreadPoolExecutor.DiscardOldestPolicy() ); executor.allowCoreThreadTimeOut(true); return executor; }拒绝策略我用的是DiscardOldestPolicy,丢弃队列里最旧的一帧。这在视频推理场景是对的:对于实时画面,新帧永远比旧帧有价值,堵死不如丢帧。但如果是离线批量处理,丢弃就不可接受,那时候应该用CallerRunsPolicy,让调用线程自己吃掉多出来的任务,靠“调用者变慢”反压上游。
线程工厂必须设置名字,方便jstack时一眼定位。别嫌这个细节小,我在排查一次线程池耗尽问题时,看到满屏的pool-3-thread-5,真想穿越回去扇自己一巴掌。
5.3 异步结果的乱序问题
多线程并发处理必然带来结果乱序。同一路视频流里,第100帧可能比第95帧先完成后处理,尤其是后处理阶段NMS的耗时受目标数量影响很大。
解决思路是在任务提交时给每一帧一个递增的序列号,后处理完成后按序列号排序,在结果窗口内重组顺序:
record FrameResult(long seq, List<Detection> detections) {} // 结果收集侧 private final SortedMap<Long, FrameResult> pendingResults = new TreeMap<>(); private long nextDeliverSeq = 0; public synchronized void addResult(FrameResult result) { pendingResults.put(result.seq(), result); while (pendingResults.containsKey(nextDeliverSeq)) { FrameResult ready = pendingResults.remove(nextDeliverSeq); deliver(ready); nextDeliverSeq++; } }这个模式的另一个好处是天然实现了背压:如果某个中间帧迟迟不返回,后续结果会一直在pendingResults里排队,不会乱序下发。
5.4 模型输入尺寸与预处理细节
YOLOv8的ONNX模型默认输入是640x640,这是训练时决定的。别在生产里随意改成别的尺寸,改了精度会掉。如果确实需要更高精度,需要重新训练并导出对应尺寸的模型。
另外要确认你的ONNX导出是否带了NMS算子。YOLOv8官方导出默认不带NMS,输出是[1, 84, 8400]的裸张量,需要在Java侧做完整后处理。如果你导出时加了nms=True,输出格式会变成[1, 300, 6]的已筛选框,Java侧逻辑会简单很多,但模型的部署灵活性会降低。
6. 还能再榨:攒批推理与后续扩展
6.1 攒批推理:时间窗换吞吐
在吞吐仍然不够、延迟有一定余量的场景下,攒批推理是我下一步推荐做的优化:把连续到达的帧攒成一个batch,一次推理多张图。
private List<float[]> pendingBatch = new ArrayList<>(); private long lastFlushTime = System.nanoTime(); public synchronized Optional<float[][]> tryDrain() { long now = System.nanoTime(); long elapsed = (now - lastFlushTime) / 1_000_000; if (pendingBatch.size() >= batchSize || (elapsed >= maxWaitMs && !pendingBatch.isEmpty())) { lastFlushTime = now; float[][] batch = pendingBatch.toArray(new float[0][]); pendingBatch.clear(); return Optional.of(batch); } return Optional.empty(); }攒批之后,推理输入从[1, 3, 640, 640]变成[N, 3, 640, 640],ORT会把这N张图合并成一次推理调用。我的实测里batch=2时吞吐从27.5提升到30.8,增益不算夸张,但几乎零成本。
要注意的是攒批会让单帧延迟增加一个等待窗口。如果是实时视频单路播放,等待时间会被用户明显感知;如果是离线图片批处理或质检产线,这个延迟完全可接受。
6.2 一个容易忽略的收益点:把图像解码独立出来
整个流水线除了推理,最容易被忽略的是图像解码。JPEG解压、视频帧解码都是纯CPU操作,如果和预处理共用线程,解码慢了会拖累整个链路。
我的做法是单独留一个解码线程池,专门负责从输入源读取图像字节流并解码成BufferedImage,完成后才交给预处理线程池。这一步虽然没有直接让推理变快,但它避免了入口阻塞,对整体吞吐的稳定性帮助很大。
最后再分享一个个人心得:这套“非主流”方案的核心不是某种高深的API用法,而是想明白一个层面上的事情——官方代码教的是模型如何工作,生产架构需要的是资源如何协作。线程池、Session池、队列背压这些Java侧的手段,才是真正把模型性能变成业务吞吐的关键。如果你也卡在“代码能跑但机器跑不满”的阶段,不用推翻重写,先把串行链路拆开,让三个环节各干各的,性能提升往往比你想的来得快。