Java 从入门到精通(十四):线程池与异步编程——ThreadPoolExecutor、CompletableFuture 与 ForkJoin

本文默认读者已掌握线程基础与 synchronized/volatile(可参考本系列第十二篇)。JDK 以 8/11 为主,第八节虚拟线程需 JDK 21+。文中源码片段是对 OpenJDK 真实实现的简化改写:保留了位运算、CAS、锁与循环的关键结构,删掉了异常分支与注释噪音,便于阅读但不建议直接拷贝编译。所有 Demo 均可 javac 运行,建议把第三节的位运算实验与第六节的聚合 Demo 亲手跑一遍。

并发能提升吞吐,但”每来一个请求就 new Thread()“是把并发用成了灾难。第十二篇解决了”多个线程如何正确共享数据”,本篇解决另一个维度的问题:线程本身作为一种昂贵资源,该如何被复用、被管控、被观测。从 ThreadPoolExecutor 的位运算源码,到 CompletableFuture 的回调链编排,再到 JDK 21 虚拟线程对这套模型的颠覆,是一条从”会用”到”懂原理”再到”知道何时不该用”的完整路径。

一、为什么要线程池:先把线程的成本算清楚

1.1 一个线程到底有多贵

JDK 21 之前,java.lang.Thread 采用的是 1:1 线程模型:一个 Java 线程直接对应一个操作系统内核线程(pthread/lightweight process),由操作系统负责调度。这带来三项实打实的成本:

一是内存成本。 每个线程都有独立的虚拟机栈,-Xss 默认通常为 1MB(预留的虚拟地址空间,按需提交物理页),此外还有内核侧的 task_struct、内核栈与线程局部存储。粗略估算:1 万个线程仅栈的虚拟地址空间就是 10GB,实际物理占用几十到几百 MB——足以打爆一台 4G 的容器。

二是创建与销毁成本。 new Thread().start() 最终走到 pthread_create,需陷入内核、分配内核数据结构、通知调度器,实测单次开销数十微秒。若请求处理只需 100 微秒,”创建线程”就吃掉了 30% 的预算。

三是上下文切换成本。 线程数超过核数后,OS 要在多个线程间来回切换:保存/恢复寄存器、内核栈指针、页表基址,还会污染 TLB 与各级 Cache。一次切换约几千个 CPU 周期,缓存失效时可达上万。更隐蔽的是切换本身不干活:CPU 时间被”搬运工”吃掉,表现为 sys 利用率飙升、吞吐不升反降。

1.2 无限制创建线程会发生什么

下面这段代码在普通笔记本上跑几十秒就会崩溃,不同环境的报错不同,但都是灾难性的:

三种典型死法:其一,unable to create new native thread——触及 ulimit -u 或内核 threads-max 上限;其二,Java heap space——线程栈虽不在堆里,但 Thread 对象、线程局部变量、被引用的对象图都在堆上;其三被 OOM Killer 杀掉进程。即使侥幸没崩,几千个线程抢几个核会让 CPU 陷入”抖动”:大量时间花在切换上,延迟从毫秒劣化到秒级且毛刺不可预测——最难排查的一类线上问题。

1.3 池化:一种通用的资源治理范式

线程池的本质是资源复用 + 准入管控:预先创建一批线程(预热),用完归还而非销毁(复用),超出容量时排队或拒绝(管控),并对外暴露队列长度、活跃数等指标(可观测)。

这套范式在软件工程中反复出现,横向对比如下:

池类型管理的资源最小/最大容量空闲回收超出时的行为
线程池OS 线程corePoolSize / maximumPoolSizekeepAliveTime入队 / 拒绝策略
数据库连接池TCP 连接minIdle / maxActiveminEvictableIdleTime阻塞等待 / 抛异常
对象池重型对象(如 PB 解析器)min / max空闲检测新建 / 阻塞
HTTP 连接池长连接maxTotal / maxPerRoute空闲驱逐排队等待

它们的共同点是:用有限、可控的资源去服务无限且波动的请求,并把”过载”这个事实显式暴露出来(排队、超时、拒绝),而不是假装资源无限。理解这一点,后面的拒绝策略、有界队列、动态调整就都顺理成章了。

二、ThreadPoolExecutor 的七大参数与任务决策链

2.1 七个参数的语义

ThreadPoolExecutor 最完整的构造函数有七个参数,这是理解线程池的入口:

参数语义取值依据与常见坑
corePoolSize核心线程数,即使空闲也尽量保留(除非开启 allowCoreThreadTimeOut)常驻规模,按负载下限设置;设为 0 时任务先进队列
maximumPoolSize最大线程数,队列满后才会创建超出核心数的线程只有队列有界时才有意义;无界队列下永不生效
keepAliveTime + unit非核心线程空闲多久后被回收潮汐业务设 30~60s;对延迟敏感业务可设更长以减少创建开销
workQueue保存待执行任务的阻塞队列强烈建议有界;队列容量决定了系统的缓冲能力与内存上限
threadFactory线程创建工厂必须自定义命名,否则 pool-1-thread-3 无法定位问题
handler拒绝策略生产环境建议自定义:记录日志 + 落库 + 降级

2.2 任务提交的完整决策链

提交一个任务时,线程池按以下顺序决策,这是全篇最需要记住的图(用文字描述,请默画一遍):

  1. 当前线程数 < corePoolSize → 直接创建新的核心线程执行任务(哪怕其它核心线程正空闲着)。
  2. 线程数已达 corePoolSize → 尝试把任务放进 workQueue。入队成功后还要做一次 recheck(见下文)。
  3. 队列已满 → 尝试创建”非核心线程”(直到 maximumPoolSize)来执行任务。
  4. 线程数已达 maximumPoolSize 且队列已满 → 执行拒绝策略。

对应的简化源码如下(保留关键分支):

2.3 为什么”先入队再加线程”是反直觉的

很多人第一次看这段代码都会疑惑:为什么满了不是先扩容线程,而是先塞进队列?队列都堆了几万个任务了,线程数还守着 core 不动,这不是”眼看要爆了还不加人”吗?

这背后是 Doug Lea 的明确设计意图,源码注释写得很清楚:线程池希望线程数保持在 corePoolSize 附近,只有当队列无法吸收任务(证明核心线程的处理能力确实不够)时,才值得付出创建线程的代价。理由有三:

  • 创建线程昂贵,而入队是相对廉价的内存操作。能用内存缓冲解决的,不要动用操作系统资源。
  • 队列是有界缓冲区,代表”系统还能承受多少积压”。队列能装下,说明只是瞬时波峰,核心线程很快就能消化。
  • 线程数增长有代价:更多线程意味着更多上下文切换与更多内存,盲目扩容反而降低吞吐。

必须记住的推论:如果队列是无界的(比如默认构造的 LinkedBlockingQueue),第 3 步永远走不到,maximumPoolSize 形同虚设。这是第五节 Executors.newFixedThreadPool 最大的坑。

顺带解释第二步的 recheck:offer 之后存在时间窗口,并发调用 shutdown() 会让线程池不再是 RUNNING,任务虽已入队但不应执行,故 remove(command) 并拒绝;另一种情况是线程数恰好掉到 0,队列里躺着任务却无人消费,须补一个 firstTask 为 null 的 worker 来”唤醒”消费。

2.4 corePoolSize 与 maximumPoolSize 怎么定

先要区分任务类型:

CPU 密集型(加密解密、图像处理、复杂计算):线程几乎不等 IO,超过核数只会增加切换开销,经验值 N + 1(N = Runtime.getRuntime().availableProcessors())。那个 +1 是为了在偶发页错误、调度抖动时有一个线程顶上,保证 CPU 不空转。

IO 密集型(数据库查询、RPC 调用、读写文件):线程大量时间在等待,经验值 2N,更严谨的公式是:

比如一个请求 CPU 计算 10ms、等待下游 90ms,则线程数 = N × (1 + 90/10) = 10N。实践还可引入 Little’s Law(排队论):

若目标 1000 QPS、平均 RT 50ms,则并发度至少要 1000 × 0.05 = 50。

这些公式只是起点,不是答案。它们忽略四件事:一是锁竞争,线程再多也得串行等锁;二是容器 CPU 配额,availableProcessors() 在 K8s 里读到的是宿主机核数而非 limit;三是下游瓶颈,线程数加到 200 只是把压力传导给数据库;四是 GC。最终参数必须以压测为准:固定其它条件,单调调整线程数,观察吞吐与 P99 的拐点。

还要注意:corePoolSize == maximumPoolSize 时线程池是固定大小的;想让它”能屈能伸”,两者要拉开差距并配合有界队列使用。

三、源码剖析:ctl、execute、Worker 与回收

3.1 ctl:一个 int 装下两个状态

线程池需要同时维护运行状态与工作线程数,且两者必须原子地一起变化(否则会出现”状态改 SHUTDOWN 的同时线程数加了 1”这类竞态)。用两个 AtomicInteger 就要加锁或引入组合原子性难题,Doug Lea 的解法是:用一个 32 位 int 同时编码两个字段——高 3 位存状态,低 29 位存线程数。

为什么是 3 位?5 种状态至少需 3 位(2^3 = 8 ≥ 5),剩下 29 位存线程数,上限 (2^29) - 1 ≈ 5.37 亿,远超现实需求。

五个状态按数值单调递增排列,方便用大小比较判断”是否该停止接活”:

状态值含义是否接收新任务是否处理队列存量任务
RUNNING-1 << 29正常运行是是
SHUTDOWN0 << 29调用 shutdown(),平和关闭否是
STOP1 << 29调用 shutdownNow(),立即停止否否(队列任务被丢弃并返回)
TIDYING2 << 29所有任务已结束,工作线程数为 0,即将执行 terminated()否否
TERMINATED3 << 29terminated() 执行完毕,彻底终结否否

注意 RUNNING 是负数,这样”状态是否 ≥ SHUTDOWN”这样的判断只需一次比较。相关位运算如下:

可以写个小实验验证位运算的正确性:

一个精妙之处:workerCount 加 1 时直接对 ctl 做整数加法,因为低位进位最多进到第 30 位,而状态位在更高位,正常范围不会溢出污染。这使得”状态 + 数量”可一次 CAS 完成。

3.2 addWorker:CAS 自旋 + 双重检查

addWorker(firstTask, core) 负责真正创建并启动线程,分两段:先用 CAS 自旋增加 workerCount,再实例化 Worker 并启动。

要点:CAS 循环里那句 if (runStateOf(c) != rs) continue retry; 是典型的双重检查——并发下状态可能被别的线程改了,必须回外层重新校验,否则可能在 SHUTDOWN 之后错误地加入 worker。

3.3 Worker:为什么继承 AQS

Worker 是 ThreadPoolExecutor 的内部类,它同时实现了 Runnable 并继承了 AbstractQueuedSynchronizer:

两个设计细节值得反复体会:

其一,为什么不用 ReentrantLock 而要自己写个不可重入的锁? ReentrantLock 可重入,shutdownNow() 里用 tryLock() 判断”线程是否空闲”时,若线程自己已持有锁(如 beforeExecute 里用户调用了 shutdownNow),tryLock() 会错误返回 true,导致正在执行的任务被中断。不可重入锁保证 tryLock() 成功 ⟺ 该 worker 确实没在执行任务。

其二,setState(-1) 与”运行中不响应中断”。 初始 state 为 -1 表示”线程尚未开始工作”,interruptIfStarted() 检查 getState() >= 0 才允许中断——这正是”Shutdown 时只有空闲线程会被中断”的实现:runWorker 一开始就 w.lock() 把 state 置为 1,shutdown 时 tryLock() 无法抢占,正在跑的任务得以执行完毕。中断只是协作式通知,不是强制杀死,这个设计保证了关闭的优雅。

3.4 runWorker 与 getTask:主循环与回收

重点提醒:任务抛出的异常会导致当前工作线程直接死亡,再由 processWorkerExit 补充新 worker。线程池”吞掉”了任务异常(用 execute 时)但不会崩溃,代价是线程重建。所以建议任务里用 try-catch 包住业务逻辑,并重写 afterExecute 记录日志。

getTask() 决定了线程何时被回收:

由此得到回收规则:只有”非核心线程”(wc > corePoolSize)或开启了 allowCoreThreadTimeOut 的线程,才会在 keepAliveTime 空闲后被回收;否则线程一直阻塞在 workQueue.take() 上。allowCoreThreadTimeOut(true) 的价值是让线程池在潮汐流量下真正缩容,避免低峰期白占线程与栈内存。

3.5 shutdown 与 shutdownNow:一字之差,行为天壤

对比项shutdown()shutdownNow()
状态迁移RUNNING/SHUTDOWN → SHUTDOWN→ STOP
是否接收新任务否否
队列里积压的任务继续执行完毕丢弃,以 List 返回
正在执行的任务允许跑完(不中断)尝试 interrupt()(协作式,取决于任务是否响应中断)
中断范围只中断空闲 worker(tryLock 成功的)中断所有已启动的 worker
返回值voidList 未执行任务
适用场景优雅停机:注册中心下线后等存量请求处理完紧急止损

优雅停机的标准写法是三段式:shutdown() → awaitTermination(timeout) → 未结束则 shutdownNow() 并二次等待。Spring Boot 里可给线程池 bean 设置 setWaitForTasksToCompleteOnShutdown(true) 与 setAwaitTerminationSeconds(30)。

3.6 状态迁移全景

五个状态的迁移路径只有四条,且不可逆:

  • RUNNING → SHUTDOWN:调用 shutdown()。
  • RUNNING/SHUTDOWN → STOP:调用 shutdownNow()。
  • SHUTDOWN → TIDYING:队列已空且工作线程数为 0。
  • STOP → TIDYING:工作线程数为 0。
  • TIDYING → TERMINATED:terminated() 钩子执行完毕。

tryTerminate() 是唯一的推进器:每当 worker 退出、任务被移除都会尝试调用它;发现”SHUTDOWN 且队列空且 wc==0”(或 STOP 且 wc==0)时,就把状态 CAS 推进到 TIDYING,执行 terminated(),再置为 TERMINATED,最后 termination.signalAll() 唤醒阻塞在 awaitTermination 上的线程。注意:terminated() 是空实现,正是留给我们的资源清理扩展点。

四、工作队列与拒绝策略

4.1 四种队列如何改变线程池的性格

队列不是被动容器,它直接决定线程池的行为曲线:

队列结构是否有界对线程池的影响
ArrayBlockingQueue数组 + 单把 ReentrantLock有界(构造时必填容量)队列满即触发扩容到 max,行为可预测;单锁下生产消费互斥,吞吐略低
LinkedBlockingQueue链表 + 入队出队两把锁默认无界(Integer.MAX_VALUE),可显式指定容量无界时 maximumPoolSize 失效,堆积可致 OOM;有界时吞吐优于 ArrayBlockingQueue
SynchronousQueue不存储元素,生产者必须等到消费者接手容量恒为 0任务直接移交,无缓冲 → 必须立刻开新线程,是 newCachedThreadPool 高并发吞吐的来源
PriorityBlockingQueue堆结构,按 Comparator 排序无界支持优先级调度,但无界同样有 OOM 风险;优先级反转需自行处理
DelayedWorkQueue堆 + 延迟无界ScheduledThreadPoolExecutor 专用,按到期时间出队

关于 LinkedBlockingQueue 的陷阱值得单独强调:

Executors.newFixedThreadPool(n) 内部用的正是 A 方案,这就是为什么它在流量洪峰下会静默堆积、最终 Java heap space。结论:生产环境永远显式指定队列容量。

4.2 四种内置拒绝策略

策略行为优点风险适用
AbortPolicy(默认)抛 RejectedExecutionException快速失败,问题立刻暴露调用方不捕获会导致请求链路中断需要强告警的核心链路
CallerRunsPolicy由提交任务的线程自己执行天然反压,削峰填谷占用业务线程,可能拖垮上游;线程池已关闭时静默丢弃可接受降级的批处理、后台任务
DiscardPolicy静默丢弃,无任何提示实现简单数据悄悄丢失,最难排查可丢弃的埋点/日志上报
DiscardOldestPolicy丢弃队列队头(最老)任务,再尝试提交优先处理新任务可能丢弃关键的老任务实时性优先的场景

CallerRunsPolicy 的反压效果值得理解:线程池饱和时提交者被迫自己干活,于是没时间再提交新任务,提交速率自然下降——这是一个负反馈闭环,让系统在过载时”减速”而非”崩溃”。但它有两个副作用:一是若提交者是 Tomcat 的 HTTP 工作线程或 Netty 的 EventLoop,反压会直接传导为接口超时或事件循环阻塞;二是 JDK 8 实现中若线程池已 shutdown,策略会静默丢弃任务而不抛异常。

4.3 自定义拒绝策略:日志 + 落库 + 降级

生产环境几乎不应该直接用内置策略,至少要能”留痕”:

真正稳妥的做法是不要依赖拒绝策略兜底:在入口用 Sentinel / Resilience4j 做限流,把过载挡在外面,拒绝策略只作最后一道保险。

五、Executors 的陷阱与正确姿势

5.1 四个工厂方法的真实参数

工厂方法coremax队列核心风险
newFixedThreadPool(n)nnLinkedBlockingQueue 无界任务堆积 → OutOfMemoryError: Java heap space
newSingleThreadExecutor()11LinkedBlockingQueue 无界同上;且被 FinalizableDelegatedExecutorService 包装,无法强转为 ThreadPoolExecutor 调参
newCachedThreadPool()0Integer.MAX_VALUESynchronousQueue线程数无上限 → 高并发下线程爆炸、unable to create new native thread
newScheduledThreadPool(n)nInteger.MAX_VALUEDelayedWorkQueue延时任务堆积时同样可能爆线程;且定时任务异常会静默终止后续调度
newWorkStealingPool()——基于 ForkJoinPool默认并行度为 CPU 核数;IO 密集任务会阻塞 worker

这就是《阿里巴巴 Java 开发手册》强制要求”线程池不允许使用 Executors 创建”的原因——Executors 把最危险的两个参数(无界队列、无限最大线程数)藏在便利性的外衣下,让资源耗尽的风险从编译期推迟到线上爆炸时。

5.2 手动创建的标准模板

四条铁律:必须命名线程(pool-3-thread-8 在 jstack 里毫无信息量);必须有界队列(宁可拒绝,不可 OOM);必须自定义拒绝策略(至少留痕);必须按业务隔离线程池(慢 SQL 拖垮共用池,所有业务陪葬)。

5.3 动态调参与监控

线程池参数不必写死,可在运行时调整,配合配置中心(Nacos / Apollo)实现”高峰扩容、低峰缩容”:

更精细的监控可继承 ThreadPoolExecutor 重写三个钩子:beforeExecute(记录开始时间到 ThreadLocal)、afterExecute(计算耗时与异常)、terminated(关闭时打印汇总)。把 P50/P99 耗时、拒绝次数、队列水位打点到 Micrometer,并配置”队列使用率 > 80% 持续 1 分钟”告警,就能在雪崩前发现问题。

六、CompletableFuture:把异步从”取结果”升级为”编排流程”

6.1 Future 的三个痛点

FutureTask 解决了”异步执行 + 稍后取结果”,但用它编排多个异步任务极其痛苦(四个痛点见代码注释):

CompletableFuture 正是为把”异步”从取值升级为编排而来:它既能被手动完成(complete),又能挂接一串回调,还能对多个未来做组合与异常处理。

6.2 核心模型与创建方式

内部只有两个字段撑起全部能力:volatile Object result(结果或包装的异常)与 volatile Completion stack(一个 Treiber 栈,保存所有依赖它的回调)。result 写入后弹出栈中所有回调并逐个触发——典型的”完成即通知”。

分类方法说明
创建supplyAsync(Supplier)有返回值,默认用 ForkJoinPool.commonPool
supplyAsync(sup, executor)推荐:显式指定业务线程池
runAsync(Runnable[, executor])无返回值
completedFuture(value)包装一个已完成的结果
串行thenApply(Function)同步转换结果 T → U,返回新 CF
thenApplyAsync(fn[, ex])异步转换,切换到指定线程池执行
thenAccept(Consumer)消费结果,返回 CF
thenRun(Runnable)不关心结果,只在其后执行
thenCompose(Function)扁平化:避免 CF>,等价于”异步版 thenApply”
组合thenCombine(other, BiFunction)两个都完成后合并结果
applyToEither(other, Function)谁先完成用谁的结果
allOf(cfs...)全部完成(返回 CF,需自己取值)
anyOf(cfs...)任一完成
异常exceptionally(Function)只在异常时给替代值,正常时透传
handle(BiFunction)结果/异常都能拿到,可改变返回值
whenComplete(BiConsumer)类似 finally,不改变结果,异常继续传播
超时orTimeout(t, unit)JDK 9+,超时抛 CompletionException(TimeoutException)
completeOnTimeout(v, t, unit)JDK 9+,超时填充默认值

为什么必须指定线程池? supplyAsync 不传 executor 时用 ForkJoinPool.commonPool,其并行度默认 CPU 核数 - 1,且 JVM 全局共享。后果有二:一是 IO 密集任务长时间占用 commonPool 线程,导致 parallelStream 与其它 CompletableFuture 全部饿死;二是 commonPool 线程是守护线程,主线程退出后异步任务被直接放弃。

6.3 串行:thenApply / thenCompose / thenAccept 的差异

判断口诀:转换逻辑是同步计算用 thenApply,转换逻辑本身要异步(返回 CompletableFuture)用 thenCompose。

6.4 异常处理:exceptionally / handle / whenComplete 怎么选

选择建议:需要兜底默认值用 exceptionally;需要把成功/失败统一映射成同一种返回结构(如 Result)用 handle;需要记录日志、关闭资源但不想影响链路用 whenComplete。另外 join() 抛非受检的 CompletionException,在 lambda 里比 get() 顺手。

6.5 实战:并行调用三个下游接口并聚合

最常见场景:详情页要同时拉商品、库存、营销三个接口,串行 400ms,并行只要 200ms。

这段代码有四个可借鉴的点:一是线程池隔离,库存慢不会耗尽商品的线程;二是 per-call 超时,用 orTimeout 给每个下游单独设阈值;三是分层降级,单个服务降级到字符串、整体再兜一层 completeOnTimeout;四是 getNow,能在任务未完成时立刻返回默认值,是构造”部分结果”的关键。

七、ForkJoinPool 与并行流

7.1 分治与工作窃取

ForkJoinPool 为分治型(divide and conquer)计算任务而设计,核心创新是工作窃取(work-stealing):每个工作线程维护一个双端队列(WorkQueue)。

  • 自己产生的子任务通过 fork() push 到队头,自己执行时也从队头 pop(LIFO)。
  • 自己的队列空了,就随机挑一个其它线程的队列,从队尾偷(FIFO)。

“自己 LIFO、偷别人 FIFO”有两层考量:LIFO 取自己的任务能拿到最新(粒度更小、数据还在缓存里)的任务,局部性更好;从队尾窃取拿到的是最老的任务,而最老的任务往往”最大”,一次窃取就获得足够工作,减少窃取次数与竞争。同时窃取在队尾、消费在队头,多数情况下操作队列的不同端,冲突概率极低。

注意 left.fork(); right.compute(); left.join(); 这个惯用写法——一半异步、一半同步,比”两个都 fork 再 join”少一次任务调度,是官方推荐模式。

再看归并排序,更能体现分治思想:

7.2 parallelStream 的底层与”不该用”的四种情况

stream().parallel() 底层就是 ForkJoinPool.commonPool(),并行度默认 availableProcessors() - 1(主线程也算一份),可通过 -Djava.util.concurrent.ForkJoinPool.common.parallelism=N 调整。拆分由 Spliterator 完成:数组、ArrayList 拆分均匀效率高;LinkedList、BufferedReader.lines() 拆分极差,几乎无收益。

不该用并行流的四种情况:① 数据量小(万级以下,拆分合并开销大于收益);② IO 密集(线程阻塞在等待上,反而占用全局 commonPool);③ 有状态 lambda 或依赖顺序(forEachOrdered、limit 会严重削弱并行度);④ 数据源不易拆分或装箱成本高(Stream 拆装箱开销可观,优先 IntStream)。此外不要在并行流里做同步阻塞,也不要修改共享变量——用 collect/reduce 表达归约意图。

八、JDK 21 虚拟线程与最佳实践清单

8.1 平台线程的成本与虚拟线程模型

前面所有内容都建立在”线程很贵”这个前提上。JDK 21(JEP 444)带来的虚拟线程(Virtual Thread)正是为打破这个前提:它是由 JVM 管理、不直接对应 OS 线程的轻量级线程,创建成本在微秒级、内存占用从 MB 级降到 KB 级,因此可创建百万级而无压力。

底层模型有三个关键角色:

  • Continuation:可暂停/恢复的计算单元。虚拟线程的调用栈不再固定在 OS 栈上,而以栈帧形式保存在堆内存里,阻塞时可 yield 让出载体线程,之后再 run 恢复。
  • 载体线程(Carrier Thread):真正执行虚拟线程字节码的平台线程。默认调度器是一个 ForkJoinPool,并行度等于 CPU 核数。
  • 调度器(Scheduler):负责把虚拟线程挂载(mount)到载体线程上。

关键机制:虚拟线程执行阻塞操作(LockSupport.park、Socket 读写、Thread.sleep)时,JVM 会把它改写成一次 Continuation.yield——先卸载(unmount)虚拟线程、把栈帧搬回堆,释放载体线程去执行其它虚拟线程;条件满足后再挂载(mount)到空闲载体线程继续执行。对 Java 代码而言 Thread.sleep() 仍是阻塞的,但底层 OS 线程没有闲着——这就是”用同步的写法获得异步的性能”。

8.2 适用场景与四条禁忌

适用场景:高并发 IO 密集的服务端程序——典型的”每请求一线程”Web 服务。过去 200 并发就要 200 个平台线程,现在 10 万并发也只是 10 万个虚拟线程,代码依然是同步写法。Spring Boot 3.2+ 只需 spring.threads.virtual.enabled=true 即可让 Tomcat 使用虚拟线程。

禁忌一:synchronized 造成的钉扎(pinning)。 JDK 21~23 中,虚拟线程在 synchronized 块内阻塞时无法卸载——监视器持有者记录在 OS 线程层面,JVM 无法安全地把栈搬走,结果载体线程被钉住,并发度退化。可用 -Djdk.tracePinnedThreads=short 打印钉扎堆栈定位;解法是换成 ReentrantLock(JUC 的锁已适配虚拟线程)。JEP 491(JDK 24)重新实现了 synchronized,使其不再钉扎。

禁忌二:ThreadLocal 滥用。 每个虚拟线程都有自己的 ThreadLocal 副本,百万虚拟线程 × 几个 ThreadLocal Map 会造成可观的内存膨胀。JDK 21 引入的 ScopedValue 正为虚拟线程设计的”结构化、不可变、有作用域”替代方案。应避免用 ThreadLocal 传递大对象,链路追踪等框架层尤其要评估开销。

禁忌三:CPU 密集任务几乎无收益。 虚拟线程不增加算力,纯计算时数量再多也只能占有限的核,反而增加调度与内存开销。CPU 密集仍应走 ForkJoinPool / parallelStream / 固定大小线程池。

禁忌四:不要池化虚拟线程。 虚拟线程创建极其廉价,”复用”反而带来状态泄漏与调试困难。直接用 newVirtualThreadPerTaskExecutor(),每任务一条新虚拟线程即可。

与响应式编程(Reactor / RxJava)的取舍:虚拟线程让”同步阻塞的写法”重新可行,可读性与可调试性远胜层层回调;但响应式在背压控制、流式窗口聚合、事件编排上仍有不可替代的优势。务实选择:新项目优先虚拟线程 + 结构化并发;已有成熟响应式栈不必迁移;网关、流式计算继续用响应式。

8.3 最佳实践清单

  1. 禁止使用 Executors 创建线程池,一律 new ThreadPoolExecutor(...) 显式声明七大参数。
  2. 队列必须有界,容量按”峰值 QPS × 可容忍等待时间”估算,宁可拒绝不可 OOM。
  3. 线程必须命名并配置 UncaughtExceptionHandler,否则线上 jstack 无法定位。
  4. 按业务隔离线程池(舱壁模式),慢服务不能共用核心池。
  5. 拒绝策略必须自定义:记录指标 + 落库待补偿 + 告警,绝不静默丢弃。
  6. 任务内部必须 try-catch,并重写 afterExecute 兜住异常(execute 提交的异常会被吞掉)。
  7. 参数以压测为准,公式只作起点;把 core/max/queue 接入配置中心支持动态调整。
  8. 监控四要素:活跃线程数、队列水位、拒绝次数、任务 P99 耗时,配水位告警。
  9. 优雅停机三段式:shutdown() → awaitTermination → 超时再 shutdownNow(),并钩子等待存量任务。
  10. IO 密集且高并发(JDK 21+)优先虚拟线程,synchronized 改 ReentrantLock,慎用 ThreadLocal,不要池化。

8.4 高频面试题速答

  1. 线程池的执行流程? 核心线程 → 队列 → 最大线程 → 拒绝策略;入队后还要 recheck 状态与线程数。
  2. 为什么先入队而不是先扩容线程? 创建线程昂贵,队列能缓冲说明核心线程足以消化,避免无谓扩容的切换与内存开销(Doug Lea 源码注释明示)。
  3. corePoolSize 与 maximumPoolSize 区别? 前者是常驻规模,后者是队列满后的上限;无界队列时后者失效。
  4. keepAliveTime 对核心线程生效吗? 默认不生效,需 allowCoreThreadTimeOut(true)。
  5. ctl 为什么用一个 int? 高 3 位状态 + 低 29 位线程数,使状态与数量的更新一次 CAS 完成;29 位上限约 5.37 亿。
  6. Worker 为什么继承 AQS? 实现不可重入独占锁,tryLock 成功即代表空闲,供 shutdown 精确中断空闲线程。
  7. shutdown 与 shutdownNow 区别? 前者平和:不接新任务、跑完队列、不中断运行中任务;后者激进:清空队列并返回、中断所有线程。
  8. 为什么禁止 Executors? 无界队列 OOM、Integer.MAX_VALUE 最大线程数导致线程爆炸、参数不可控、默认线程名无意义。
  9. submit 与 execute 区别? submit 返回 Future、异常被封装进 Future(get 时才抛);execute 无返回值、异常直接抛出并导致该线程重建。
  10. CPU 密集与 IO 密集线程数怎么设? N+1 与 2N(或 N × (1 + W/C)),最终以压测为准。
  11. CallerRunsPolicy 有什么用? 让提交者自己执行,形成反压降低提交速率;风险是占用上游线程、池关闭时静默丢弃。
  12. thenApply 与 thenCompose 区别? 前者同步转换为 CF;后者接收返回 CF 的函数并扁平化为 CF,避免嵌套。
  13. exceptionally/handle/whenComplete 差异? 分别对应:异常兜底、结果+异常统一转换、不改变结果的回调(类似 finally)。
  14. 为什么 supplyAsync 要指定线程池? 默认 ForkJoinPool.commonPool 全局共享、并行度 CPU-1、守护线程,IO 任务会饿死其它调用方。
  15. 工作窃取为何自己 LIFO、窃取 FIFO? LIFO 提升缓存局部性,FIFO 从队尾拿到更大的任务块,减少窃取次数与竞争。
  16. 什么时候不该用并行流? 数据量小、IO 密集、有状态操作、数据源难拆分。
  17. 虚拟线程适合什么场景? 高并发 IO 密集;不适合 CPU 密集;注意 synchronized 钉扎、ThreadLocal 膨胀、不要池化。
  18. 虚拟线程与响应式如何取舍? 虚拟线程胜在可读可调试,响应式胜在背压与流式编排。

到这里”如何用好线程”这条主线就完整了:从线程的成本认识池化的价值,从七大参数与决策链理解行为边界,从 ctl 位运算与 AQS Worker 看懂实现的精妙,从 CompletableFuture 把异步升级为编排,从 ForkJoin 认识另一种并行范式,最后用 虚拟线程看清这套体系的走向。下一篇将进入 JVM 层面,看看这些线程共享的内存是如何被划分、分配与回收的。

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论