并发 11 · 同步工具类

秒杀活动零点开始之前,系统通常要做一轮"预热":库存服务要把商品库存加载进 Redis,优惠券服务要初始化好发券规则,风控服务要拉取好黑名单------主线程必须等这几个子系统都准备好,才能把"允许下单"的开关打开 。这是一个典型的"等待多个任务全部完成"的协作场景,靠前面几篇讲的锁或者 BlockingQueue 都不太顺手,因为这里的诉求不是互斥、也不是传递数据,而是线程之间的节奏协调

JDK 在 java.util.concurrent 包里专门准备了一组"同步工具类"来解决这类节奏协调问题:CountDownLatchCyclicBarrierSemaphorePhaserExchanger。它们和第 5、6 篇讲的锁不是一回事------锁解决的是"同一时刻谁能进临界区",这几个工具解决的是"线程之间谁等谁、等到什么时候一起走"。这一篇把它们挨个拆开看,重点关注每一个工具适合什么场景、和相邻工具的边界在哪里。

目录

  1. CountDownLatch:一次性的门闩
  2. CyclicBarrier:可重复使用的栅栏
  3. 两者的边界:门闩等外部事件,栅栏等彼此到达
  4. Semaphore:控制并发访问数量的信号量
  5. Phaser:更灵活的多阶段协作(简要了解)
  6. Exchanger:两个线程之间的数据交换
  7. 实战:秒杀系统预热+分片处理+限流的组合
  8. 选型小结

一、CountDownLatch:一次性的门闩

CountDownLatch(倒计时门闩)的用法非常直白:用一个计数器初始化,多个线程各自完成任务后调用 countDown() 让计数器减一,等待的线程调用 await() 阻塞,直到计数器归零才被放行。

java 复制代码
public class SeckillWarmup {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(3);   // 3 个子系统要初始化
        AtomicReference<Throwable> failure = new AtomicReference<>();

        new Thread(() -> {
            try {
                loadStockToRedis();
            } catch (Throwable t) {
                failure.compareAndSet(null, t);
            } finally {
                latch.countDown();  // 成功失败都必须减一,否则等待方可能永久阻塞
            }
        }).start();

        new Thread(() -> {
            try {
                initCouponRules();
            } catch (Throwable t) {
                failure.compareAndSet(null, t);
            } finally {
                latch.countDown();
            }
        }).start();

        new Thread(() -> {
            try {
                loadRiskBlacklist();
            } catch (Throwable t) {
                failure.compareAndSet(null, t);
            } finally {
                latch.countDown();
            }
        }).start();

        latch.await();              // 主线程阻塞,直到计数器归零
        if (failure.get() != null) {
            throw new IllegalStateException("预热失败", failure.get());
        }
        System.out.println("预热完成,开闸放行");
        openSeckillGate();
    }
}

底层实现直接复用了第 5 篇讲过的 AQS 共享模式 :内部维护一个 Sync extends AbstractQueuedSynchronizerstate 就是那个计数器。countDown() 本质是 releaseShared(),每次把 state 减一,减到 0 时唤醒所有等待线程;await() 本质是 acquireSharedInterruptibly()state 不为 0 就把当前线程挂进 AQS 的等待队列。这也解释了为什么它天然支持"多个线程同时 await()"------共享模式下,一个唤醒动作可以级联唤醒队列里所有等待的节点,而不像独占模式一次只放一个。

CountDownLatch 有个容易被忽略但很关键的限制:计数器只能减、不能重置,减到 0 之后这个门闩就永久保持开放 ,后续 await() 会直接通过;想开始一轮新的计数必须重新 new 一个。这决定了它适合的是"一次性"的等待场景------像上面这种应用启动预热,天然只需要发生一次。

示例里还有一条生产代码必须守住的规则:countDown() 要放在 finally 中,同时用单独的失败通道把异常传回等待方。 只放 finally 能避免永久等待,但如果不记录失败,主线程仍可能误把"三个任务都结束了"当成"三个任务都成功了"。

二、CyclicBarrier:可重复使用的栅栏

如果预热逻辑不是"启动一次",而是要反复出现 ------比如秒杀系统把海量的扣库存请求分成一批一批处理,每一批里的所有线程都必须先处理完当前这批,才能一起进入下一批 (常见于分片计算、批量任务分阶段执行)------这种"周期性汇合点"就该用 CyclicBarrier(循环栅栏)了。

java 复制代码
public class BatchStockDeduction {
    public static void main(String[] args) {
        int workerCount = 4;
        CyclicBarrier barrier = new CyclicBarrier(workerCount,
            () -> System.out.println("本批次全部处理完毕,进入下一批"));  // 栅栏放开时执行一次
        AtomicBoolean cancelled = new AtomicBoolean();

        for (int i = 0; i < workerCount; i++) {
            int workerId = i;
            new Thread(() -> {
                for (int batch = 0; batch < 10 && !cancelled.get(); batch++) {
                    try {
                        processBatch(workerId, batch);   // 处理当前批次分配到的任务
                        if (cancelled.get()) break;
                        barrier.await();              // 等其余3个线程也处理完这一批
                    } catch (InterruptedException e) {
                        cancelled.set(true);
                        barrier.reset();
                        Thread.currentThread().interrupt();
                        break;
                    } catch (BrokenBarrierException e) {
                        cancelled.set(true);
                        break;                        // 栅栏已经损坏,继续 await 只会再次失败
                    } catch (RuntimeException e) {
                        cancelled.set(true);
                        barrier.reset();              // 自己提前退出时,唤醒正在等这一批的同伴
                        throw e;
                    }
                }
            }).start();
        }
    }
}

关键区别在实现上:CyclicBarrier 不是基于 AQS ,而是靠一把 ReentrantLock 配合 Condition 手写的(回顾第 6 篇)。每个线程调用 await() 时,先 lock(),把计数减一,如果减到 0 就执行可选的 Runnable(就是上面例子里那个"进入下一批"的打印),然后 condition.signalAll() 唤醒所有等待者,同时把计数器重置回初始值 ------这就是"循环"(Cyclic)的含义,用完一次自动复位,可以无限次重复使用。如果计数没减到 0,当前线程就 condition.await() 挂起等待。

三、两者的边界:门闩等外部事件,栅栏等彼此到达

CountDownLatchCyclicBarrier 长得很像------都是"等到某个数量才放行"------但语义上有一条清晰的分界线,容易在面试或者选型时搞混:

维度 CountDownLatch CyclicBarrier
等待的对象 一个或多个外部事件完成 一组彼此协作的线程互相到达同一个点
谁调用哪个方法 完成任务的线程调 countDown(),等待的线程调 await()------两者可以是完全不同的线程集合 所有参与协作的线程都调用同一个 await()------等待者和被等待者是同一批线程
能否重复使用 不能,减到 0 即失效 能,放行后自动重置计数
到达后的动作 无内置回调 可传入一个 Runnable,最后一个到达的线程触发,在放行前执行

用一句话记:门闩(Latch)适合"1 个或多个线程等另外几个线程把活干完"(如主线程等子系统初始化);栅栏(Barrier)适合"一组同伴互相等对方走到同一步,再一起往下走"(如批量任务分阶段推进)。 秒杀系统的启动预热天然是前者,分片批处理天然是后者,选错了工具虽然能通过一些变通用法凑合实现,但语义会很扭曲。

四、Semaphore:控制并发访问数量的信号量

前两个工具解决的是"等待",Semaphore(信号量)解决的是另一类问题:限制同一时刻能有多少个线程同时访问某个资源

它的模型很直观:维护一定数量的"许可"(permit),线程访问资源前先 acquire() 拿一个许可(没有许可就阻塞等待),用完之后 release() 归还许可。

java 复制代码
public class SeckillRateLimiter {
    // 假设数据库连接池只能扛住 20 个并发扣库存操作,用信号量限流保护它
    private final Semaphore semaphore = new Semaphore(20);

    public void deductStock(Long productId) {
        try {
            semaphore.acquire();          // 拿不到许可就在这里排队等
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return;                       // 没拿到许可,不能执行 release()
        }

        try {
            doDeductStock(productId);     // 真正操作数据库
        } finally {
            semaphore.release();          // 只归还已经成功拿到的许可
        }
    }
}

底层同样是基于 AQS 的共享模式 (和 CountDownLatch 用的是同一套家族),state 表示当前剩余的许可数量。acquire()tryAcquireShared() 失败则入队等待,release() 是增加许可后唤醒等待队列。

release() 必须和一次已经成功完成的 acquire() 配对。若线程在等待许可时被中断,acquire() 会直接抛异常,此时不能进入释放逻辑,否则会凭空增加许可、悄悄放宽并发上限。

一个常被拿来类比又容易搞混的点:如果把许可数设成 1,Semaphore 看起来就像一把互斥锁 ,但和 ReentrantLock 有本质区别------Semaphore 没有"持有者"概念,不具备可重入性,A 线程 acquire() 拿到的许可,可以由 B 线程 release() 归还 ,这在锁的语义里是不允许的(锁必须由持有它的线程自己释放)。这个特性反过来也是它的价值所在:Semaphore 表达的从来不是互斥关系,而是"资源配额"------无论是限制数据库连接数、限制同时下载的文件数,还是给下游接口做限流保护,本质都是"这批资源总量有限,谁先申请到谁先用,用完还回去",这跟"谁拿到锁就必须谁释放"是两种不同的心智模型。

五、Phaser:更灵活的多阶段协作(简要了解)

JDK 7 引入的 Phaser 可以看作 CyclicBarrier 的加强版,解决的是同一类"多阶段协作"问题,但补上了 CyclicBarrier 的两个短板:

  • 参与者数量可以动态增减。 CyclicBarrier 的参与线程数在构造时就固定死了;Phaser 支持运行期通过 register()/deregister() 动态调整参与者数量------比如一个批处理任务,中途可能有新的工作线程加入或者提前退出。
  • 支持分层(多级 Phaser 树)。 大量参与者时,单个 Phaser 内部同步的开销会变高,可以把参与者组织成一棵树,每层内部先同步,再逐层向上汇聚,降低整体的同步竞争。

代价是 API 比 CyclicBarrier 复杂不少(arrive()arriveAndAwaitAdvance()awaitAdvance() 等一堆方法,还要理解"阶段号"这个概念)。实际业务代码里 Phaser 出现的频率并不高 ------多数分阶段同步的需求用 CyclicBarrier 就够了,只有真的遇到参与者数量会动态变化的场景才值得多学一层 Phaser 的复杂度。这里知道它存在、明白它解决什么问题即可,不做源码级展开。

六、Exchanger:两个线程之间的数据交换

Exchanger 是这几个工具里最小众的一个,只解决一件很窄的事:让恰好两个线程在同一个点上,各自把手里的数据交给对方

java 复制代码
Exchanger<List<Order>> exchanger = new Exchanger<>();

// 线程A:填充数据的一方
new Thread(() -> {
    List<Order> buffer = fillOrderBuffer();
    try {
        List<Order> partnerBuffer = exchanger.exchange(buffer);  // 等待线程B,交换后拿到对方的数据
        process(partnerBuffer);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}).start();

// 线程B:另一方
new Thread(() -> {
    List<Order> buffer = fillAnotherBuffer();
    try {
        List<Order> partnerBuffer = exchanger.exchange(buffer);  // 等待线程A,交换后拿到对方的数据
        process(partnerBuffer);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}).start();

exchange() 是双向阻塞的------先到的线程会一直等,直到另一个线程也调用 exchange(),两者才同时完成交换并各自继续往下走 ,这跟第 10 篇讲的 SynchronousQueue 的"面对面交接"神似(实际上 SynchronousQueue 内部的等待/传递机制和 Exchanger 的实现思路一脉相承)。它适合的是那种两个角色分工明确、需要周期性互换缓冲区的场景,比如一个线程专门负责填充数据、另一个线程专门处理数据,用交换而不是加锁复制的方式减少数据拷贝开销。这个场景在日常业务代码里确实不算常见,了解思路即可。

七、实战:秒杀系统预热+分片处理+限流的组合

把本篇讲的三个主力工具(CountDownLatchCyclicBarrierSemaphore)串起来看一个更完整的秒杀系统骨架:

java 复制代码
public class SeckillSystem {
    private final Semaphore dbSemaphore = new Semaphore(20);  // 保护数据库的并发限流

    public void start() throws InterruptedException {
        // 第一阶段:CountDownLatch ------ 等待所有子系统初始化完成
        CountDownLatch warmupLatch = new CountDownLatch(3);
        AtomicReference<Throwable> warmupFailure = new AtomicReference<>();
        ExecutorService warmupPool = Executors.newFixedThreadPool(3);
        submitWarmup(warmupPool, warmupLatch, warmupFailure, this::loadStockToRedis);
        submitWarmup(warmupPool, warmupLatch, warmupFailure, this::initCouponRules);
        submitWarmup(warmupPool, warmupLatch, warmupFailure, this::loadRiskBlacklist);

        try {
            warmupLatch.await();
        } catch (InterruptedException e) {
            warmupPool.shutdownNow();
            throw e;
        } finally {
            warmupPool.shutdown();
        }
        if (warmupFailure.get() != null) {
            throw new IllegalStateException("系统预热失败", warmupFailure.get());
        }
        System.out.println("预热完成,开始处理请求");

        // 第二阶段:CyclicBarrier ------ 分批处理请求,每批同步一次
        int workerCount = 4;
        CyclicBarrier batchBarrier = new CyclicBarrier(workerCount,
            () -> System.out.println("本批处理完毕,统计当前剩余库存"));
        AtomicBoolean cancelled = new AtomicBoolean();

        for (int i = 0; i < workerCount; i++) {
            new Thread(() -> {
                for (int batch = 0; batch < 100 && !cancelled.get(); batch++) {
                    // 第三阶段:Semaphore ------ 每个具体的扣库存操作前先限流
                    try {
                        dbSemaphore.acquire();
                        try {
                            deductStockForBatch(batch);
                        } finally {
                            dbSemaphore.release();    // 只在 acquire 成功后进入这层 finally
                        }

                        if (cancelled.get()) break;
                        batchBarrier.await();         // 等其他worker也处理完这一批,再一起进下一批
                    } catch (InterruptedException e) {
                        cancelled.set(true);
                        batchBarrier.reset();         // 若中断发生在 acquire 阶段,也要放开其他等待者
                        Thread.currentThread().interrupt();
                        break;
                    } catch (BrokenBarrierException e) {
                        cancelled.set(true);
                        break;
                    } catch (RuntimeException e) {
                        cancelled.set(true);
                        batchBarrier.reset();         // 业务异常退出时避免同伴永久卡在栅栏
                        throw e;
                    }
                }
            }).start();
        }
    }

    private void submitWarmup(ExecutorService pool,
                              CountDownLatch latch,
                              AtomicReference<Throwable> failure,
                              Runnable task) {
        pool.execute(() -> {
            try {
                task.run();
            } catch (Throwable t) {
                failure.compareAndSet(null, t);
            } finally {
                latch.countDown();
            }
        });
    }
}

三个工具在这里各司其职:CountDownLatch 管"启动前的一次性等待",CyclicBarrier 管"处理过程中反复出现的阶段汇合",Semaphore 管"任意时刻别让并发量把数据库压垮"------它们互不冲突,可以在同一个系统里叠着用,各自解决自己那一层的协调问题。

八、选型小结

场景 工具 关键理由
等待多个一次性任务全部完成再继续 CountDownLatch 一次性门闩,基于AQS共享模式,减到0永久放行
一组线程反复地"互相等对方到达再一起走" CyclicBarrier 可重复使用,锁+Condition实现,支持到达后回调
限制同一时刻并发访问某资源的线程数量 Semaphore 许可无持有者概念,表达资源配额而非互斥
参与者数量会动态变化的多阶段协作 Phaser 支持register/deregister动态增减+分层降低竞争
恰好两个线程之间周期性交换数据 Exchanger 双向阻塞的面对面数据交换,减少复制开销

一条主线贯穿全篇:这几个工具解决的都不是"互斥"问题(那是锁的地盘),而是"线程之间怎么排节奏"------谁等谁、等到什么条件、要不要重复等------分辨清楚这一点,选型时就不会纠结。

带走两句话:① 分不清用 CountDownLatch 还是 CyclicBarrier 时,问自己"等待者和被等待者是不是同一批线程"------不是同一批,用门闩;是同一批互相等,用栅栏。② Semaphore 不是锁的替代品,它表达的是资源配额,release() 不要求必须是 acquire() 的那个线程,这个特性用对了是灵活性,用错了是隐藏的 bug 来源。

下一篇,我们进入 ThreadLocal ------它和这一篇的思路完全相反:不是让多个线程协作,而是让每个线程拥有自己独立的一份数据,彼此互不干扰 。会讲清楚它的实现原理、经典的内存泄漏问题从哪来,以及 InheritableThreadLocal、阿里开源的 TransmittableThreadLocal 分别解决了什么场景。

相关推荐
chuan.bai7 小时前
Java RAG 实战(第 11 篇):RAG 知识工作台网页
java·开发语言·人工智能
0x5310 小时前
网站通信(一)
java
Terra.K10 小时前
Java异常学习[特殊字符]
java·开发语言·学习
葡萄城技术团队11 小时前
InfluxDB 2\.x 深度解析:核心架构、Flux 函数与制造业落地指南(三)
java·开发语言·架构
Super 含11 小时前
Android 启动优化(五):线程、GC 与 IO 为什么会拖慢启动?
java·服务器·数据库
counting money11 小时前
Java IO流详解:从InputStream到文件操作实战
java·开发语言·python
程序员小八77712 小时前
上海百度B端java后端日常实习一面
java·开发语言
mldong13 小时前
零依赖的秘密:8 大 SPI 设计
java·架构