Java 的并发模型在过去二十多年里经历了从操作系统线程到轻量级协程的深刻演变。从早期的 synchronized 与 wait/notify,到 java.util.concurrent 的线程池体系,再到 Java 21 正式落地的虚拟线程,开发者手中的工具越来越丰富,但“什么场景用什么”却变得更难回答。本文梳理这条演进主线,并给出可落地的实践要点。
一、线程池:并发编程的基本盘
线程池的本质是复用昂贵的操作系统线程,避免频繁创建销毁带来的开销。ThreadPoolExecutor 的七个参数中,核心线程数、最大线程数、工作队列和拒绝策略最为关键,它们共同决定了系统在负载攀升时的行为曲线。
实践中有两点值得注意:
- 队列无界的线程池会掩盖过载信号,任务持续堆积最终以 OOM 的形式爆发。Executors.newFixedThreadPool 使用的正是无界队列,生产环境应显式构造 ThreadPoolExecutor 并设置有界队列。
- 拒绝策略不应停留在默认的 AbortPolicy。对可丢弃的任务(如埋点上报),采用自定义降级逻辑或记录后再丢弃,往往比直接抛异常更合理。
二、线程数怎么定:从经验公式到压测验证
“CPU 密集型设 N+1,IO 密集型设 2N”是最广为流传的经验值,但它只是起点而非终点。真实系统中任务的阻塞比例、下游服务响应时间的抖动,都会让理论值失效。
更可靠的做法是:先估算单任务的平均耗时与等待占比,给出一组初始参数,再通过压测观察队列长度、活跃线程数和任务的 P99 延迟,逐步逼近拐点。同时应按业务划分独立的线程池,避免一个慢下游拖垮全部请求——线程池隔离本质上是一种故障隔离手段,而非单纯的数量分配问题。
三、异步化:CompletableFuture 的正确用法
任务链一长,“提交—回调—再提交”的写法很快会演变成回调地狱。CompletableFuture 提供 thenApply、thenCompose、allOf 等组合子,让异步代码可以像链式调用一样组织。
一个常见的坑是默认线程池:不指定 Executor 时,异步回调会跑在 ForkJoinPool.commonPool() 上,其并行度约为 CPU 核数减一,且被整个 JVM 共享。IO 密集场景下,正确做法是为每条任务链显式传入专属线程池,否则几条慢请求就可能阻塞公共池,殃及整个进程。
四、虚拟线程:IO 密集型场景的范式转变
Java 21 正式发布虚拟线程,改变了“线程昂贵”这一前提。虚拟线程由 JVM 调度,阻塞时自动挂起并让出载体线程,百万级并发连接不再必须依赖复杂的异步改写。它的用法接近普通线程:
- 通过 Thread.ofVirtual().start() 或 Executors.newVirtualThreadPerTaskExecutor() 创建;
- JDBC 查询、HttpClient 等阻塞调用可以直接写,无需回调嵌套。
三个前提值得记住。第一,虚拟线程不应池化,它们本身廉价,复用反而限制吞吐。第二,避免长时间占用载体线程,例如在 synchronized 块内执行阻塞调用(JDK 24 已消除这类 pinning 问题)。第三,用 Semaphore 限制对下游的并发数,替代传统池容量控制。
选型建议
CPU 密集型计算和需要精细资源控制的应用,平台线程池依然是稳妥之选;高并发 IO 场景,虚拟线程加上仍处于预览阶段的结构化并发,代表了更简洁的方向。对存量系统而言,虚拟线程几乎不需要改动业务代码即可获得吞吐提升,这也是它被称为一次“温和革命”的原因。