第 31 章 并发工具、线程池与 AQS
线程池决定"任务怎么被调度",AQS 决定"抢不到锁的线程去哪排队";前者是容量与背压,后者是并发包的共同骨架。
学习目标
读完本章,你应当能够:
- 说出
ThreadPoolExecutor七个构造参数的作用,推演任务提交的四步流程与"为什么先入队而不是先扩容",并判断三种工作队列下线程池的扩容行为与Executors四个工厂方法的故障模式; - 用 CPU 密集、IO 密集、Little 定律三种口径算出线程数,说出压测找拐点的观测指标,并说明
ReentrantLock公平与非公平的实现差异; - 画出 AQS 的
state+ 双向同步队列 + 单向条件队列结构图,说明共享模式为什么必须有PROPAGATE,并辨析CountDownLatch/CyclicBarrier/Semaphore/Phaser的计数方向与阻塞对象; - 说清
CompletableFuture默认线程池的隐患与LongAdder相对AtomicLong的退化条件。
前置知识检查
| 你需要知道 | 在哪一节补齐 |
|---|---|
CAS、Unsafe 原子操作与 Mark Word 锁升级 | 见第 30 章 30.4、30.5 节 |
happens-before、内存屏障与 volatile 语义 | 见第 30 章 30.2、30.3 节;AQS 的 state 为何是 volatile 依赖它 |
ThreadLocal 的 ThreadLocalMap 结构与泄漏根因 | 见第 30 章 30.7 节;本章只讲它与线程复用的组合故障 |
线程与进程的本质区别、LockSupport | 见第 4 章 4.3 节;本章只讲它在 AQS 里承担的内核阻塞 |
| 阻塞队列、链表、位运算与幂等语义 | 数据结构课程内容;本章直接进实现 |
31.1 ThreadPoolExecutor 的七个参数与四步提交流程
31.1.1 七个参数各自管什么
| |
七个参数里只有 maximumPoolSize 与 keepAliveTime 作用在非核心线程上:核心线程默认不超时、永久存活;开启 allowCoreThreadTimeOut(true) 后核心线程也受 keepAliveTime 约束,等于把线程数下限交还给池。prestartAllCoreThreads() 一次性创建全部核心线程(prestartCoreThread() 只建一个),它把"第一次请求额外付一次线程创建成本(约 10 μs 量级,且栈内存要按页触底)“挪到启动阶段,也顺带在启动时暴露 ulimit 不足这类问题。
警告
Executors.defaultThreadFactory() 造出的线程全是非守护线程(non-daemon),名字形如 pool-N-thread-M。非守护线程会阻止 JVM 退出,未命名会让 JStack 里几十个线程无法归属到业务。生产上必须自定义 ThreadFactory:命名带业务前缀、设为守护线程、并挂 uncaughtExceptionHandler 兜住未捕获异常。
31.1.2 四步流程与"为什么队列优先”
| |
② 成功后的"再校验"来自源码的 recheck:入队后若发现池已不是 RUNNING,就把刚入队的任务移出并走拒绝;若线程数为 0 则补一个线程,否则核心线程刚超时退出时入队的任务会永远没人取。源码写的是 else if (isRunning(c) && workQueue.offer(command)),只有入队失败才走到第 ③ 步。于是问题变成:为什么队列满之前不扩到最大线程数?其一,线程昂贵而队列廉价:一个平台线程的栈默认 1 MB(-Xss,32 位 JDK 上 512 KB),10000 个线程光栈就是 10 GB 虚拟地址空间,切换一次约 1~5 μs,1000 个线程在 8 核上轮转时大量 CPU 时间花在换栈上;其二,扩容解决"持续阻塞",队列解决"瞬时突发",短时尖峰交给队列慢慢消化比瞬间拉起几千线程再全部回收更划算;其三,线程池无法识别任务类型,只能取安全默认值——execute 只拿到一个 Runnable,不知道它会不会阻塞 I/O,若默认"先扩容",混合型线程池会被阻塞任务把线程数推到 maximumPoolSize,CPU 被大量睡眠线程的换入换出拖垮。先入队 = 先假设任务是 CPU 密集的,这个假设最安全。
所以队列与 maximumPoolSize 的分工是:队列抗突发,最大线程数抗持续阻塞。要改变这个顺序,只能不排队——用 SynchronousQueue 让每次提交都必须立刻有线程接手。
警告
第 ③ 步每次提交只新建一个线程:队列满后是"每来一个任务加一个线程",要连续多次提交才能爬到 maximumPoolSize;而且此时新任务优先给新线程,队列里排在前面的老任务反而后执行。
31.2 三种工作队列对行为的决定性影响
线程池的行为不由"线程数"决定,而由队列的容量语义决定。
| 队列 | 容量 | 会满? | 后果 |
|---|---|---|---|
ArrayBlockingQueue | 构造时固定(如 500) | 会满 | 能触发扩容到 maximumPoolSize,再满才拒绝 |
LinkedBlockingQueue | 默认 Integer.MAX_VALUE | 实际不会满 | maximumPoolSize 形同虚设,maximumPoolSize − corePoolSize 个线程永不创建,任务无限堆积直到堆 OOM |
SynchronousQueue | 容量 0,不存储元素 | 永远"满" | 每次 offer 失败立即走第 ③ 步扩容,线程随任务增长到 maximumPoolSize 才拒绝 |
DelayedWorkQueue(ScheduledThreadPoolExecutor 专用) | 无界二叉堆 | 不会满 | maximumPoolSize 被固定为 Integer.MAX_VALUE,扩容等于无限 |
LinkedBlockingQueue 的"等价于无界"要说透:它的 maximumPoolSize 不是被忽略,而是永远达不到——offer 在无界队列上恒返回 true,第 ③ 步的代码路径永不执行。所以 newFixedThreadPool(4) 的真实语义是"最多 4 个线程 + 一个无上限的队列",而不是"最多 4 个任务在跑"。 |
31.3 四种拒绝策略与为什么必须自定义
| 策略 | 行为 | 提交方感知 | 适用场景 | 代价 |
|---|---|---|---|---|
AbortPolicy(默认) | 抛 RejectedExecutionException | 立即感知 | 失败必须让上游知道、可重试的任务 | 未捕获异常会打断调用链 |
CallerRunsPolicy | 调用 execute 的线程自己执行该任务 | 隐式感知(线程被占) | 能容忍提交方变慢、但不允许丢任务 | 占住调用方线程(Tomcat 工作线程被占 → 接口整体变慢),形成背压 |
DiscardPolicy | 静默丢弃 | 完全无感知 | 几乎无正当场景 | 数据静默丢失,最危险 |
DiscardOldestPolicy | 丢弃队列中最老(队首)的任务后重试当前任务 | 无感知(丢的是旧任务) | 只关心最新值的场景 | 旧任务被无声吞掉;若队列是优先队列,丢的反而是优先级最高的 |
CallerRunsPolicy 执行前会检查 !e.isShutdown():这不是"关闭时还要执行任务",而是关闭阶段不再让调用方线程承担工作,否则调用方会被拖住。生产环境为什么要自定义?因为四种都无法同时满足三件事:可观测(谁被拒、拒绝率多少)、可感知(提交方要知道,否则上层以为成功)、可降级(拒绝时走兜底而不是抛异常打穿链路)。 |
| |
31.4 Executors 四个工厂方法的隐患
| 工厂方法 | 内部实际参数 | 隐患 | 后果 |
|---|---|---|---|
newFixedThreadPool(n) | 核心 = 最大 = n,队列 LinkedBlockingQueue(无界) | 队列无界 | 峰值任务无限堆积,堆被 Runnable 与其闭包吃光 → OutOfMemoryError: Java heap space;线程数恒为 n |
newSingleThreadExecutor() | 核心 = 最大 = 1,队列同上 | 队列无界 + 单线程 | 同源的堆积 OOM;一个慢任务阻塞全部后续任务 |
newCachedThreadPool() | 核心 0,最大 Integer.MAX_VALUE,队列 SynchronousQueue | 线程数上限是 2³¹ − 1 | 提交快于执行时无限制建线程 → OutOfMemoryError: unable to create native thread |
newScheduledThreadPool(n) | 核心 n,最大 Integer.MAX_VALUE,队列 DelayedWorkQueue | 队列无界 + 线程数无界 | 任务堆积 OOM;线程数不随负载扩展 |
小心
答"Executors 是官方工具类所以生产可用"会被直接判定为没读过生产事故。四者的根因只有两条:要么队列无界(堆积 OOM),要么线程数无界(线程爆炸)。背下这句,任何追问都能回到它上面。
手工构造的建议(8 核、订单接口这类 IO 密集任务):
| |
31.5 线程数怎么确定:公式、推导与压测拐点
31.5.1 CPU 密集:核数 + 1
记核数为 N。CPU 密集任务的时间几乎全在计算,N 个线程已能让 N 个核满载,第 N + 1 个的收益来自"当某个线程因缺页、safepoint 或 JIT 编译暂停时,还有线程能占住空闲的核"。同理 ForkJoinPool.commonPool() 的并行度取 N − 1(见 31.11 节),是为了给调用线程留一个核。线程数超过 N 后,多出的线程只增加上下文切换(约 1~5 μs/次)与缓存失效,吞吐不再上升。
31.5.2 IO 密集:核数 × (1 + 等待时间/计算时间)
| |
31.5.3 混合型:用 Little 定律反推
| |
线程数少于 100 时排队会把 RT 拉长,而 RT 变长又让 L 变大,形成"越慢越多、越多越慢"的正反馈。这也是 Semaphore 能限流的原因:new Semaphore(50) 限住并发 50,单请求 100 ms 时最大吞吐 50/0.1 = 500 QPS,与 L = λ × W 完全一致。
31.5.4 压测找拐点
公式只给起点,真实线程数必须压测:预热(JIT、连接池、page cache、堆稳定)后从 N_threads = N 起步,每轮只调一个参数,观察 QPS、P99/P999、getActiveCount()、getQueue().size()、getCompletedTaskCount()、CPU 使用率,继续加线程直到 QPS 不再上升而 P99 陡增,该点即拐点。拐点的三个特征同时出现:getQueue().size() 不再持续为 0、getActiveCount() 贴住线程数上限、P99 上翘而 QPS 持平;若加线程 QPS 完全不涨且 CPU 也没满,说明瓶颈在下游(数据库连接池、下游 RPC 配额),加线程只会加剧排队。
重要
“线程数越多吞吐越高"错在超过临界点后额外线程的成本高于收益:每次切换约 1~5 μs,切换后 CPU 缓存与 TLB 局部性被破坏,CPU 把时间花在换栈与重新填充缓存上,有效计算时间反而减少;线程数继续上涨还会耗尽栈内存(每线程 1 MB 量级),触发 swap 或直接 unable to create native thread。
31.6 可观测、钩子与动态调整
31.6.1 三个核心指标与两个钩子
| 方法 | 语义 | 怎么用 |
|---|---|---|
getActiveCount() | 近似值,正在执行任务的线程数 | 与 getPoolSize() 一起看:长期贴住上限 = 线程不足或下游慢 |
getQueue().size() | 队列中待执行任务数 | 常态应接近 0;持续增长说明消费不过来,比 CPU 更早报警 |
getCompletedTaskCount() | 近似值,累计完成数 | 取相邻区间差值,得到实际吞吐与单任务平均耗时 |
beforeExecute / afterExecute | 任务执行前后回调,同一工作线程内调用 | 提交时打时间戳、beforeExecute 里算差值,才能把"排队耗时"从"执行耗时"里剥离 |
terminated() | 池完全终止后回调 | 关闭时收尾、释放外部资源、上报 |
getActiveCount() 与 getCompletedTaskCount() 遍历 workers 时不加锁(避免监控路径影响吞吐),只能当趋势指标,不能当准确账本,用它做资源计量或有状态判断都是错的。 |
| |
31.6.2 动态调整与 execute/submit/shutdown
setMaximumPoolSize 调小时,超出新上限的线程会在空闲后被回收;setCorePoolSize 调大时源码会主动创建新线程补齐到新核心数,调小时只影响后续的空闲回收判定,不会立刻回收已存在的核心线程——这就是"缩容不回收核心线程”:核心线程默认不超时,只在下次取任务时看到"线程数 > 核心数"才按 keepAliveTime 退出。要打破它得开 allowCoreThreadTimeOut(true),代价是低峰期后需重新建线程;调参也不会改队列容量(ArrayBlockingQueue 的 capacity 是 final)。
submit 内部把任务包成 FutureTask 再调 execute,因此带来两点:有返回值、异常被吞。FutureTask.run() 捕获取消异常与一切 Throwable 存进 outcome,所以任务抛出的异常不会传回工作线程、工作线程不会因异常退出,错误被延迟到 future.get() 时以 ExecutionException 抛出。
| |
警告
submit 吞异常是"任务明明失败、监控上却全是成功"的最常见来源。更隐蔽的一点:afterExecute(r, t) 的 t 对 submit 提交的任务恒为 null,因为异常在 FutureTask 内部就被截获了;只有 execute 提交的任务才会把异常传进 t。
| 维度 | shutdown() | shutdownNow() |
|---|---|---|
| 池状态与队列 | RUNNING → SHUTDOWN,继续执行完队列中全部任务 | RUNNING → STOP,返回未执行任务列表并清空队列 |
| 正在执行的任务 | 不打断 | 对每个工作线程 interrupt() |
| 何时真正终止 | 队列空且线程全退出 | 线程响应中断后退出;不响应中断的任务仍会跑下去 |
两者都只是"请求关闭",等待结束要用 awaitTermination。shutdownNow 的返回值是 List<Runnable>,队列里被丢弃的任务只能靠它拿回来,不取就等于静默丢数据。 |
31.7 线程池的四种典型故障模式
| 故障 | 触发条件 | 现象 | 处置 |
|---|---|---|---|
| 线程被异常替换 | 用 execute 提交且任务抛出未捕获异常 | runWorker 因异常退出,finally 里 processWorkerExit 移除该线程并按需补建;线程 id 不断变大 | 任务体内兜住异常;用 submit + Future.get;ThreadFactory 挂 uncaughtExceptionHandler |
ThreadLocal 数据串扰 | 线程被复用,上一个任务的 ThreadLocal 没清 | 用户 A 的上下文出现在用户 B 的日志/鉴权里;ThreadLocalMap 的 Entry 强引用 value,长生命周期线程上永不回收 | try/finally 里 remove();框架层成对清理(见第 30 章 30.7 节) |
| 死锁:任务等自己提交的子任务 | 单线程池里任务 future.get() 自己 submit 的子任务;或两层池互等 | 任务永久挂起,队列积压,进程不报错也不前进 | 禁止在任务内同步等待自己提交的任务;子任务用独立线程池;改异步组合 |
Future 超时后任务仍在跑 | get(timeout) 抛 TimeoutException 后任务未被真正停止 | 调用方已放弃,任务还在写库/发消息,产生重复效果 | cancel(true) 只是发中断;任务必须检查 Thread.interrupted();取消不可靠,副作用必须幂等 |
四种故障的共同背景是:线程池把"创建线程"变成了"复用线程",而复用的东西(线程、ThreadLocal、栈上的残留状态)不会自动清理。追问"你怎么知道线程被替换过":看 threadFactory 生成的线程名序号是否持续增长、日志里 UncaughtExceptionHandler 的记录。 |
31.8 AQS 的完整实现
AbstractQueuedSynchronizer(AQS)是 ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock、ThreadPoolExecutor.Worker 的共同骨架。它自己不做业务判断,只提供两件事:一个可 CAS 的状态量,与一个抢不到就排队的等待队列。
31.8.1 state 的设计
| |
为什么用 int:32 位 CAS 在所有平台上都有硬件指令(x86 的 lock cmpxchg、ARM 的 ldrex/strex),而 64 位 CAS 在部分 32 位平台要退化或要求额外对齐;重入计数与许可数的量级远达不到 int 上限(2³¹ − 1 ≈ 21 亿)。state 是 volatile + compareAndSetState,于是"判断 + 修改"是原子的。条件队列的等待线程数用另一个普通 int 字段 waiters 记录,不放进 state——条件队列与同步状态是两个正交概念。
31.8.2 CLH 队列的变体
AQS 队列是 Craig-Landin-Tarjan 自旋锁(CLH lock)的变体:原版是单向链表 + 前驱节点上自旋,AQS 改成双向链表 + 显式 park。
| |
为什么必须改成双向:原版靠前驱自旋看状态,而 AQS 用 LockSupport.park 真正挂起线程,唤醒时必须从队尾往前找第一个有效节点(unparkSuccessor 从 tail 反向遍历、跳过 CANCELLED)。正向 next 指针在并发入队/取消时可能为 null 或指向已取消节点,只有 prev 链是可靠的。
31.8.3 独占模式:acquire 的三步
| |
shouldParkAfterFailedAcquire 的状态机是:前驱 ws == SIGNAL 才允许 park;前驱 ws > 0(CANCELLED)就向前跳过连续的取消节点;否则用 compareAndSetWaitStatus(pred, ws, SIGNAL) 把前驱置为 SIGNAL,置失败就再循环一次抢锁——这既避免 CAS 冲突下的忙等,也保证不会在没有后继信号的情况下 park。
31.8.4 共享模式的 PROPAGATE:为什么必须有它
共享模式与独占模式最大的差别是:一个线程释放后,多个后继线程应当被依次唤醒(Semaphore.release(3) 要让 3 个等待者通过;CountDownLatch 归零要让所有等待者通过)。实现方式是 doReleaseShared 里无条件循环传播:
| |
PROPAGATE 解决的是一个丢失唤醒(lost wakeup)竞态:初始队列为 head(ws=0) → n1(SIGNAL) → n2(SIGNAL) 且共享资源此刻已可获取。释放方 R 读到 head.ws == 0,若不置 PROPAGATE 就直接判"是否 h == head";与此同时被唤醒者 T 重新 tryAcquireShared 成功并 setHead,自己也进入本方法,读到的是"自己这个 head,ws == 0";随后 R 发现 head 已被 T 改变,判定"传播已到队尾"而 break 退出。此时若 T 在 ws == 0 时同样跳过、不 unpark,n2 就永远停在 SIGNAL 上无人唤醒——这就是丢失唤醒。根因是“还要不要继续唤醒后继"这个信息没有在 waitStatus 里留下痕迹:ws == 0 既可能是"没有后继要唤醒”,也可能是"我刚被交接了唤醒义务"。PROPAGATE(-3,小于 0)把后一种情况显式编码进状态,于是后续任何读到它的线程都会走 unparkSuccessor 继续传播,代价只是一个常量与一次 CAS。这个问题表现为 CountDownLatch.await() 偶发不返回、Semaphore.acquire() 偶发挂死,只在特定线程交织下出现、极难复现。
31.8.5 Condition 的条件队列与 await/signal 迁移
ConditionObject 是 AQS 的内部类,维护一条独立的单向链表:firstWaiter → nextWaiter → lastWaiter,节点复用 Node 但 waitStatus = CONDITION。
| |
await() 的顺序必须是"先释放锁、再入队、后 park":① addConditionWaiter() 把自己以 CONDITION 状态挂到条件队列;② fullyRelease(node) 释放全部锁(含重入计数)——不释放,signal 方根本进不来;③ while (!isOnSyncQueue(node)) { park(this); checkInterruptWhileWaiting(node); },因为被唤醒后可能仍在条件队列上(伪唤醒),必须循环确认;④ acquireQueued(node, savedState) 重新竞争锁,拿回后把 state 恢复到 savedState,所以重入次数不会因 await 丢失。signal() 走 doSignal → transferForSignal(node):用 CAS 把节点 waitStatus 从 CONDITION 改为 0(改失败说明该节点已被取消,跳过),再 enq(node) 接进同步队列尾部;若接回后前驱已被取消或 SIGNAL 置位失败,则立即 unpark 该线程。两个高频追问的答案就在这里:为什么 await 必须写在 while 循环里——从 signal 到真正抢到锁之间条件可能再次不成立(还有伪唤醒),单次 if 是标准 bug;为什么 signal 后调用方还要 unlock——被唤醒者要回到 acquireQueued 重新抢锁,不释放它只会从条件队列迁到同步队列后继续阻塞。
31.8.6 模板方法模式在 AQS 中的体现
| 待实现方法 | 语义 | 典型实现要点 |
|---|---|---|
tryAcquire(int) | 独占获取 | 非公平版直接 compareAndSetState(0, 1);公平版先查 hasQueuedPredecessors |
tryRelease(int) | 独占释放 | 减 state,减到 0 才清 exclusiveOwnerThread 并返回 true(true 才触发唤醒) |
tryAcquireShared(int) | 共享获取 | 返回值语义特殊:负数=失败,0=成功但后续不传播,正数=成功且可继续唤醒后继 |
tryReleaseShared(int) | 共享释放 | 用 CAS 循环改 state;只有"释放后允许更多获取"时才返回 true |
isHeldExclusively() | 是否被当前线程独占 | exclusiveOwnerThread == Thread.currentThread(),Condition 用它做状态校验 |
hasQueuedPredecessors() | 队列中是否有前辈 | AQS 提供;它是公平性判定的唯一入口 |
重要
tryAcquireShared 的返回值必须能区分三种情况,因为 doAcquireShared 要用它决定"要不要继续唤醒后继"。CountDownLatch.Sync.tryAcquireShared 返回 (state == 0) ? 1 : -1,正是"归零了才让所有等待者一起通过"的落点。
31.9 ReentrantLock:公平、非公平与 synchronized 对比
非公平锁的插队只可能发生在 state == 0 的瞬间:
| |
锁被占用时新来的线程照样进队列,所谓"不公平"不是"永远抢不到",而是在锁交接的窗口里新线程可能抢在被唤醒的队首线程之前。而队首此刻通常还停在 park 上、尚未执行,所以它这次失败并不算浪费一次执行——这正是非公平版本吞吐更高的原因。
| 维度 | 公平锁 | 非公平锁(默认) |
|---|---|---|
| 判定入口 | hasQueuedPredecessors() 必须先查队列 | 直接 CAS,不查队列 |
| 唤醒成本 | 队首 unpark 后必须成功,无额外竞争 | 队首可能被抢占,需再 park 一轮(每次约 1~5 μs) |
| 吞吐与延迟 | 吞吐较低;接近 FIFO,P99 更可控,无饥饿 | 吞吐较高;个别线程可能长时间得不到锁(理论上可饥饿) |
其余要点:tryLock() 无参版不遵循公平策略——看到 state == 0 就直接 CAS,所以公平锁上调它也可能插队成功(这是定义行为,不是 bug);带超时的 tryLock(long, TimeUnit) 遵循公平性,并在超时/中断时把节点标记为 CANCELLED 并清理。newCondition() 可创建多个条件变量,是它相对 synchronized 的结构性优势。 | ||
| 对比项 | synchronized | ReentrantLock |
| — | — | — |
| 实现层次 | JVM 内建(monitorenter/monitorexit),靠 Mark Word 与 Monitor | Java 层 AQS + LockSupport(见第 30 章 30.5 节) |
| 可中断 / 可超时 / 公平性 | 都不支持 | lockInterruptibly()、tryLock(timeout, unit)、可构造公平锁 |
| 条件变量 | 一个(wait/notify,绑定锁对象) | 多个 Condition |
| 释放方式 | 不可手动释放,随同步块结束 | 必须在 finally 里 unlock(),可跨方法持有 |
31.10 读写锁、StampedLock 与同步工具
ReentrantReadWriteLock 把读写状态压缩进一个 state:高 16 位是读锁持有次数,低 16 位是写锁重入次数。由此推出三条硬结论:读锁 → 写锁(升级)会死锁,因为写锁要求所有读锁释放,而升级者自己持有的读锁不会自动释放(“升级"在语义上无法安全实现,两个读者同时升级就互相等待),正确做法是"释放读锁 → 重新获取写锁 → 重新校验数据”;写锁 → 读锁(降级)允许,同一线程持写锁时可再拿读锁,先释放写锁即完成平滑降级;写饥饿——默认非公平模式下只要读锁能连续获取,写锁就可能一直排队,可用 new ReentrantReadWriteLock(true) 打开公平模式。
StampedLock 用乐观读(optimistic read)换吞吐:tryOptimisticRead() 只返回一个版本戳、不写 state、不排队(成本接近读一个 volatile),随后直接读数据,再用 validate(stamp) 校验——期间有写线程获取过锁就会使版本戳失效,此时退化为 readLock() 重读一次。它吞吐更高的根本原因是读路径完全不写共享状态:读者之间没有 state 的 CAS 竞争,也不产生缓存行在核间的来回弹跳。代价同样明确:不可重入(重复获取会自己阻塞自己,没有 exclusiveOwnerThread 与重入计数)、未实现 Lock 接口、不支持 Condition、不响应中断。所以它适合"只读热点 + 极短写",且 readLock/unlock 必须自己配对写对。
| 工具 | 可否重用 | 计数方向 | 谁等谁 | 归零时 | 实现基类 |
|---|---|---|---|---|---|
CountDownLatch | 不可重用(一次性,state 只减不增) | 减计数 | 主线程等 N 个子任务 | 所有 await 者被唤醒 | AQS 共享模式,初始 state = N |
CyclicBarrier | 可重用(换代后计数复原) | 加计数 | 线程之间互相等 | 最后一个到达者执行 barrierAction,再唤醒全体并重置 | ReentrantLock + Condition,不是 AQS 共享模式 |
Semaphore | 可重复获取/释放 | 许可数(获取减、释放加) | 拿不到许可者阻塞 | 许可用尽则后续 acquire 阻塞 | AQS 共享模式,初始 state = permits |
Phaser | 可重用,参与者可动态注册/注销 | 加计数(arrive) | 按阶段(phase)互相等 | 进入下一阶段(phase 递增),可终止 | 自研 state |
最容易答错的是"谁等谁":CountDownLatch 是主线程等子任务,子任务彼此无关;CyclicBarrier 是参与线程互相等,最后一个到达者兼职执行 barrierAction(它由到达的线程执行,做耗时操作会拖住这批线程)。Phaser 相对 CyclicBarrier 的两个增量是"参与者数量可在运行中变更"与"天然支持多阶段流水线"。 |
| |
31.11 CompletableFuture 与 ForkJoinPool
| 方法 | 输入函数形态 | 语义 | 常见误用 |
|---|---|---|---|
thenApply | T → U | 同步转换前一步结果 | 函数里做阻塞调用,会占住完成前一步的那个线程 |
thenCompose | T → CompletionStage<U> | 摊平嵌套 Future,串起真正的异步链 | 用 thenApply 返回 Future,得到 CF<CF<U>>,需要两轮 join |
thenCombine | (T, U) → V | 两个互相独立的 Future 都完成后合并 | 误用 thenCompose,把本该并行的两个调用写成串行 |
allOf | CF<?>... | 全部完成,返回 CF<Void> | 以为它会把结果聚合成列表,其实要自己从各 Future 取 |
anyOf | CF<?>... | 任一完成即完成 | 用于"多副本取最快"却没给其余请求取消/超时 |
exceptionally | Throwable → T | 异常时给默认值 | 忽略上游异常被吞(它只处理上游) |
handle | (T, Throwable) → U | 正常/异常两条路都走,且改变结果类型 | 与 whenComplete 混用 |
whenComplete | (T, Throwable) → void | 只做副作用(日志、埋点),透传原结果或原异常 | 用它做降级:返回值透传,异常照样往下抛 |
thenApply 与 thenCompose 的差别只有一句:函数返回的是值还是 CompletionStage。前者在当前线程立即算完,后者把新阶段接到池里——把 IO 调用写在 thenApply 里,等于让完成上一步的线程去阻塞,异步链会被压回同步。 | |||
所有不带 Executor 参数的 *Async 方法都落到 ForkJoinPool.commonPool(),它的两个默认值决定了它不能承载业务 IO:并行度默认是 availableProcessors() − 1(8 核机器上只有 7 个工作线程,减 1 是为了给调用线程留一个核);它全 JVM 共享且不可扩容(parallelism 在首次使用时确定)。因此提交阻塞任务(数据库查询、HTTP 调用)时,7 个线程会全部躺平在 IO 等待上,连锁反应是所有依赖 commonPool 的代码(包括并行流 parallelStream())一起卡死,而线程栈上看不到任何"忙"的迹象,只有大量 WAITING/park——这是最典型的排查陷阱。 |
小心
三种正解:(1) 业务线程池显式传入——supplyAsync(supplier, bizPool);(2) 用 JDK 9+ 的 orTimeout(...) 与 completeOnTimeout(...) 给每个阶段加超时;(3) 用虚拟线程(virtual thread)做每任务一虚拟线程的执行器。注意:自定义 ForkJoinPool 只把并行度改大了,阻塞的本质没变;-Djava.util.concurrent.ForkJoinPool.common.parallelism=N 也只是调参。
ForkJoinPool 本身的机制是工作窃取(work stealing):每个工作线程有一个 WorkQueue(双端队列),自己 fork 的任务 push 到 base 端、pop 也从 base 端取(LIFO),因为刚 fork 的子任务数据大概率还在缓存里;空闲线程从别人队列的 top 端窃取(FIFO),从另一端拿能把两端操作者的竞争降到最低(这也是 base/top 用 volatile + CAS 而不是锁的原因)。这两条规则合起来既保住局部性,又让大任务切成小任务后能被全池消化——这正是 parallelStream 与 RecursiveTask 的吞吐来源,也是它偏爱 CPU 密集短任务的原因:窃取只能救"分工不均",救不了"所有人都阻塞在 IO 上"。
[!TIP]
一句话区分:ThreadPoolExecutor 用共享队列做全局调度,队列是竞争点,适合任务粒度大、有阻塞、需要背压与拒绝语义的场景;ForkJoinPool 用每线程队列 + 窃取做分布式调度,几乎没有队列竞争,适合可递归拆分的 CPU 密集任务。
31.12 并发容器选择与 CAS 的退化
AtomicLong.incrementAndGet() 是一个无限循环的 CAS:所有线程争抢同一个 value,失败即重试,最坏情况下开销与竞争线程数同阶。LongAdder 把一份计数分段:add(1) 先 CAS base,失败则按 (getProbe() & (cells.length − 1)) 定位到一个 Cell 再 CAS 该 Cell;被争抢的分片用 CAS 扩容 Cell[](容量上限为最接近 2 × 核数 的 2 的幂),每个 Cell 用 @Contended 填充一个缓存行以避免相邻 Cell 伪共享;sum() 则是 base + Σ cells[i] 的普通读,不阻塞写。
它降低 CAS 竞争的原理是分散热点:竞争线程数固定时分片越多,两个线程碰撞同一分片的概率越低,CAS 失败率随之下降(空间换时间,代价是每个 Cell 占一个缓存行)。代价与边界必须一起记住:
sum()不是精确的瞬时值。 它逐个读base与各Cell,读取过程中其他线程可能正在改某个Cell,Cell之间也没有统一快照,所以sum()只是"某个瞬间的近似汇总"。它适合监控指标、统计计数(QPS、调用次数、命中率),不适合余额、库存、序号分配这类需要精确值的场景,也不适合"先sum()再compareAndSet“这种复合判断;需要精确累加用AtomicLong,需要复杂聚合用LongAccumulator。- 低竞争时
AtomicLong可能更快:没有分片数组的间接寻址与内存占用,路径更短。AtomicReference的 ABA 问题:compareAndSet只比较引用是否相同,看不出"值被人改走又改回来”,典型形态是无锁栈pop里"头节点被换走又换回",于是 CAS 成功但中间态已被破坏。解法是加版本号:AtomicStampedReference<V>(int版本戳)或AtomicMarkableReference<V>(布尔标记)。要补一句面试官爱听的:ABA 在"值只是被覆盖"的场景无害,在"节点身份参与结构"的场景才致命——判断要不要防 ABA,看的是数据结构有没有依赖"引用同一性"。
注意
LongAdder 是 Striped64 的子类,DoubleAdder/LongAccumulator/DoubleAccumulator 共享同一套分片机制。ThreadLocalRandom.getProbe() 生成的探针值决定线程落在哪个分片:让同一线程稳定命中同一分片(局部性)与让不同线程分散(降低冲突)是矛盾的,实现上选择了后者。
本章小结
| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| 四步流程与队列容量 | 队列优先于扩容(else if 决定);LinkedBlockingQueue 默认 Integer.MAX_VALUE,maximumPoolSize 永远达不到 | 答"核心满 → 扩到最大 → 再入队";答"无界队列只是会变慢" |
| 四工厂隐患 | 两条根因:队列无界(堆积 OOM)或线程数无界(线程爆炸) | 答"Executors 是官方推荐所以生产可用" |
| 线程数公式 | CPU 密集 N + 1;IO 密集 N × (1 + W_w/W_c);混合用 L = λ × W 反推 | 只背公式,说不出 1/(1 − p) 的推导与压测拐点 |
| AQS state 与 CLH 变体 | int + volatile + CAS;双向链表 + waitStatus 四态,唤醒时从尾部反向找有效节点 | 答"state 只能是 0 和 1"、“队列是单向链表” |
PROPAGATE | 共享模式用 -3 解决 doReleaseShared 的丢失唤醒竞态 | 答"它只是共享模式的标记,没什么用" |
| 同步工具 | CountDownLatch 减计数一次性;CyclicBarrier 加计数可重用且线程互等 | 说反"谁等谁",或答 CyclicBarrier 也是 AQS 共享模式 |
自测题
Q1(推导)8 核机器上建了一个 new ThreadPoolExecutor(8, 64, 60, SECONDS, new LinkedBlockingQueue<>(), factory, new AbortPolicy()),随后提交 100 万个任务,每个任务耗时 1 ms。请说明线程数最终是多少、AbortPolicy 会不会触发、内存会发生什么。
答案与解析
**线程数恒为 8,`AbortPolicy` 永远不会触发,最终是堆内存 OOM。** 推导链:`LinkedBlockingQueue` 默认容量是 `Integer.MAX_VALUE`,所以 `execute` 第 ② 步的 `workQueue.offer(command)` 恒返回 `true`,第 ③ 步的扩容分支是死代码——`maximumPoolSize = 64` 形同虚设,`64 − 8 = 56` 个非核心线程永远不会创建。队列不断增长,100 万个 `Runnable` 连其闭包一起常驻堆,最终 `OutOfMemoryError: Java heap space`。拒绝策略只在第 ④ 步被调用,第 ④ 步走不到,所以 `AbortPolicy` 也不会抛——**最阴的一点:任务没被拒绝,只是永远排不到**。常见错误是答"会扩到 64 个线程"(把队列当成有界)或答"队列满了触发 `AbortPolicy`"(无界队列不会满)。 **面试官会追问**:(1) 换成 `ArrayBlockingQueue<>(500)` 后如何?——队列满才扩容,最高到 64 个线程;64 个也满了才开始拒绝。(2) 任务改成耗时 1 s 的阻塞调用呢?——吞吐掉到 8/s,堆积速度快 1000 倍,OOM 来得更早;此时应按 31.5.2 节把核心数提上去,或降低队列容量让拒绝早发生。Q2(辨析)有人说"用 Executors.newCachedThreadPool() 只要设置好 keepAliveTime 就很安全,因为空闲线程 60 秒后会自动回收"。请判断对错。
答案与解析
**错,错在同一个前提上。** `keepAliveTime` 管的是**空闲回收**,与**峰值线程数**是两个无关维度。`newCachedThreadPool` 的核心数是 0、队列是 `SynchronousQueue`(不存储)、最大线程数是 `Integer.MAX_VALUE`:每来一个任务,若没有空闲线程立刻接手就走第 ③ 步新建线程,**没有任何上限**。提交速率持续高于执行速率时,线程数在 60 秒内可以涨到几千甚至上万;每个线程占 1 MB 量级栈空间,每次切换约 1~5 μs,CPU 大量时间花在换栈上,最终报 `OutOfMemoryError: unable to create native thread`(线程栈是本地内存,受 `ulimit` 与物理内存约束)。**正确做法**:`new ThreadPoolExecutor(0, 200, 60, SECONDS, new SynchronousQueue<>(), factory, handler)`——保留"不排队、直接移交"的语义,但**给最大线程数一个明确上限**,越界即拒绝并形成背压。 **面试官会追问**:(1) 为什么用 `SynchronousQueue` 而不是长度 1 的队列?——它的容量为 0,`offer` 在有空闲线程接手时成功、否则失败,正对应"有闲线程就复用、没有就新建";用容量 1 的队列会让任务先被缓存,复用率下降。(2) 什么场景适合它的形态?——任务短小、突发性强、提交速率可控且能接受线程数波动;生产上仍应显式写明上限与拒绝策略。Q3(推导)某订单接口目标 3000 QPS,压测测得单请求平均 RT 为 40 ms,其中 CPU 计算 8 ms,其余为下游 RPC 与数据库等待。机器 16 核。请分别用 Little 定律与 IO 密集公式估算线程数,说明不一致时以哪个为准,并给出压测找拐点的观测指标。
答案与解析
**Little 定律口径**:`L = λ × W = 3000 × 0.04 s = 120`,线程数至少 120 才不排队。**IO 密集口径**:`W_c = 8 ms`、`W_w = 32 ms`,`N_threads = 16 × (1 + 32/8) = 80`。**以哪个为准**:前者算的是"支撑目标吞吐所需的并发",后者算的是"让 CPU 不空转所需的线程",取**较大值**——按 80 个线程的吞吐上限只有 `80/0.04 = 2000 QPS`,达不到目标,故取 120(预留余量到 140~160)。若 120 个线程仍达不到 3000 QPS 且 CPU 未满,瓶颈不在线程池而在下游(数据库连接池上限、下游 RPC 配额),加线程只会让 RT 更差。 **拐点观测指标与特征**:`getActiveCount()` 是否贴住线程数上限、`getQueue().size()` 是否从 0 开始持续增长、`getCompletedTaskCount()` 的相邻区间差值给出实际吞吐,外加 P99/P999 与 CPU 使用率;**拐点 = QPS 不再上升而 P99 陡增、队列持续非空**。若加线程后 QPS 与 CPU 都不动,立刻停止加线程去查下游。**面试官会追问**:`L = λ × W` 的前提是系统处于**稳态**(到达率等于离开率、队列不无限增长);队列持续堆积时算出的 `L` 只是下界,因为 RT 会因排队继续变大,形成正反馈。Q4(推导/画图)请说明 AQS 共享模式中 Node.PROPAGATE 的必要性:如果 doReleaseShared 里处理 ws == 0 的分支改成"什么都不做直接跳过",会出现什么问题?
答案与解析
**会出现丢失唤醒(lost wakeup)**:某个等待线程永远不会被 `unpark`,表现为 `CountDownLatch.await()` 偶发不返回、`Semaphore.acquire()` 偶发挂死,只在特定线程交织下出现、极难复现。时序是:t1 释放方 R 读到 `ws == 0`,若不置 `PROPAGATE` 就直接判"是否 `h == head`";t2 被唤醒者 T 重新 `tryAcquireShared` 成功并 `setHead`,随后自己也调用本方法;t3 T 读到"自己这个 head,`ws == 0`";t4 R 发现 `head` 已被 T 改变,判定"传播已到队尾"而退出;t5 若 T 在 `ws == 0` 时同样跳过、不 `unpark`,n2 就在 `SIGNAL` 上永久挂起——丢失唤醒。根因是**"要不要继续唤醒后继"这个信息没有在 `waitStatus` 里留下痕迹**:`ws == 0` 既可能是"没有后继要唤醒",也可能是"我刚被交接了唤醒义务"。`PROPAGATE`(小于 0)把后一种情况显式编码进状态,于是后续任何读到它的线程都会走 `unparkSuccessor` 继续传播,代价只是一个常量与一次 CAS。常见错误是答"`PROPAGATE` 用来区分独占与共享模式"(区分靠节点构造时的 `shared` 字段)。**面试官会追问**:(1) 独占模式需要它吗?——不需要,独占一次只放行一个线程,`release` → `unparkSuccessor` 是一条直路。(2) 为什么 `acquireQueued` 被中断不直接抛异常?——为了把"不可中断获取"与"可中断获取"分成两条 API,`acquire` 只在最后 `selfInterrupt()` 补一个中断标记,既不吞掉中断也不破坏锁语义。Q5(设计)一个服务用 CompletableFuture.supplyAsync 并发调用 5 个下游接口(1 个数据库查询 + 3 个 RPC + 1 个缓存),QPS 上来后出现"整机所有异步任务突然一起卡死,线程栈全是 park"。请指出根因、给出三种修复方案,并说明为什么 Semaphore 限流与线程数调整是同一个数学关系。
答案与解析
**根因**:不带 `Executor` 参数的 `supplyAsync` 落在 `ForkJoinPool.commonPool()`,并行度默认是 `availableProcessors() − 1`(8 核机器上只有 7 个线程),且**全 JVM 共享、不可扩容**。5 个下游调用都是阻塞 IO,7 个线程很快全部躺在 IO 等待上;此后任何依赖 `commonPool` 的代码(包括 `parallelStream()`)都排队等不到线程——线程既不忙也不报错,只是没有可用工作线程,所以线程栈上只有大量 `park`。 **三种修复**:(1) **显式指定业务线程池**,按 31.5 节公式算出线程数后建独立池,并让不同下游用不同池隔离(避免一个慢下游耗尽全部线程);(2) **给每个阶段加超时与降级**——`orTimeout(...)` 或 `completeOnTimeout(default, ...)` 配合 `exceptionally` 给默认值;(3) **改用虚拟线程执行器**或在阻塞调用处换异步客户端。注意自定义 `ForkJoinPool` 或调 `common.parallelism` 只是把 7 改成 32,**没有改变"阻塞会耗尽池"这个本质**,属于调参不是修复。**`Semaphore` 与线程数的关系**:`L = λ × W` 中的 `L` 就是"同时在处理中的请求数";线程池用**线程数**给 `L` 设上限,`Semaphore(n)` 用**许可数**给 `L` 设上限,约束的是同一个量——单请求 100 ms 时 `Semaphore(50)` 的吞吐上限是 `50/0.1 = 500 QPS`,与"50 个线程"完全等价。区别只在形式:线程池的许可与线程一一绑定(阻塞时线程被占),`Semaphore` 的许可可跨线程、跨阶段传递。 **面试官会追问**:(1) 怎么确认是 `commonPool` 的问题?——线程名前缀是 `ForkJoinPool.commonPool-worker-N`,`N` 不超过 `核数 − 1` 且状态全是 `WAITING`。(2) `thenApply` 和 `supplyAsync` 哪个更危险?——`thenApply` 更隐蔽:它不切换线程,会**占用上一个阶段完成时的那个线程**(可能正是 `commonPool` 的工作线程)。面试高频问题与陷阱
| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| 线程池的四种拒绝策略分别是什么?你们生产环境用的是哪个? | 拒绝语义与背压 | 错答"用默认策略"却说不出默认是 AbortPolicy、也不知道它会抛 RejectedExecutionException。加分点:CallerRunsPolicy 由提交线程执行形成背压(代价是占用 Tomcat 工作线程),DiscardPolicy 静默丢弃最危险 | 31.3 |
| 线程池线程数应该设多少?CPU 密集和 IO 密集分别怎么算? | 公式推导与压测方法 | 错答"设成核数两倍比较合适"这类口诀。加分点:N + 1 与 N × (1 + W_w/W_c) 的推导、用 L = λ × W 从目标 QPS 反推、压测看 getActiveCount/getQueue().size()/getCompletedTaskCount | 31.5 |
请描述一下 AQS 的原理,state 是什么?队列是单向还是双向、为什么? | AQS 状态与队列结构 | 错答"state 是 0 空闲 1 占用"就没了,或答"队列是单向链表"(那是原版 CLH)。加分点:state 是 volatile int + CAS,语义由子类定义;改成双向链表是为了唤醒时能从尾部反向找到第一个有效节点 | 31.8 |
AQS 的 Node.waitStatus 有哪几个值?PROPAGATE 是干什么用的? | 共享模式的并发正确性 | 错答"就是标记节点状态的,PROPAGATE 没什么用"。加分点:CANCELLED/SIGNAL/CONDITION/PROPAGATE 四态加默认 0;PROPAGATE 解决 doReleaseShared 里 ws == 0 分支导致的丢失唤醒 | 31.8.4 |
CountDownLatch 和 CyclicBarrier 有什么区别?Semaphore 又能解决什么问题? | 同步工具的语义辨析 | 错答把"谁等谁"说反,或答"两者差不多,CyclicBarrier 只是能重置"。加分点:CountDownLatch 减计数、一次性、主线程等子任务;CyclicBarrier 加计数、可重用、最后一个到达者执行 barrierAction;Semaphore 管许可数即限流 | 31.10 |
CompletableFuture 的 thenApply 和 thenCompose 有什么区别?不传线程池会怎样? | 编排语义与默认线程池 | 错答"都是接下一步,没区别",或压根不知道默认用 commonPool。加分点:区别在返回值是值还是 CompletionStage;commonPool 并行度默认 核数 − 1、全 JVM 共享不可扩容,阻塞任务会把公共池连 parallelStream 一起拖死 | 31.11 |
AtomicLong 和 LongAdder 怎么选?LongAdder.sum() 为什么不能当精确值? | CAS 退化与分段累加 | 错答"LongAdder 全面优于 AtomicLong"。加分点:LongAdder 用 base + Cell[] 分散 CAS 热点,代价是 sum() 为逐 Cell 的近似汇总(非原子快照),适合监控指标不适合余额库存 | 31.12 |
延伸阅读
- Oracle 官方文档:
java.util.concurrent.ThreadPoolExecutor(七个参数语义、prestartAllCoreThreads、allowCoreThreadTimeOut、RejectedExecutionHandler)、Executors(四个工厂方法的等价参数与告警说明)、AbstractQueuedSynchronizer(模板方法清单与Node.waitStatus常量)、Condition、ForkJoinPool(parallelism默认值与工作窃取说明)、LongAdder/Striped64(sum()的近似性说明)。 - OpenJDK 源码:
ThreadPoolExecutor.execute(三段else if与recheck)、runWorker/processWorkerExit(异常替换线程的路径)、AbstractQueuedSynchronizer.doReleaseShared(PROPAGATE的处理)、ConditionObject.await/transferForSignal、ReentrantReadWriteLock(state高低 16 位的位域划分)。 - Doug Lea, The java.util.concurrent Synchronizer Framework(AQS 的设计说明,含 CLH 变体与
PROPAGATE的引入动机)。 - 见第 30 章 30.2、30.3 节(
happens-before与内存屏障)、第 30 章 30.5 节(Mark Word 与锁升级)、第 30 章 30.7 节(ThreadLocalMap结构与泄漏根因)、第 32 章 32.2 节(TLAB与对象分配)。