1. 为什么需要主动“制造”并发问题?

在日常开发或者多线程学习过程中,很多并发问题难以被发现,因为它们依赖于特定的线程执行时序。这些问题就像定时炸弹,平时安静地潜伏在代码中,一旦遇到高并发场景,就会突然爆发,造成不可预料的后果。

传统的测试方法往往难以稳定复现这类问题,因为现代操作系统的线程调度具有不确定性。我们需要的是一种能够主动制造高并发竞争条件的方法,而CountDownLatch可以提供一个复现问题的环境(不能保证每次百分百复现,需结合无限循环判断异常结果打印的方式来进行复现)。

2. CountDownLatch 简介

CountDownLatch 是Java并发包中的一个同步工具类,它允许一个或多个线程等待一组操作完成。其核心机制是基于一个计数器:计数器初始值为需要等待的线程数,每个线程完成时调用countDown()方法使计数器减1,当计数器变为0时,所有等待的线程被唤醒。

除了常见的同步用途,CountDownLatch在测试中有一个重要应用:​作为“发令枪”,强制让多个线程在同一时刻同时开始执行,从而极大提高线程竞争的概率。

3. 代码复现问题演示

下面我们通过一个典型的转账场景,演示如何使用CountDownLatch来暴露线程安全问题。

3.1 账户类 (非线程安全)

UnsafeAccount.java


public class UnsafeAccount { private int balance; public UnsafeAccount(int initialBalance) { this.balance = initialBalance; } // 非线程安全的转账方法 (问题代码) public synchronized void transfer(UnsafeAccount target, int amount) { // 线程1锁定的是accountA(this), 线程2锁定的是accountB(this) // 两把不同的锁,无法实现互斥 synchronized (this) { // 模拟一些操作耗时,增大并发窗口 try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } if (this.balance >= amount) { this.balance -= amount; //问题点:对目标账户的操作不在同一个锁范围内 target.balance += amount; } } } public int getBalance() { return balance; } }

这个transfer方法看起来简单,但实际上存在严重的线程安全问题:

A、B、C三个账户,余额都是200元,我们用两个线程分别执行两个转账操作:账户A转给账户B 100 元,账户B转给账户C 100 元,最后我们期望的结果应该是账户A的余额是100元,账户B的余额是200元, 账户C的余额是300元。假设线程1执行账户A转账户B的操作,线程2执行账户B转账户C的操作。这两个线程分别在两颗CPU上同时执行,线程1锁定的是账户A的实例(A.this),而线程2锁定的是账户B的实例(B.this),所以这两个线程可以同时进入临界区transfer()。线程1和线程2都会读到账户B的余额为200,导致最终账户B的余额可能是300(线程1后于线程2写B.balance,线程2写的B.balance值被线程1覆盖),可能是100(线程1先于线程2写B.balance,线程1写的B.balance值被线程2覆盖)。

下面的是安全的transfer方法,大家可以最后对比测试


public void transfer(UnsafeAccount target, int amount) { // 定义锁的顺序,避免死锁 UnsafeAccount firstLock = this; UnsafeAccount secondLock = target; if (System.identityHashCode(this) > System.identityHashCode(target)) { firstLock = target; secondLock = this; } // 按顺序获取两把锁 synchronized (firstLock) { synchronized (secondLock) { if (this.balance >= amount) { this.balance -= amount; target.balance += amount; } } } }

3.2 测试类

UnsafeAccountTest.java


import java.util.concurrent.CountDownLatch; public class UnsafeAccountTest { public static void main(String[] args) throws InterruptedException { // 多次运行,建议像我一样放在循环里,观察打印 while (true) { UnsafeAccount accountA = new UnsafeAccount(200); UnsafeAccount accountB = new UnsafeAccount(200); UnsafeAccount accountC = new UnsafeAccount(200); // 创建两个CountDownLatch CountDownLatch startLatch = new CountDownLatch(1); // 发令枪,初始为1 CountDownLatch doneLatch = new CountDownLatch(2); // 等待两个线程完成,初始为2 // 线程1:A 转给 B 100 Thread t1 = new Thread(() -> { try { startLatch.await(); // 等待发令枪响 accountA.transfer(accountB, 100); } catch (InterruptedException e) { e.printStackTrace(); } finally { doneLatch.countDown(); // 完成后计数减1 } }, "T1-A→B"); // 线程2:B 转给 C 100 Thread t2 = new Thread(() -> { try { startLatch.await(); // 等待同一个发令枪 accountB.transfer(accountC, 100); } catch (InterruptedException e) { e.printStackTrace(); } finally { doneLatch.countDown(); // 完成后计数减1 } }, "T2-B→C"); // 启动线程 t1.start(); t2.start(); Thread.sleep(50); // 短暂延迟,确保两个线程都已就绪并在await()处等待 startLatch.countDown(); // 发令枪响,两个线程同时开始转账 doneLatch.await(); // 主线程等待所有转账线程完成 if (accountB.getBalance() != 200) { System.err.println("accountA: " + accountA.getBalance()); System.err.println("accountB: " + accountB.getBalance()); // 关注点 System.err.println("accountC: " + accountC.getBalance()); } } } }

3.3 执行结果分析

运行上述程序,你很可能会看到类似这样的输出(多次循环才能体现):


accountA: 100 accountB: 100 accountC: 300 accountA: 100 accountB: 300 accountC: 300

file-20250910141217978.png

每次循环的结果正确的概率远高于错误的概率,这也是并发问题在生产环境下难以发现、难以排查的原因。

4. 原理解析

4.1 核心机制:计数器与控制逻辑

CountDownLatch 的核心是一个计数器(count)​,其工作机制可以概括为:

  • 初始化​:在创建 CountDownLatch时,需要指定一个初始计数值 NCountDownLatch latch = new CountDownLatch(N);)。这个 N通常表示需要等待完成的任务数量线程数量。​

  • 等待 (await)​​:一个或多个线程可以调用 latch.await()方法。调用后,这些线程会被阻塞,进入等待状态

  • 计数递减 (countDown)​​:其他执行任务的线程在完成自己的任务后,会调用 latch.countDown()方法。每次调用 countDown(),计数器的值都会原子性地减 1​。

  • 释放与继续​:当计数器值减至 0​ 时,所有在 await()方法上阻塞的线程都会被唤醒,从而继续执行后续操作。

4.2 底层实现:基于 AQS 的共享模式

CountDownLatch的线程安全性和同步机制主要是通过 Java 并发框架的核心——AbstractQueuedSynchronizer (AQS)​​ 来实现的。CountDownLatch内部有一个继承自 AQS 的静态内部类 Sync,它使用 AQS 的 ​**state**​ 字段来表示计数器的当前值。

其关键方法在 AQS 中的运作如下:

  • await():调用 sync.acquireSharedInterruptibly(1)。内部会尝试获取共享锁(tryAcquireShared),若计数器 state != 0,则当前线程会进入 AQS 的等待队列并被阻塞。

  • countDown():调用 sync.releaseShared(1)。内部会尝试释放共享锁(tryReleaseShared),通过 ​CAS 循环将 state 减 1。若 state 成功减至 0,则唤醒所有在队列中等待的线程。

4.3 核心特性

理解 CountDownLatch 时,掌握其以下几个关键特性非常重要:

  • 一次性:CountDownLatch 的计数器无法重置。一旦计数器归零,所有 await()调用都会立即返回,再次使用需要创建新的实例

  • 等待与递减分离await()和 countDown()通常由不同的线程调用,实现了等待者任务执行者的分离

  • ​“发令枪”模式:通过设置初始值为 1 的 CountDownLatch (CountDownLatch startLatch = new CountDownLatch(1)),可以让多个线程在 startLatch.await()处等待。主线程调用 startLatch.countDown()后,所有等待线程会同时开始执行,模拟并发场景

5. 总结

CountDownLatch 通过一个简单的计数器机制,高效地解决了线程间的等待/通知问题。其底层依赖于 ​AQS​ 的共享模式来保证线程安全和同步的正确性。核心在于 await() (等待)和 ​countDown()​(通知)的配合使用。

更多推荐