2.5 CountDownLatch
前两个synchronized
和ReentrantLock
都是解决线程安全问题的好手,就像两把宝剑,可以披荆斩棘大杀四方。下面我们来探索java.util.concurrent
包下面解决线程同步问题的功能类。
在使用多线程进行性能测试的过程中,经常需要基于事件、时间点进行线程的同步。例如我们整点抢红包场景、前置数据并发初始化等。我们需要所有线程都到达某一个关键点之后,再进行下一步。在流程类的测试场景中,这种需求尤为常见。
要解决这类问题或者说实现此类需求,我们必然会用到线程同步类。CountDownLatch
是一个相对简单同步工具类,可以实现让一个或多个线程等待直到在其他线程中执行的操作完成后再继续执行。如果你觉得不太好理解,类似的概念就是 JMeter 中的集合点,其含义就是所有线程都到达集合点集合一下,然后再各走各路。CountDownLatch
适合用于一个或多个线程等待其他一组线程完成工作后再继续执行的场景。
CountDownLatch
是通过线程安全的计数器数值判断是否到达集合点,工作流程如下:
(1)首先创建同步对象,并且设置同步数量
(2)任务线程执行任务,完成之后将计数器值减一
(3)等待线程(通常是 main 线程)会阻塞(可以设置超时),直到计数器归零,继续执行后面代码
2.5.1 基础方法
CountDownLatch 的构造方法如下:
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
this.sync = new Sync(count);
}
这个方法只有一个 int 参数 count,如果 count 小于 0,会抛出异常。这个 count 就是需要同步的数量,对应CountDownLatch
工作流的第 1 步。
CountDownLatch
工作流第 2 步,用到的计数器减一的方法如下:
public void countDown() {
sync.releaseShared(1);
}
方法没有参数,直接调用即可,通常会跟try-catch-finally
同时使用,以保障每一个任务线程都会执行countDown()
方法。
CountDownLatch 工作流第 3 步,对应 2 个方法,方法一:
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
方法二:
public boolean await(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
}
方法一可以看做方法二的无限等待版本。
当等待同步的线程执行到这个方法,会阻塞继续执行,直到CountDownLatch
计数器归零或者等待超时。一般来说,不建议新手使用某个无线等待的阻塞方法,但在 Java 性能测试最佳实战中,笔者会推荐使用方法一。原因有两点:一是CountDownLatch
通常是用在前置或者后置数据处理,并发执行时间无法准确估计;二是我们可以通过框架功能设计,规避CountDownLatch
真发生无限等待异常场景,还可以更加灵活控制集合时间。
2.5.2 最佳实战
CountDownLatch 方法较少,使用流程简单,通过下面这个例子,展示CountDownLatch
使用最佳实战。
package org.funtester.performance.books.chapter02.section5;
import java.util.concurrent.CountDownLatch;
/**
* CountDownLatch示例
*/
public class CountDownLatchDemo {
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(3);// 创建一个CountDownLatch实例
for (int i = 0; i < 3; i++) {// 创建3个线程
new Thread(() -> {// 创建一个线程
try {
Thread.sleep(100);// 睡眠100毫秒
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
countDownLatch.countDown();// 计数器减1
}
System.out.println(System.currentTimeMillis() + " 任务完成 " + Thread.currentThread().getName());// 打印日志
}).start();// 启动线程
}
System.out.println(System.currentTimeMillis() + " 等待任务完成 " + Thread.currentThread().getName());// 打印日志
try {
countDownLatch.await();// 等待计数器归零
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(System.currentTimeMillis() + " 等待结束 " + Thread.currentThread().getName());// 打印日志
}
}
在上面的示例中,首先创建了同步数量为 3 的 CountDownLatch 对象,接着创建了 3 个线程,每个线程下面 100 毫秒,然后计数器减一,打印任务完成日志。下面是控制台输出:
1698497720691 等待任务完成 main
1698497720791 任务完成 Thread-0
1698497720791 任务完成 Thread-2
1698497720791 任务完成 Thread-1
1698497720791 等待结束 main
可以看到,main
线程在到达await()
方法后,阻塞了 100 毫秒后,3 个任务均完成,计数器归零,main
线程执行了打印等待结束的代码。
2.5.3 使用场景
CountDownLatch
常用的使用场景如下:
- 线程等待。使用
CountDownLatch
可以让一个或者多个线程一直等待,直到其他线程均完成预定的任务。例如:在并发初始化前置数据场景。 - 发送起止信号。使用
CountDownLatch
功能可以给一组线程发送信号,启动或者结束改组线程。例如:整点抢红包场景。
在使用场景中,CountDownLatch
与java.lang.Thread#sleep(long)
方法有部分重合,有些场景甚至相互替代。这里笔者建议在多线程场景中尽量少使用java.lang.Thread#sleep(long)
方法,相比之下,CountDownLatch
有一下几点优势:
- 更加精准控制线程同步时机。
CountDownLatch
可以精确控制等待的线程数目,而 sleep 只能通过轮询来实现等待,容易出现错误。 - 更好的性能。
CountDownLatch
可以使线程处于等待状态而不是占用 CPU 时间片,sleep 会导致线程不停醒来循环等待。 - 更优雅退出。
CountDownLatch
通过await()
设置超时控制退出等待,而sleep
不行;CountDownLatch
一旦计数器归零,程序会立即退出等待,而sleep
必须等到sheep
结束才行。 - 使用更优雅。
CountDownLatch
的接口简单直接,而 sleep 需要估算时间,设置不当容易造成浪费。 - 代码可读性强。
CountDownLatch
同步逻辑简单易懂,而 sleep 方法若无注释很难理解。
因此在需要等待其他线程完成的场景下,CountDownLatch
是一个更加简单、可靠、安全的选择。
人无完人,类无全能。下面说一下CountDownLatch
的缺点:
- 无法复用。
CountDownLatch
对象创建好之后,就无法重置计数器,无法复用对象。 - 不够灵活。
CountDownLatch
对象创建好之后,就无法增加计数器数值,只能调用方法进行减一,无法应对复杂多线程场景。
总而言之,CountDownLatch
主要应用于一次性的简单等待场景。对于复杂的多线程协调还需要其他更高级的同步工具。
书的名字:从 Java 开始做性能测试 。
如果本书内容对你有所帮助,希望各位不吝赞赏,让我可以贴补家用。赞赏两位数可以提前阅读未公开章节。我也会尝试制作本书的视频教程,包括必要的答疑。
FunTester 原创精华