《Java 并发工具箱:线程池、CompletableFuture 与虚拟线程》
要点
Java 写并发,日常真正要打交道的就三样:线程池(托管资源)、CompletableFuture(编排异步)、虚拟线程(换并发模型)。这篇按”什么时候用哪个”的主线讲:线程池的参数怎么定、怎么监控,CompletableFuture 的编排与异常传播,虚拟线程的正确姿势,最后把结构化并发的现状交代清楚。语言特性底子(record、模式匹配这些)不在这里展开,见Java 新特性篇。
本文不做:synchronized/volatile 与 JMM 的内存可见性原理、AQS 与锁实现、并发容器的源码分析。口径:JDK 25(2025-09-16 GA 的 LTS)为主,全部示例兼容 JDK 21;结构化并发仍是预览特性,文中如实标注。并发代码的测试写法(超时断言、并发场景用例)在Java 测试入门篇。
ThreadPoolExecutor:七个参数怎么定
动手配池之前先给任务分类:这段代码的时间花在算(CPU)上,还是花在等(IO)上?两类任务的池化思路完全不同——算型任务线程多了互相抢核,等型任务线程少了白白空等。后面的参数公式、虚拟线程的适用判断,都建立在这个分类上。分类拿不准时,用打点量一次”计算耗时”和”等待耗时”的比例,一分钟的事,比争论半天快。
生产代码里不该出现 Executors.newFixedThreadPool 这类工厂方法——固定线程数配的是无界队列,队列可以无限涨,OOM 时你连现场都找不到。手动 new,把七个参数摆到明面上:
AtomicInteger POOL_SEQ = new AtomicInteger(); // 线程名序号
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, // corePoolSize:常驻线程
16, // maximumPoolSize:峰值线程
60, TimeUnit.SECONDS, // 非核心线程空闲存活时间
new ArrayBlockingQueue<>(500), // 有界队列:必须有界
r -> { // 命名线程工厂
Thread t = new Thread(r, "order-pool-" + POOL_SEQ.getAndIncrement());
t.setDaemon(false);
return t;
},
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);
参数怎么定,我的经验口径(经验公式,不是定律):CPU 密集任务,核心数取核数到核数加一;IO 密集任务按”核数 × (1 + 等待/计算比)”估,等得越久线程可以越多。队列容量看任务的可丢弃性与内存预算,不是拍 10000。拒绝策略四种里,CallerRunsPolicy 让提交线程自己干活,天然形成背压,多数场景比直接抛异常体面。
还有一条纪律:线程池必须有名字。出问题翻线程 dump 时,”pool-1-thread-3″ 和 “order-pool-3” 是两种排查体验。
扩容顺序与监控
执行顺序很多人背反了:队列满了才会创建超出 core 的线程,不是先扩线程再入队。core 满 → 入队 → 队列也满 → 才扩到 max → max 也满才触发拒绝。验收:提交 1000 个任务实测这个顺序,行为与理解一致才算真懂这个池。
池子跑起来后别当黑盒,ThreadPoolExecutor 自带的监控点够用:
pool.getActiveCount(); // 正在干活的线程数
pool.getQueue().size(); // 排队中的任务数
pool.getCompletedTaskCount(); // 已完成任务数
pool.getLargestPoolSize(); // 历史峰值线程数
把这几项暴露成指标(接进公司现有的 metrics 体系),容量调整靠数据不靠拍脑袋。队列持续上涨是最值得报警的信号——它意味着消费速度追不上生产速度,后面等着的就是拒绝策略登场。
CompletableFuture:编排与异常
聚合多个下游调用是 CompletableFuture 的主场。一个”订单页”场景:订单、用户两路并行取,合并渲染,任何一路挂了降级:
CompletableFuture<Order> orderF =
CompletableFuture.supplyAsync(() -> orderService.fetch(id), pool);
CompletableFuture<User> userF =
CompletableFuture.supplyAsync(() -> userService.fetch(id), pool);
CompletableFuture<OrderPage> page = orderF
.thenCombine(userF, this::render)
.orTimeout(2, TimeUnit.SECONDS)
.exceptionally(ex -> OrderPage.fallback());
两条补充编排:下游依赖上游结果时用 thenCompose(fetchOrder 返回 CompletableFuture 的场景,flatMap 式展开,避免嵌套两层 future);批量并发用 allOf:
List<CompletableFuture<Item>> futures = ids.stream()
.map(id -> CompletableFuture.supplyAsync(() -> itemService.fetch(id), pool))
.toList();
CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();
List<Item> items = futures.stream().map(CompletableFuture::join).toList();
三条关键纪律,都是从坑里捞出来的:
- 所有异步方法显式传 pool:不传就落进 ForkJoinPool.commonPool——那是给 CPU 密集并行流准备的共享池,你的 IO 任务在里面会互相饿死,还可能拖垮 JVM 里其他用 commonPool 的功能。
- thenApply 与 thenApplyAsync 的区别是”谁执行”:不带 Async 的在同一线程或调用线程继续,带 Async 的重新提交到池。IO 动作一律用带 Async 的版本,也别在 thenApply 里再发 IO——那等于占住上游线程干下游的活,并发度悄悄掉一半。
- 异常沿链传播:链上任何一环抛了,后面的正常步骤全被跳过,直到 exceptionally / handle / whenComplete 接住。exceptionally 只处理失败并给兜底值,handle 成败都进。最阴的坑是”链路吞异常”:没有接住的异常躺在 future 里,没人 get 就没人看见——排查”任务没跑”的问题先查这个。
验收:故意让 orderService.fetch 抛异常,确认页面返回 fallback 而不是 500,且日志里能看到原始异常(whenComplete 里记一次日志是好习惯)。
虚拟线程:换模型,不是换更快的线程
虚拟线程(JDK 21 转正)解决的是”线程太贵”:百万级阻塞式 IO 并发不再需要池化技巧,一个请求一个线程直接写:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i ->
executor.submit(() -> {
var resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
return resp.statusCode();
}));
}
// try 块结束自动等待并关闭
什么时候用哪个,我按这个矩阵判断:
- IO 密集、并发高、逻辑简单:虚拟线程。代码保持同步写法,可读性最好;
- CPU 密集:平台线程池。虚拟线程不会让计算变快,核数就是上限;
- 要限流:用 Semaphore 限并发,不要用池化虚拟线程——”把虚拟线程放进池子”是新手最常见的反向优化,池化的意义(复用昂贵资源)对虚拟线程不存在;
- 需要精细控制队列、拒绝策略:还是平台线程池,ThreadPoolExecutor 那套参数虚拟线程给不了。
关于 pinning:JDK 21 时代 synchronized 长代码块会把虚拟线程钉在载体线程上,削弱吞吐;这个已知问题在 JDK 24 通过 JEP 491 修复,JDK 25 上写 synchronized 不再有此顾虑(native 调用栈中仍有 pin 场景,属于边缘情况)。升级到 21 之后版本的意义,这就是一个。
框架侧的现状
多数人不是手动 new 虚拟线程,而是从框架里拿到它。Spring Boot 3.2 起提供开关(spring.threads.virtual.enabled=true),打开后 Web 层的请求处理线程换成虚拟线程,业务代码零改动;数据库驱动、HTTP 客户端这些依赖是否”虚拟线程友好”(不内部池化阻塞、不做 pin 场景),决定实际收益有多大——开关打开后压测对比一次再决定要不要留在生产配置里。自建工具类里的批量 IO 任务,则直接用上面 newVirtualThreadPerTaskExecutor 的写法。
结构化并发:现状与一笔
StructuredTaskScope 把”一组相关子任务,同生共死”写成结构。JDK 25 里它是第五个预览版(JEP 505),API 换过一轮设计:静态工厂 open 加 joiner 表达”全部成功”或”任一成功”的汇合策略:
// JDK 25 预览 API,编译运行都要 --enable-preview
try (var scope = StructuredTaskScope.open()) {
Subtask<Order> o = scope.fork(() -> orderService.fetch(id));
Subtask<User> u = scope.fork(() -> userService.fetch(id));
scope.join();
return new OrderPage(o.get(), u.get());
}
两路并发、一路失败另一路自动取消——这正是 CompletableFuture 手写取消逻辑最费劲的部分。但预览 API 换过几轮设计,我的建议是了解概念、生产先观望,等转正再迁移;上面这段代码的价值是让你认得出它的形状,真用的时候知道往哪查。
常见坑
- 无界队列:new LinkedBlockingQueue 不传容量(Executors 工厂方法的默认值),背压失效,内存只涨不跌。
- 虚拟线程里包池化:把 newVirtualThreadPerTaskExecutor 的结果再套一层固定池,等于白换模型。
- ThreadLocal 滥用:百万虚拟线程 × 大对象的 ThreadLocal,内存放大极其可观;跨任务传上下文用 ScopedValue(JDK 25 预览)或显式参数。
- orTimeout 之后任务还在跑:超时只是让 future 完成,底层请求不会自动取消,要取消得自己在链路里处理。
- commonPool 饥饿:并行流和没传池的 CompletableFuture 共用一个小池,一个 IO 任务高峰能把全 JVM 的并行计算拖慢,这个坑跨模块传染。
- 拿 sleep 测吞吐:Thread.sleep 是最”乖”的阻塞,真实 IO 的开销分布长得多,用 sleep 撑出来的吞吐数字没有任何参考价值,压测要打真实依赖或打桩模拟真实延迟分布。
验收清单
- 线程池:有界队列 + 命名线程工厂 + 明确拒绝策略,三样齐全,参数旁注释了估算依据。
- 压一池 1000 任务:队列满后才扩线程、拒绝策略按预期触发,行为与理解一致。
- 池的核心指标(活跃数/队列长/完成数)已接入监控或有日志输出点。
- CompletableFuture:两路并行 + orTimeout + exceptionally 全链路走通,失败路径有日志有兜底;批量场景 allOf 跑通。
- 虚拟线程:万级并发 IO 任务跑通,无池化包装,吞吐对比用自己实测数据记录。
- 并发代码有超时断言的测试兜底(写法见JUnit 5 测试篇),CI 里跑不过不许合。
把工具箱用到业务里,需要新语法打底时回Java 新特性篇补;异步逻辑怎么在测试里断言超时与并发,见Java 测试入门篇。
本文数据核对时间:2026-02-23:JDK 25 LTS(2025-09-16 GA)与 JDK 21 LTS;虚拟线程于 JDK 21 转正(JEP 444),synchronized pinning 经 JEP 491 于 JDK 24 修复;结构化并发为 JEP 505 第五预览(JDK 25,需 –enable-preview)。