1. 为什么需要主动"制造"并发问题?
在日常开发或者多线程学习过程中,很多并发问题难以被发现,因为它们依赖于特定的线程执行时序。这些问题就像定时炸弹,平时安静地潜伏在代码中,一旦遇到高并发场景,就会突然爆发,造成不可预料的后果。
传统的测试方法往往难以稳定复现这类问题,因为现代操作系统的线程调度具有不确定性。我们需要的是一种能够主动制造高并发竞争条件的方法,而CountDownLatch可以提供一个复现问题的环境(不能保证每次百分百复现,需结合无限循环判断异常结果打印的方式来进行复现)。
2. CountDownLatch 简介
CountDownLatch 是Java并发包中的一个同步工具类,它允许一个或多个线程等待一组操作完成。其核心机制是基于一个计数器:计数器初始值为需要等待的线程数,每个线程完成时调用countDown()
方法使计数器减1,当计数器变为0时,所有等待的线程被唤醒。
除了常见的同步用途,CountDownLatch在测试中有一个重要应用:作为"发令枪",强制让多个线程在同一时刻同时开始执行,从而极大提高线程竞争的概率。
3. 代码复现问题演示
下面我们通过一个典型的转账场景,演示如何使用CountDownLatch来暴露线程安全问题。
3.1 账户类 (非线程安全)
UnsafeAccount.java
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方法,大家可以最后对比测试
java
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
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 执行结果分析
运行上述程序,你很可能会看到类似这样的输出(多次循环才能体现):
makefile
accountA: 100
accountB: 100
accountC: 300
accountA: 100
accountB: 300
accountC: 300

每次循环的结果正确的概率远高于错误的概率,这也是并发问题在生产环境下难以发现、难以排查的原因。
4. 原理解析
4.1 核心机制:计数器与控制逻辑
CountDownLatch 的核心是一个计数器(count),其工作机制可以概括为:
-
初始化 :在创建
CountDownLatch
时,需要指定一个初始计数值N
(CountDownLatch 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()
(通知)的配合使用。