hello,我是逆境不可逃
并发开发不只是"给共享变量加锁"。很多时候,我们真正需要解决的是:等待一批任务结束、让多个线程到齐后再出发、限制同时访问资源的数量,或者协调多个阶段的任务。JUC 已经为这些场景准备好了成熟的同步工具。
摘要: 本文从实际开发和面试两个角度,系统讲解 CountDownLatch、CyclicBarrier、Semaphore 和 Phaser。每个工具都包含通俗解释、适用场景、可运行 Demo、参考输出、常见错误和关键源码;最后结合 AQS 共享模式讲清底层原理,并给出工具选型表与高频面试题。
建议标签: Java、JUC、CountDownLatch、CyclicBarrier、Semaphore、Phaser、AQS、Java面试
文章信息
- 推荐环境:JDK 17
- 适合读者:掌握 Java 线程和锁的基础,希望应对实际开发与 Java 并发面试的开发者
- 学习目标:会选型、会使用、能避坑、能沿关键源码讲清底层原理
- 源码说明:文中的源码按照 OpenJDK 主干逻辑裁剪,省略部分参数校验和非核心分支;不同 JDK 版本的具体代码可能略有差异
- 输出说明:并发程序的线程执行顺序不固定,文中展示的是一种可能的参考输出
一、为什么有了锁,还需要同步工具类
synchronized 和 ReentrantLock 主要解决的是:
text
同一时刻,只允许一个线程进入临界区。
但实际开发中,还有很多更具体的线程协作需求:
text
商品详情接口:等待商品、库存、价格三个查询全部完成
多人游戏:所有玩家准备完成后才能开始
第三方接口:最多允许 10 个请求同时调用
数据处理:所有线程完成加载后,再一起进入校验阶段
当然,我们可以自己使用锁、条件队列和计数器实现这些逻辑,但代码会复杂,而且很容易发生漏唤醒、死锁和资源泄漏。
JUC 同步工具类相当于把常见的协作模式封装好了:
| 工具 | 一句话作用 | 通俗比喻 |
|---|---|---|
CountDownLatch |
等待若干任务全部完成 | 倒计时结束后开门 |
CyclicBarrier |
多个线程到齐后一起继续 | 所有人到集合点再出发 |
Semaphore |
限制同时访问资源的数量 | 数量有限的许可证 |
Phaser |
协调动态参与者的多阶段任务 | 可增减成员的分阶段项目组 |
先记住一个选型口诀:
text
等别人干完:CountDownLatch
大家到齐再走:CyclicBarrier
限制并发数量:Semaphore
动态多阶段协作:Phaser
接下来逐个拆开。
二、CountDownLatch:一个线程等待多个任务完成
2.1 它解决什么问题
假设商品详情页需要并行查询:
text
商品基本信息
实时库存
会员价格
主线程不能在任何一个查询完成后就直接返回,而要等待三个查询都结束,再组装最终结果。
CountDownLatch 可以理解为一个倒计时门闩:
text
创建时设置计数 N
每完成一个任务,调用 countDown(),计数减 1
等待线程调用 await()
计数变成 0 后,门闩打开,所有等待线程继续执行
注意:调用 countDown() 的线程不会因此等待。真正等待的是调用 await() 的线程。
2.2 Demo:聚合三个查询结果
java
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ProductDetailDemo {
public static void main(String[] args) throws InterruptedException {
ExecutorService pool = Executors.newFixedThreadPool(3);
CountDownLatch latch = new CountDownLatch(3);
Map<String, String> result = new ConcurrentHashMap<>();
pool.execute(() -> query("商品", "机械键盘", 300, result, latch));
pool.execute(() -> query("库存", "库存 86 件", 500, result, latch));
pool.execute(() -> query("价格", "会员价 299 元", 200, result, latch));
System.out.println("主线程:等待三个查询完成");
boolean completed = latch.await(2, TimeUnit.SECONDS);
if (completed) {
System.out.println("主线程:组装结果 " + result);
} else {
System.out.println("主线程:查询超时,执行降级逻辑");
}
pool.shutdown();
}
private static void query(String key,
String value,
long cost,
Map<String, String> result,
CountDownLatch latch) {
try {
TimeUnit.MILLISECONDS.sleep(cost);
result.put(key, value);
System.out.println(Thread.currentThread().getName()
+ " 完成查询:" + key);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
}
}
一种可能的输出:
text
主线程:等待三个查询完成
pool-1-thread-3 完成查询:价格
pool-1-thread-1 完成查询:商品
pool-1-thread-2 完成查询:库存
主线程:组装结果 {商品=机械键盘, 库存=库存 86 件, 价格=会员价 299 元}
这里有三个关键点。
第一,结果容器使用 ConcurrentHashMap,因为三个线程会并发写入。如果换成普通 HashMap,就不能保证并发安全。
第二,countDown() 必须放在 finally 中。如果查询发生异常但没有执行 countDown(),计数可能永远无法变成 0。
第三,线上代码应优先使用带超时的 await:
java
boolean completed = latch.await(2, TimeUnit.SECONDS);
某个下游卡死时,主线程可以超时降级,而不是无限等待。
2.3 两个门闩:让线程同时开始,再等待全部结束
压测时常见的需求是:准备多个线程,让它们尽量同时发送请求,然后等待全部完成。
java
import java.util.concurrent.CountDownLatch;
public class RaceDemo {
public static void main(String[] args) throws InterruptedException {
int runnerCount = 3;
CountDownLatch startSignal = new CountDownLatch(1);
CountDownLatch finishSignal = new CountDownLatch(runnerCount);
for (int i = 1; i <= runnerCount; i++) {
int runnerNo = i;
new Thread(() -> {
try {
System.out.println("选手 " + runnerNo + " 已准备");
startSignal.await();
System.out.println("选手 " + runnerNo + " 出发");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
finishSignal.countDown();
}
}, "runner-" + i).start();
}
Thread.sleep(300);
System.out.println("裁判:开始!");
startSignal.countDown();
finishSignal.await();
System.out.println("裁判:全部结束");
}
}
参考输出:
text
选手 1 已准备
选手 2 已准备
选手 3 已准备
裁判:开始!
选手 2 出发
选手 1 出发
选手 3 出发
裁判:全部结束
这里两个门闩分工不同:
text
startSignal:初始值为 1,裁判负责放行所有选手
finishSignal:初始值为选手数,裁判等待所有选手结束
需要说明的是,它只能让线程"尽量同时开始"。真正何时获得 CPU,仍由操作系统调度决定。
2.4 CountDownLatch 的关键源码
CountDownLatch 内部有一个继承 AQS 的 Sync:
java
private static final class Sync extends AbstractQueuedSynchronizer {
Sync(int count) {
setState(count);
}
int getCount() {
return getState();
}
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
protected boolean tryReleaseShared(int releases) {
for (;;) {
int c = getState();
if (c == 0)
return false;
int nextc = c - 1;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
}
构造方法把计数放进 AQS 的 state:
java
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException();
this.sync = new Sync(count);
}
await() 的主线:
java
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
它会调用 tryAcquireShared:
text
state == 0:返回正数,获取成功,线程直接继续
state != 0:返回负数,获取失败,线程进入 AQS 同步队列并被阻塞
countDown() 的主线:
java
public void countDown() {
sync.releaseShared(1);
}
tryReleaseShared 使用 CAS 把 state 减 1。只有当本次操作把 state 减到 0 时才返回 true,随后 AQS 执行共享唤醒,把等待线程传播式唤醒。
完整流程可以概括为:
text
new CountDownLatch(3)
↓
AQS state = 3
↓
await 发现 state != 0,进入 AQS 队列等待
↓
三个任务依次 countDown:3 → 2 → 1 → 0
↓
最后一次 countDown 触发共享唤醒
↓
所有 await 线程继续执行
2.5 常见误区
CountDownLatch是一次性的,计数到 0 后不能恢复。如果需要重复多轮同步,应考虑CyclicBarrier或Phaser。countDown()多调用不会变成负数,但这通常意味着业务计数设计有问题。await()被中断会抛出InterruptedException,不要直接吞掉,应恢复中断标记或向上抛出。- 任务没有成功提交时,也要重新考虑计数,否则可能等待一个根本不存在的任务。
CountDownLatch只负责等待,不会自动收集子任务返回值,也不会自动传播子任务异常。
三、CyclicBarrier:所有线程到齐后再一起继续
3.1 它和 CountDownLatch 有什么不同
假设三个玩家进入房间:
text
玩家 A 准备完成
玩家 B 准备完成
玩家 C 准备完成
三个人全部准备后,游戏才能开始
每个玩家不仅要报告"我到了",自己也必须留在集合点等待别人。这正是 CyclicBarrier 的场景。
一句话区分:
text
CountDownLatch:你们干完告诉我,我在这里等你们。
CyclicBarrier:我们互相等待,所有人到齐后一起走。
3.2 Demo:三个玩家准备后开始游戏
java
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.TimeUnit;
public class GameStartDemo {
public static void main(String[] args) {
CyclicBarrier barrier = new CyclicBarrier(3, () ->
System.out.println("系统:所有玩家已准备,游戏开始"));
for (int i = 1; i <= 3; i++) {
int playerNo = i;
new Thread(() -> {
try {
TimeUnit.MILLISECONDS.sleep(playerNo * 200L);
System.out.println("玩家 " + playerNo + " 准备完成");
barrier.await();
System.out.println("玩家 " + playerNo + " 进入游戏");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (BrokenBarrierException e) {
System.out.println("玩家 " + playerNo + ":本轮屏障已损坏");
}
}, "player-" + i).start();
}
}
}
参考输出:
text
玩家 1 准备完成
玩家 2 准备完成
玩家 3 准备完成
系统:所有玩家已准备,游戏开始
玩家 3 进入游戏
玩家 1 进入游戏
玩家 2 进入游戏
最后一个到达的线程负责执行 barrierAction。执行完成后,屏障才会放行其他线程。
因此,barrierAction 不宜执行很慢的操作。否则所有参与者都要继续等待它。
3.3 为什么叫 Cyclic:同一个屏障可以循环使用
CyclicBarrier 到齐放行后会自动开始下一轮。
java
import java.util.concurrent.CyclicBarrier;
public class TwoRoundBarrierDemo {
public static void main(String[] args) {
CyclicBarrier barrier = new CyclicBarrier(2);
Runnable task = () -> {
try {
String name = Thread.currentThread().getName();
System.out.println(name + " 完成第 1 阶段");
barrier.await();
System.out.println(name + " 完成第 2 阶段");
barrier.await();
System.out.println(name + " 全部完成");
} catch (Exception e) {
System.out.println(Thread.currentThread().getName()
+ " 执行失败:" + e.getClass().getSimpleName());
}
};
new Thread(task, "worker-A").start();
new Thread(task, "worker-B").start();
}
}
一种可能的输出:
text
worker-A 完成第 1 阶段
worker-B 完成第 1 阶段
worker-B 完成第 2 阶段
worker-A 完成第 2 阶段
worker-A 全部完成
worker-B 全部完成
屏障每完成一轮,内部都会创建一个新的"代",源码中叫 Generation。
3.4 屏障为什么会损坏
屏障要求参与者共同完成这一轮。如果某个正在等待的线程被中断、等待超时,或者屏障动作执行失败,这一轮就不能正常完成。
此时屏障会进入 broken 状态,其他等待线程会收到 BrokenBarrierException。
java
try {
barrier.await(500, TimeUnit.MILLISECONDS);
} catch (java.util.concurrent.TimeoutException e) {
System.out.println("自己等待超时");
} catch (BrokenBarrierException e) {
System.out.println("其他参与者出问题,屏障已损坏");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
需要继续使用时,可以在确认旧一轮已经失败后调用:
java
barrier.reset();
但 reset() 会使当前等待者收到 BrokenBarrierException。它不是"无影响地重新计数",必须结合业务状态谨慎使用。
3.5 CyclicBarrier 的关键源码
与很多人的第一印象不同,CyclicBarrier 没有直接继承 AQS。它使用的是:
text
ReentrantLock + Condition
关键字段:
java
private final ReentrantLock lock = new ReentrantLock();
private final Condition trip = lock.newCondition();
private final int parties;
private final Runnable barrierCommand;
private Generation generation = new Generation();
private int count;
字段含义:
text
parties:每一轮需要到达的参与者总数
count:本轮还差多少参与者
trip:没到齐时,参与者在这个条件队列等待
generation:当前属于哪一轮,以及这一轮是否已损坏
barrierCommand:到齐后执行的动作
await() 最终进入 dowait。主干逻辑如下:
java
private int dowait(boolean timed, long nanos)
throws InterruptedException, BrokenBarrierException,
TimeoutException {
final ReentrantLock lock = this.lock;
lock.lock();
try {
final Generation g = generation;
if (g.broken)
throw new BrokenBarrierException();
int index = --count;
if (index == 0) {
boolean ranAction = false;
try {
if (barrierCommand != null)
barrierCommand.run();
ranAction = true;
nextGeneration();
return 0;
} finally {
if (!ranAction)
breakBarrier();
}
}
for (;;) {
if (!timed)
trip.await();
else if (nanos > 0L)
nanos = trip.awaitNanos(nanos);
if (g.broken)
throw new BrokenBarrierException();
if (g != generation)
return index;
if (timed && nanos <= 0L) {
breakBarrier();
throw new TimeoutException();
}
}
} finally {
lock.unlock();
}
}
它的流程是:
text
线程调用 await
↓
获取 ReentrantLock
↓
count 减 1
↓
不是最后一个:调用 Condition.await 释放锁并等待
↓
最后一个:执行 barrierAction
↓
nextGeneration 把 count 重置为 parties,并 signalAll
↓
所有参与者进入下一阶段,新一轮屏障可以继续使用
nextGeneration() 很能体现"循环"的含义:
java
private void nextGeneration() {
trip.signalAll();
count = parties;
generation = new Generation();
}
3.6 常见误区
- 参与者数量设置为 3,却只启动两个会调用
await()的线程,结果两个线程会一直等待。 - 把线程池大小设成 2,却把屏障参与者设为 3。两个线程占住线程池等待,第三个任务永远没有线程执行,形成线程饥饿死锁。
- 忽略
BrokenBarrierException,误以为其他线程仍能正常进入下一阶段。 - 在
barrierAction中执行耗时远程调用,导致所有线程长时间无法放行。 - 认为可复用等于失败后自动恢复。屏障损坏后必须处理本轮失败,必要时再
reset()。
四、Semaphore:用许可证限制并发数量
4.1 它解决什么问题
假设第三方短信接口承诺:同一时刻最多承受 3 个请求。
我们有 20 个业务线程,不代表应该让 20 个线程同时调用短信接口。线程池控制的是"由多少线程执行任务",而 Semaphore 更适合控制"某段资源访问代码最多同时进入多少个线程"。
可以把 Semaphore 想象成停车场:
text
停车场有 3 个车位
车辆进入前 acquire 获取车位
没有车位就等待或离开
车辆离开时 release 归还车位
4.2 Demo:最多三个任务同时调用接口
java
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class SemaphoreLimitDemo {
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(3);
AtomicInteger running = new AtomicInteger();
for (int i = 1; i <= 8; i++) {
int taskNo = i;
new Thread(() -> {
boolean acquired = false;
try {
semaphore.acquire();
acquired = true;
int current = running.incrementAndGet();
System.out.println("任务 " + taskNo
+ " 开始,当前并发数=" + current);
TimeUnit.MILLISECONDS.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (acquired) {
int current = running.decrementAndGet();
semaphore.release();
System.out.println("任务 " + taskNo
+ " 结束,当前并发数=" + current);
}
}
}, "task-" + i).start();
}
}
}
一种可能的输出:
text
任务 1 开始,当前并发数=1
任务 3 开始,当前并发数=2
任务 2 开始,当前并发数=3
任务 1 结束,当前并发数=2
任务 4 开始,当前并发数=3
任务 3 结束,当前并发数=2
任务 5 开始,当前并发数=3
...
无论线程如何交替,当前并发数 都不会超过 3。
这里为什么要用 acquired 标记?
java
boolean acquired = false;
因为 acquire() 本身可能在成功获取许可证前被中断。如果没有拿到许可证,却在 finally 中无条件 release(),许可证数量会凭空增加。
正确模板是:
java
boolean acquired = false;
try {
semaphore.acquire();
acquired = true;
doWork();
} finally {
if (acquired) {
semaphore.release();
}
}
如果在 acquire() 成功后立刻进入一个内层 try-finally,也可以写成:
java
semaphore.acquire();
try {
doWork();
} finally {
semaphore.release();
}
4.3 tryAcquire:拿不到许可证就快速失败或超时降级
保护下游服务时,通常不希望业务线程无限等待许可证。
java
boolean acquired = semaphore.tryAcquire(200, TimeUnit.MILLISECONDS);
if (!acquired) {
System.out.println("系统繁忙,请稍后重试");
return;
}
try {
callRemoteService();
} finally {
semaphore.release();
}
三种获取方式:
| 方法 | 没有许可证时的行为 |
|---|---|
acquire() |
可中断地等待 |
acquireUninterruptibly() |
忽略等待过程中的中断,直到获取成功 |
tryAcquire() |
立即返回 false |
tryAcquire(timeout, unit) |
最多等待指定时间 |
实际服务中,tryAcquire(timeout) 往往更容易设置明确的延迟上限。
4.4 一次获取多个许可证
Semaphore 允许一个任务占用多个许可证:
java
semaphore.acquire(2);
try {
doHeavyWork();
} finally {
semaphore.release(2);
}
这可以表达"重任务消耗两个资源单位,普通任务消耗一个资源单位"。但必须保证获取和归还数量一致,否则会造成许可证泄漏或凭空增加。
4.5 公平与非公平
java
Semaphore unfair = new Semaphore(3); // 默认非公平
Semaphore fair = new Semaphore(3, true); // 公平
非公平模式允许刚到达的线程直接尝试获取许可证,可能绕过已经排队的线程,通常吞吐量更高。
公平模式会尽量按照等待先后顺序分配许可证,等待更可预测,但维护排队顺序会带来一定开销。
注意:即使创建了公平信号量,不带超时的 tryAcquire() 也可能直接抢占当前可用许可证,不保证公平排队。
4.6 Semaphore 的关键源码
Semaphore 的内部同步器同样继承 AQS:
java
abstract static class Sync extends AbstractQueuedSynchronizer {
Sync(int permits) {
setState(permits);
}
final int nonfairTryAcquireShared(int acquires) {
for (;;) {
int available = getState();
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
protected final boolean tryReleaseShared(int releases) {
for (;;) {
int current = getState();
int next = current + releases;
if (next < current)
throw new Error("Maximum permit count exceeded");
if (compareAndSetState(current, next))
return true;
}
}
}
这里 state 表示当前剩余许可证数。
获取一个许可证:
text
读取 state
计算 remaining = state - 1
remaining < 0:许可证不足,获取失败,进入 AQS 队列
remaining >= 0:CAS 把 state 更新为 remaining
释放许可证:
text
读取 state
计算 next = state + releases
CAS 更新 state
执行 AQS 共享唤醒,让等待线程重新竞争许可证
公平版本在扣减许可证之前多做一次判断:
java
protected int tryAcquireShared(int acquires) {
for (;;) {
if (hasQueuedPredecessors())
return -1;
int available = getState();
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
如果同步队列中已经存在排在当前线程前面的节点,公平模式就先排队,不直接插队获取许可证。
4.7 Semaphore 是限流器吗
它可以限制"同时进行中的数量",但它不是严格意义上的 QPS 限流器。
例如许可证为 10,每个请求只执行 1 毫秒,那么一秒可能完成远超 10 个请求。它限制的是:
text
并发量:某一时刻最多有多少任务正在访问资源
而令牌桶、漏桶通常限制的是:
text
速率:一段时间内最多允许多少请求通过
所以 Semaphore 很适合隔离数据库连接、第三方接口并发和昂贵资源,但不能直接等同于每秒请求数限流。
4.8 常见误区
- 获取许可证后没有在
finally中归还,最终所有许可证被耗尽。 acquire()获取失败或被中断后仍然release(),导致许可证凭空增加。- 把
Semaphore当成分布式限流器。它只管理当前 JVM 中这个对象的许可证。 - 许可证设置过大,把所有压力直接传给下游。
- 许可证设置过小且无限等待,造成业务线程大量阻塞。
五、Phaser:参与者可动态变化的多阶段协作
5.1 为什么还需要 Phaser
CountDownLatch 只能使用一次;CyclicBarrier 可以重复使用,但参与者数量通常在构造时固定。
如果任务具备下面这些特点,Phaser 更合适:
text
任务分成加载、校验、写入等多个阶段
每个阶段结束后都要等待其他参与者
参与者可能在运行期间加入
某个参与者完成自己的工作后可以退出后续阶段
Phaser 中有两个重要概念:
text
party:参与者
phase:阶段,从 0 开始递增
5.2 常用方法先看懂
| 方法 | 含义 |
|---|---|
register() |
动态注册 1 个参与者 |
bulkRegister(n) |
一次注册 n 个参与者 |
arrive() |
到达当前阶段,但不等待其他人 |
arriveAndAwaitAdvance() |
到达当前阶段,并等待阶段推进 |
arriveAndDeregister() |
到达当前阶段,同时退出后续阶段 |
awaitAdvance(phase) |
自己不报到,只等待指定阶段推进 |
getPhase() |
获取当前阶段编号 |
getRegisteredParties() |
获取已注册参与者数量 |
特别注意:
text
arrive 表示"我完成了当前阶段"。
awaitAdvance 只是等待阶段变化,不会代替参与者 arrive。
5.3 Demo:加载、校验、保存三阶段任务
java
import java.util.concurrent.Phaser;
import java.util.concurrent.TimeUnit;
public class BatchPhaserDemo {
public static void main(String[] args) {
int workerCount = 3;
Phaser phaser = new Phaser(workerCount);
for (int i = 1; i <= workerCount; i++) {
int workerNo = i;
new Thread(() -> {
doStep(workerNo, "加载数据");
phaser.arriveAndAwaitAdvance();
doStep(workerNo, "校验数据");
phaser.arriveAndAwaitAdvance();
doStep(workerNo, "保存结果");
phaser.arriveAndDeregister();
}, "worker-" + i).start();
}
}
private static void doStep(int workerNo, String step) {
try {
TimeUnit.MILLISECONDS.sleep(workerNo * 100L);
System.out.printf("阶段 %s:worker-%d 完成%n", step, workerNo);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
参考输出:
text
阶段 加载数据:worker-1 完成
阶段 加载数据:worker-2 完成
阶段 加载数据:worker-3 完成
阶段 校验数据:worker-1 完成
阶段 校验数据:worker-2 完成
阶段 校验数据:worker-3 完成
阶段 保存结果:worker-1 完成
阶段 保存结果:worker-2 完成
阶段 保存结果:worker-3 完成
虽然每一行的精确顺序可能变化,但一定满足:
text
所有加载完成后,才会进入校验
所有校验完成后,才会进入保存
最后一个阶段使用 arriveAndDeregister(),表示任务完成当前阶段后注销,不再参与后续阶段。
5.4 Demo:运行期间动态注册参与者
java
import java.util.concurrent.Phaser;
public class DynamicRegisterDemo {
public static void main(String[] args) {
Phaser phaser = new Phaser(1); // 主线程先注册自己
for (int i = 1; i <= 3; i++) {
phaser.register();
int taskNo = i;
new Thread(() -> {
try {
System.out.println("任务 " + taskNo + " 完成阶段 0");
phaser.arriveAndAwaitAdvance();
System.out.println("任务 " + taskNo + " 进入阶段 1");
} finally {
phaser.arriveAndDeregister();
}
}).start();
}
System.out.println("主线程完成阶段 0 的准备");
phaser.arriveAndDeregister();
}
}
为什么主线程一开始要注册自己?
如果 Phaser 初始参与者为 0,子线程还没全部注册时,先注册并到达的线程可能推动阶段提前结束。让主线程先占一个参与者名额,等所有子任务注册完,再由主线程注销并放行,可以避免阶段过早推进。
5.5 自定义 onAdvance:控制何时终止
Phaser 允许重写 onAdvance,在阶段推进时决定是否终止:
java
Phaser phaser = new Phaser(3) {
@Override
protected boolean onAdvance(int phase, int registeredParties) {
System.out.println("阶段 " + phase + " 完成");
return phase >= 2 || registeredParties == 0;
}
};
返回 true 表示 Phaser 进入终止状态;返回 false 表示继续下一阶段。
默认实现相当于:
java
protected boolean onAdvance(int phase, int registeredParties) {
return registeredParties == 0;
}
也就是说,当所有参与者都注销后,Phaser 默认终止。
5.6 Phaser 的关键源码思想
Phaser 没有直接建立在 AQS 上。它使用一个 long state 紧凑保存多个状态:
text
高位:当前 phase 阶段编号
中间位:已注册参与者数量 parties
低位:本阶段还未到达的参与者数量 unarrived
可以把它想成:
text
state = [阶段编号 | 注册人数 | 未到达人数]
注册参与者时,会通过 CAS 同时增加 parties 和 unarrived:
java
public int register() {
return doRegister(1);
}
到达操作最终会进入类似下面的逻辑:
java
private int doArrive(int adjust) {
for (;;) {
long s = state;
int phase = phaseOf(s);
int counts = countsOf(s);
int unarrived = counts == EMPTY ? 0 : unarrivedOf(s);
if (unarrived <= 0)
throw new IllegalStateException();
if (STATE.compareAndSet(this, s, s -= adjust)) {
if (unarrived == 1) {
// 当前线程是本阶段最后一个到达者
// 调用 onAdvance,重新计算下一阶段状态
// 释放等待当前阶段推进的线程
}
return phase;
}
}
}
这里的 adjust 根据方法不同而变化:
text
arrive:只减少 unarrived
arriveAndDeregister:同时减少 parties 和 unarrived
arriveAndAwaitAdvance() 的核心思想:
text
先用 CAS 报告自己已经到达
如果自己是最后一个到达者,推进 phase 并唤醒等待者
如果不是最后一个,就等待 phase 发生变化
等待线程会进入 Phaser 自己维护的等待队列,并通过 LockSupport.park 阻塞;阶段推进后,再通过 LockSupport.unpark 唤醒。
为什么 Phaser 不直接使用多个普通字段?因为阶段推进时,阶段编号、注册人数和未到达人数需要保持一致。把它们编码在一个 long 中,可以通过一次 CAS 原子更新组合状态。
5.7 分层 Phaser
当参与者非常多时,所有线程竞争同一个 Phaser 的 state 会产生竞争。Phaser 支持父子分层:
java
Phaser root = new Phaser();
Phaser groupA = new Phaser(root, 10);
Phaser groupB = new Phaser(root, 10);
子 Phaser 管理自己的参与者,子组完成一个阶段后再向父 Phaser 汇报。普通业务通常不需要主动使用这个高级功能,但它说明 Phaser 的设计目标确实是支持更复杂、规模更大的阶段协作。
5.8 常见误区
- 注册后忘记
arrive,导致当前阶段永久无法推进。 - 任务结束后忘记
arriveAndDeregister,后续阶段一直等待一个已经不存在的参与者。 - 参与者为 0 时仍然认为 Phaser 会一直正常工作;默认情况下它会终止。
- 把所有简单等待都改成 Phaser,增加理解和维护成本。
- 忽略中断需求。
arriveAndAwaitAdvance()本身不可中断;需要可中断等待时,可以先arrive(),再使用awaitAdvanceInterruptibly()。
六、AQS 共享模式:CountDownLatch 和 Semaphore 为什么能放行多个线程
6.1 独占模式与共享模式
前面学习 ReentrantLock 时,接触的是 AQS 独占模式:
text
一次通常只有一个线程获取同步状态
其他线程进入队列等待
持有者释放后,唤醒下一个竞争者
CountDownLatch 和 Semaphore 使用的是共享模式:
text
一次可能允许多个线程获取成功
某个共享节点成功后,还可能继续通知后面的共享节点
两者虽然都使用共享模式,但 state 的业务含义不同:
| 工具 | state 的含义 | 获取成功条件 |
|---|---|---|
CountDownLatch |
剩余倒计时 | state == 0 |
Semaphore |
剩余许可证 | 扣减后仍不小于 0 |
6.2 tryAcquireShared 返回值是什么意思
AQS 共享模式的子类会实现:
java
protected int tryAcquireShared(int arg)
返回值语义:
text
小于 0:获取失败,需要排队
等于 0:获取成功,但后续共享传播可能无法继续
大于 0:获取成功,并且后续共享节点也可能成功
CountDownLatch 在 state == 0 时返回 1,代表门闩已经完全打开,后面的等待者也都可以继续。
Semaphore 返回扣减后的剩余许可证数:如果剩余数仍为正,说明其他等待线程也可能继续获取。
6.3 共享唤醒为什么具有传播效果
独占锁释放后,通常让队列中的后继节点重新竞争一次。
共享模式中,一个线程获取成功后,如果同步状态仍允许其他线程通过,就会继续传播唤醒。可以把它理解为:
text
第一个等待者被唤醒并确认可以通过
↓
它发现共享资源仍允许后面的人通过
↓
继续推动后继共享节点检查条件
↓
多个等待线程依次离开同步队列
这正符合:
text
CountDownLatch 归零后,所有 await 线程都能继续
Semaphore 释放多个许可证后,多个线程可能陆续获得许可证
6.4 为什么 CyclicBarrier 和 Phaser 没直接使用 AQS
同步工具是否使用 AQS,取决于状态模型是否合适。
CyclicBarrier 需要管理:
text
当前轮次、剩余参与者、屏障是否损坏、条件等待与整轮唤醒
使用 ReentrantLock + Condition 表达起来更直接。
Phaser 则需要把阶段编号、参与者数、未到达数放进一个组合状态,并支持父子分层,因此使用自己的 CAS 状态和等待队列。
所以不能把"JUC 同步工具"简单等同于"全部基于 AQS"。面试时说清每个类的真实实现,比背统一结论更重要。
七、四种工具怎么选
7.1 核心对比表
| 对比项 | CountDownLatch | CyclicBarrier | Semaphore | Phaser |
|---|---|---|---|---|
| 核心目标 | 等待若干任务完成 | 多个线程到齐再继续 | 限制并发访问数量 | 动态多阶段同步 |
| 谁会等待 | 调用 await 的线程 |
每个调用 await 的参与者 |
未拿到许可证的线程 | 到达并选择等待的参与者 |
| 是否可复用 | 否 | 是 | 许可证可反复获取归还 | 是,多阶段推进 |
| 参与者是否动态变化 | 否 | 通常固定 | 不强调参与者 | 支持注册和注销 |
| 底层实现 | AQS 共享模式 | ReentrantLock + Condition | AQS 共享模式 | CAS 组合状态 + 等待队列 |
| 典型场景 | 主线程等子任务 | 分阶段并行计算 | 下游并发隔离 | 复杂批处理流程 |
7.2 按业务问题选择
业务问题一:主线程等待 5 个初始化任务全部结束。
text
选择 CountDownLatch。
业务问题二:4 个计算线程每轮计算结束后互相等待,共执行 10 轮。
text
参与者固定、流程简单:选择 CyclicBarrier。
参与者可能动态退出或加入:考虑 Phaser。
业务问题三:保护第三方接口,同时最多允许 20 个请求调用。
text
选择 Semaphore,最好配合 tryAcquire 超时与降级。
业务问题四:批处理包含下载、解析、校验、落库,部分参与者中途结束,后面还会增加新任务。
text
选择 Phaser。
7.3 不要为了用工具而用工具
如果只需要等待线程池中几个有返回值的任务,CompletableFuture.allOf 可能更自然;如果要控制线程池自身的任务积压和工作线程数量,应该配置 ThreadPoolExecutor,而不是在外面盲目套一个 Semaphore。
选型的标准不是"哪个类更高级",而是:
text
它是否准确表达业务协作关系
失败和超时能否被处理
后来维护代码的人能否快速看懂
八、实际开发中的通用原则
8.1 等待必须考虑超时
无限等待的风险包括:
text
下游接口卡死
参与线程异常退出
计数或参与者数量配置错误
线程池资源不足,任务一直没有机会执行
因此生产代码优先考虑:
java
latch.await(2, TimeUnit.SECONDS);
barrier.await(2, TimeUnit.SECONDS);
semaphore.tryAcquire(200, TimeUnit.MILLISECONDS);
phaser.awaitAdvanceInterruptibly(phase, 2, TimeUnit.SECONDS);
超时之后还要定义清晰策略:降级、取消、重试、记录告警,或者终止整个阶段。
8.2 中断不能随便吞掉
错误示例:
java
try {
latch.await();
} catch (InterruptedException e) {
// 什么都不做
}
如果当前方法不能继续向上抛异常,至少恢复中断标记:
java
catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
中断通常表示上层希望任务尽快停止。吞掉中断会使线程池关闭、请求取消等机制失效。
8.3 释放动作放 finally,但要确认资源确实获取成功
CountDownLatch.countDown() 一般无条件放在任务的 finally,因为它表达"这个任务无论成功失败都已经结束"。
Semaphore.release() 不同:只有成功获取过许可证,才能归还。因此要使用正确的代码结构或获取标记。
8.4 同步完成不代表业务成功
CountDownLatch 归零,只说明三个任务都结束了,不代表三个任务都成功。
真实项目还要单独收集:
text
每个任务的返回值
每个任务是否异常
哪些结果可以降级
是否需要取消其他任务
同步工具解决的是"线程什么时候继续",不是完整的业务错误处理框架。
8.5 注意线程池饥饿死锁
假设线程池只有两个线程,却提交三个屏障任务:
text
任务 A 占用线程 1,执行 barrier.await
任务 B 占用线程 2,执行 barrier.await
任务 C 还在队列中,无法执行
A 和 B 等 C,C 等空闲线程
这就是典型的线程饥饿死锁。使用屏障类工具时,执行资源必须足以让本轮所有必要参与者获得运行机会。
九、综合 Demo:模拟服务启动编排
下面把 CountDownLatch 和 Semaphore 放在一个小场景中:系统启动时并行加载多个租户配置,但数据库最多允许两个加载任务同时执行;主线程等待所有任务结束后再对外提供服务。
java
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
public class ServiceBootstrapDemo {
public static void main(String[] args) throws InterruptedException {
int tenantCount = 5;
ExecutorService pool = Executors.newFixedThreadPool(5);
Semaphore databaseSlots = new Semaphore(2);
CountDownLatch initialized = new CountDownLatch(tenantCount);
for (int i = 1; i <= tenantCount; i++) {
int tenantNo = i;
pool.execute(() -> {
boolean acquired = false;
try {
acquired = databaseSlots.tryAcquire(
1, TimeUnit.SECONDS);
if (!acquired) {
System.out.println("租户 " + tenantNo + " 获取数据库许可超时");
return;
}
System.out.println("租户 " + tenantNo + " 开始加载配置");
TimeUnit.MILLISECONDS.sleep(300);
System.out.println("租户 " + tenantNo + " 配置加载完成");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (acquired) {
databaseSlots.release();
}
initialized.countDown();
}
});
}
boolean success = initialized.await(3, TimeUnit.SECONDS);
System.out.println(success
? "所有初始化任务已结束,服务开始接收请求"
: "初始化等待超时,服务进入降级状态");
pool.shutdown();
}
}
一种可能的输出:
text
租户 1 开始加载配置
租户 2 开始加载配置
租户 1 配置加载完成
租户 3 开始加载配置
租户 2 配置加载完成
租户 4 开始加载配置
租户 3 配置加载完成
租户 5 开始加载配置
租户 4 配置加载完成
租户 5 配置加载完成
所有初始化任务已结束,服务开始接收请求
这个 Demo 中:
text
线程池:负责复用工作线程和调度任务
Semaphore:限制数据库加载并发最多为 2
CountDownLatch:让主线程等待 5 个初始化任务全部结束
不同工具各自负责一个清晰的职责。
十、高频面试题与参考答案
10.1 CountDownLatch 和 CyclicBarrier 有什么区别
参考回答:
text
CountDownLatch 是一次性倒计时工具,一个或多个线程通过 await 等待,
其他任务通过 countDown 把计数减到 0。countDown 的线程自己不需要等待,
适合主线程等待多个子任务完成。
CyclicBarrier 是循环屏障,参与者调用 await 后会互相等待,全部到齐后一起继续,
并且屏障可以进入下一轮重复使用,适合固定线程的多阶段协作。
底层实现上,CountDownLatch 使用 AQS 共享模式,
CyclicBarrier 主要使用 ReentrantLock 和 Condition。
10.2 CountDownLatch 为什么不能复用
参考回答:
text
CountDownLatch 用 AQS 的 state 保存剩余计数,countDown 只会把 state 递减到 0,
没有把 state 重置为初始值的 API。归零后所有 await 都会直接通过,
因此一个实例只能表示一轮倒计时。
10.3 CountDownLatch 如何保证 countDown 之前的操作对 await 之后可见
参考回答:
text
CountDownLatch 提供内存一致性保证:线程在调用 countDown 之前执行的操作,
happen-before 另一个线程从对应 await 成功返回之后的操作。
底层同步状态通过 AQS 和 volatile/CAS 机制更新,建立了必要的可见性与有序性。
10.4 CyclicBarrier 为什么会抛 BrokenBarrierException
参考回答:
text
CyclicBarrier 强调一组参与者共同完成一轮同步。如果某个等待线程被中断、超时,
屏障被 reset,或者 barrierAction 执行失败,这一轮就无法正常完成,屏障会被标记为 broken。
其他等待者会收到 BrokenBarrierException,从而统一感知本轮协作失败。
10.5 Semaphore 与 ReentrantLock 有什么区别
参考回答:
text
ReentrantLock 通常用于互斥访问,同一时刻由一个线程持有,并且具有持有者语义,
只有持有锁的线程才能正常 unlock。
Semaphore 管理一组许可证,可以允许多个线程同时进入,也没有严格的线程所有权语义,
一个线程获取的许可证理论上可以由另一个线程归还。实际业务仍应保持获取和归还配对。
10.6 Semaphore 能不能当 QPS 限流器
参考回答:
text
Semaphore 主要限制同一时刻的并发数量,而不是单位时间通过的请求数量。
任务执行越快,单位时间内通过的总请求数仍可能很高。
严格控制 QPS 通常需要令牌桶或漏桶等速率限制算法。
10.7 公平 Semaphore 一定完全公平吗
参考回答:
text
公平模式下,阻塞式 acquire 会优先考虑同步队列中等待更久的线程。
但无参 tryAcquire 会直接尝试获取当前许可证,即使已有线程排队,也可能插队成功,
因此不能简单理解为所有方法在任何情况下都绝对公平。
10.8 Phaser 比 CyclicBarrier 灵活在哪里
参考回答:
text
Phaser 支持参与者动态注册和注销,天然支持多个阶段,并能获取阶段编号、
自定义阶段推进和终止逻辑,还支持父子 Phaser 分层。
CyclicBarrier 更适合参与者固定、每轮到齐后继续的简单场景。
10.9 CountDownLatch 和 Semaphore 为什么都使用 AQS 共享模式
参考回答:
text
因为它们都可能允许多个线程通过。CountDownLatch 的 state 归零后,
所有等待线程都能继续;Semaphore 只要还有许可证,也可以让多个线程分别获取成功。
AQS 共享模式支持获取成功后的传播唤醒,正好符合这种语义。
10.10 使用同步工具最需要关注哪些问题
参考回答:
text
第一是超时,避免参与者异常后永久等待;第二是中断处理,不能随意吞掉中断;
第三是资源配对,例如 countDown 放 finally、Semaphore 只在获取成功后 release;
第四是线程池容量,避免所有工作线程都在等待尚未运行的参与者;
第五是业务异常需要单独收集,同步完成不等于业务成功。
十一、源码阅读主线总结
阅读这四个工具的源码,不必一开始钻进每个分支。先抓住下面四条主线。
CountDownLatch
text
构造方法把计数写入 AQS state
await → tryAcquireShared → state 不为 0 就排队
countDown → tryReleaseShared → CAS 把 state 减 1
state 变成 0 → 共享传播唤醒所有等待者
CyclicBarrier
text
await → 获取 ReentrantLock → count 减 1
不是最后一个 → Condition.await
最后一个 → 执行 barrierAction → nextGeneration
signalAll 唤醒本轮线程,并把 count 重置为 parties
Semaphore
text
构造方法把许可证数写入 AQS state
acquire → CAS 扣减许可证
许可证不足 → 进入 AQS 共享队列
release → CAS 增加许可证 → 唤醒等待线程
公平模式获取前增加 hasQueuedPredecessors 判断
Phaser
text
long state 同时记录 phase、parties、unarrived
register → CAS 增加参与者和未到达数
arrive → CAS 减少未到达数
最后一个到达者 → 调用 onAdvance 并推进阶段
阶段变化 → 唤醒等待当前阶段推进的线程
十二、掌握程度自测
如果不看文章能回答下面的问题,第 6 周的主线就掌握了:
CountDownLatch的countDown()为什么通常放在finally中?CountDownLatch归零后为什么不能重新使用?CountDownLatch的state表示什么?CyclicBarrier为什么可以自动进入下一轮?- 一个参与者超时后,其他屏障等待者会发生什么?
- 为什么小线程池配合大参与者数量的屏障可能发生线程饥饿死锁?
Semaphore.acquire()被中断后为什么不能直接release()?Semaphore限制的是并发量还是 QPS?- 公平和非公平 Semaphore 在源码上的核心区别是什么?
Phaser中 party 和 phase 分别表示什么?arrive()与arriveAndDeregister()有什么区别?- 哪两个工具直接使用了 AQS 共享模式?
结语
同步工具类最值得掌握的不是 API 数量,而是业务协作关系:
text
我等一批任务结束
→ CountDownLatch
我们互相等待,到齐再继续
→ CyclicBarrier
资源有限,只能放一部分线程进入
→ Semaphore
任务有多个阶段,成员还可能变化
→ Phaser
最后记住六句话:
text
CountDownLatch 是一次性倒计时,countDown 要放 finally,await 要考虑超时。
CyclicBarrier 是可循环屏障,一个参与者失败可能导致整轮屏障损坏。
Semaphore 限制的是并发数量,获取成功后必须可靠归还许可证。
Phaser 适合动态多阶段协作,简单问题不要过度设计。
CountDownLatch 和 Semaphore 基于 AQS 共享模式,CyclicBarrier 和 Phaser 不是。
同步结束只代表线程可以继续,不代表所有业务都执行成功。
真正能在项目中选对工具、处理超时和异常,并沿关键状态讲清源码,才算真正掌握了这一周的内容。
