Java 中使用 CountDownLatch 增加线程竞争,帮助复现并发问题

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时,需要指定一个初始计数值 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()​(通知)的配合使用。

相关推荐
郑洁文2 小时前
基于SpringBoot的天气预报系统的设计与实现
java·spring boot·后端·毕设
optimistic_chen3 小时前
【Java EE进阶 --- SpringBoot】Spring DI详解
spring boot·笔记·后端·spring·java-ee·mvc·di
Java水解3 小时前
【MySQL】数据库基础
后端·mysql
中国胖子风清扬3 小时前
Rust 日志库完全指南:从入门到精通
spring boot·后端·rust·学习方法·logback
玉衡子3 小时前
MySQL基础架构全面解析
数据库·后端
郭京京4 小时前
goweb内置的 net/http 包
后端·go
dylan_QAQ4 小时前
Java转Go全过程06-工程管理
java·后端·go
用户4099322502124 小时前
如何用FastAPI玩转多模块测试与异步任务,让代码不再“闹脾气”?
后端·ai编程·trae
考虑考虑4 小时前
Postgerssql格式化时间
数据库·后端·postgresql