Java JUC 同步工具类一次讲透:CountDownLatch、CyclicBarrier、Semaphore、Phaser 与 AQS 共享模式

hello,我是逆境不可逃

并发开发不只是"给共享变量加锁"。很多时候,我们真正需要解决的是:等待一批任务结束、让多个线程到齐后再出发、限制同时访问资源的数量,或者协调多个阶段的任务。JUC 已经为这些场景准备好了成熟的同步工具。

摘要: 本文从实际开发和面试两个角度,系统讲解 CountDownLatchCyclicBarrierSemaphorePhaser。每个工具都包含通俗解释、适用场景、可运行 Demo、参考输出、常见错误和关键源码;最后结合 AQS 共享模式讲清底层原理,并给出工具选型表与高频面试题。

建议标签: JavaJUCCountDownLatchCyclicBarrierSemaphorePhaserAQSJava面试

文章信息

  • 推荐环境:JDK 17
  • 适合读者:掌握 Java 线程和锁的基础,希望应对实际开发与 Java 并发面试的开发者
  • 学习目标:会选型、会使用、能避坑、能沿关键源码讲清底层原理
  • 源码说明:文中的源码按照 OpenJDK 主干逻辑裁剪,省略部分参数校验和非核心分支;不同 JDK 版本的具体代码可能略有差异
  • 输出说明:并发程序的线程执行顺序不固定,文中展示的是一种可能的参考输出

一、为什么有了锁,还需要同步工具类

synchronizedReentrantLock 主要解决的是:

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 常见误区

  1. CountDownLatch 是一次性的,计数到 0 后不能恢复。如果需要重复多轮同步,应考虑 CyclicBarrierPhaser
  2. countDown() 多调用不会变成负数,但这通常意味着业务计数设计有问题。
  3. await() 被中断会抛出 InterruptedException,不要直接吞掉,应恢复中断标记或向上抛出。
  4. 任务没有成功提交时,也要重新考虑计数,否则可能等待一个根本不存在的任务。
  5. 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 常见误区

  1. 参与者数量设置为 3,却只启动两个会调用 await() 的线程,结果两个线程会一直等待。
  2. 把线程池大小设成 2,却把屏障参与者设为 3。两个线程占住线程池等待,第三个任务永远没有线程执行,形成线程饥饿死锁。
  3. 忽略 BrokenBarrierException,误以为其他线程仍能正常进入下一阶段。
  4. barrierAction 中执行耗时远程调用,导致所有线程长时间无法放行。
  5. 认为可复用等于失败后自动恢复。屏障损坏后必须处理本轮失败,必要时再 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 常见误区

  1. 获取许可证后没有在 finally 中归还,最终所有许可证被耗尽。
  2. acquire() 获取失败或被中断后仍然 release(),导致许可证凭空增加。
  3. Semaphore 当成分布式限流器。它只管理当前 JVM 中这个对象的许可证。
  4. 许可证设置过大,把所有压力直接传给下游。
  5. 许可证设置过小且无限等待,造成业务线程大量阻塞。

五、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 同时增加 partiesunarrived

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 常见误区

  1. 注册后忘记 arrive,导致当前阶段永久无法推进。
  2. 任务结束后忘记 arriveAndDeregister,后续阶段一直等待一个已经不存在的参与者。
  3. 参与者为 0 时仍然认为 Phaser 会一直正常工作;默认情况下它会终止。
  4. 把所有简单等待都改成 Phaser,增加理解和维护成本。
  5. 忽略中断需求。arriveAndAwaitAdvance() 本身不可中断;需要可中断等待时,可以先 arrive(),再使用 awaitAdvanceInterruptibly()

六、AQS 共享模式:CountDownLatch 和 Semaphore 为什么能放行多个线程

6.1 独占模式与共享模式

前面学习 ReentrantLock 时,接触的是 AQS 独占模式:

text 复制代码
一次通常只有一个线程获取同步状态
其他线程进入队列等待
持有者释放后,唤醒下一个竞争者

CountDownLatchSemaphore 使用的是共享模式:

text 复制代码
一次可能允许多个线程获取成功
某个共享节点成功后,还可能继续通知后面的共享节点

两者虽然都使用共享模式,但 state 的业务含义不同:

工具 state 的含义 获取成功条件
CountDownLatch 剩余倒计时 state == 0
Semaphore 剩余许可证 扣减后仍不小于 0

6.2 tryAcquireShared 返回值是什么意思

AQS 共享模式的子类会实现:

java 复制代码
protected int tryAcquireShared(int arg)

返回值语义:

text 复制代码
小于 0:获取失败,需要排队
等于 0:获取成功,但后续共享传播可能无法继续
大于 0:获取成功,并且后续共享节点也可能成功

CountDownLatchstate == 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:模拟服务启动编排

下面把 CountDownLatchSemaphore 放在一个小场景中:系统启动时并行加载多个租户配置,但数据库最多允许两个加载任务同时执行;主线程等待所有任务结束后再对外提供服务。

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 周的主线就掌握了:

  1. CountDownLatchcountDown() 为什么通常放在 finally 中?
  2. CountDownLatch 归零后为什么不能重新使用?
  3. CountDownLatchstate 表示什么?
  4. CyclicBarrier 为什么可以自动进入下一轮?
  5. 一个参与者超时后,其他屏障等待者会发生什么?
  6. 为什么小线程池配合大参与者数量的屏障可能发生线程饥饿死锁?
  7. Semaphore.acquire() 被中断后为什么不能直接 release()
  8. Semaphore 限制的是并发量还是 QPS?
  9. 公平和非公平 Semaphore 在源码上的核心区别是什么?
  10. Phaser 中 party 和 phase 分别表示什么?
  11. arrive()arriveAndDeregister() 有什么区别?
  12. 哪两个工具直接使用了 AQS 共享模式?

结语

同步工具类最值得掌握的不是 API 数量,而是业务协作关系:

text 复制代码
我等一批任务结束
    → CountDownLatch

我们互相等待,到齐再继续
    → CyclicBarrier

资源有限,只能放一部分线程进入
    → Semaphore

任务有多个阶段,成员还可能变化
    → Phaser

最后记住六句话:

text 复制代码
CountDownLatch 是一次性倒计时,countDown 要放 finally,await 要考虑超时。
CyclicBarrier 是可循环屏障,一个参与者失败可能导致整轮屏障损坏。
Semaphore 限制的是并发数量,获取成功后必须可靠归还许可证。
Phaser 适合动态多阶段协作,简单问题不要过度设计。
CountDownLatch 和 Semaphore 基于 AQS 共享模式,CyclicBarrier 和 Phaser 不是。
同步结束只代表线程可以继续,不代表所有业务都执行成功。

真正能在项目中选对工具、处理超时和异常,并沿关键状态讲清源码,才算真正掌握了这一周的内容。

相关推荐
遥感知识服务1 小时前
SMAP看每天有多少水,Landsat告诉水最可能出现在哪里:拆解Idai洪水数据驱动预报
python
大尚来也1 小时前
项目上线前必须检查的清单:避开线上重大事故
开发语言
酉鬼女又兒1 小时前
[特殊字符]零基础入门AI:归纳演绎、假设空间、归纳偏好、NFL、过拟合与欠拟合、模型评估选择、超参数、性能度量、混淆矩阵、P-R曲线和F1
人工智能·windows·python·深度学习·安全·机器学习·矩阵
q27551300421 小时前
AS717 Type-C转DP 8K方案外维少 AS717电路图
c语言·开发语言
xlq223222 小时前
高并发服务器day5
java·服务器·数据库
our_times2 小时前
【硬核实战】当机器人走进厨房:Java后端如何重构物联网并发与状态一致性`。
java·重构·机器人
一路向北North2 小时前
Spring AI(5) :对话机器人-会话记忆
java·后端·spring
艾斯特_2 小时前
Function Calling与工具调用:让模型从回答走向执行
人工智能·python·算法·ai