1.CountDownLatch概述
CountDownLatch
可以看做是一个包含了阻塞功能的计数器,我们可以在创建它的时候定义一个执行次数,然后在代码中的某处调用阻塞方法来阻塞线程,线程每执行一次就将计数器中的执行次数减1,到计数器减到0时,就可以唤醒阻塞的线程继续执行。
1.1.使用场景
CountDownLatch
可以阻塞一个线程,也可以阻塞多个线程。
- 阻塞一个线程时,可以用在多个线程去跑不同的数据,最后再做一个汇总这样类似的场景,这种场景往往只会调用一次
await()
。 - 阻塞多个线程更多的时候可能是用在模拟多个线程并发的场景,这种场景每个线程都要调用
await()
。
下面模拟了一个多线程查询不同的数据,然后做汇总的过程。
public class CountDownLatchDemo {
public static void test(CountDownLatch latch) {
latch.countDown();
long count = latch.getCount();
System.out.println(Thread.currentThread().getName() +
":查询第" + count * 100 + "到" + (count + 1) * 100 + "行" + "数据");
}
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(5);
for (int i = 0; i < 5; i++) {
new Thread(() -> CountDownLatchDemo.test(latch), "线程" + i).start();
}
latch.await();
System.out.println("子线程查询数据完毕,主线程汇总开始");
}
}
线程0:查询第400到500行数据
线程3:查询第100到200行数据
线程2:查询第200到300行数据
线程1:查询第300到400行数据
线程4:查询第0到100行数据
子线程查询数据完毕,主线程汇总开始
2.CountDownLatch的实现原理
从上面的例子可以看到,CountDownLatch
的API非常简单,重点就在于await()
和countDown()
方法。在看这两个方法的实现之前呢,先看一下创建CountDownLatch
对象时做了什么。
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
this.sync = new Sync(count);
}
调用构造方法时会传入一个计数,然后将这个参数值赋值给AQS
中的state
变量,最终调用的方法如下:
protected final void setState(int newState) {
state = newState;
}
比如上面的例子在构造方法中传入了5,这里的state
就会被赋值为5。
2.1.线程阻塞的实现
在前面两篇笔记中提到了AQS
如何实现独占锁,除了独占模式之外,还有一个共享模式,所有的线程共享state
变量值,通过这个共享的state
变量,await()
方法实现阻塞就很简单了。
线程调用await()
时其实就是加上了一个共享锁,当前AQS
中的state
变量是否为0,不为0则进入队列将自己挂起,直到计数器减到0才会被唤醒。
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
这个三目运算可以得到1或-1的返回值,当state > 0
时返回-1,就会进入挂起线程的方法,代码如下:
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted()) throw new InterruptedException();
// 根据三目运算的返回值判断是否需要挂起
if (tryAcquireShared(arg) < 0)
doAcquireSharedInterruptibly(arg);
}
我们知道线程在AQS
中挂起是需要将自己封装在一个Node
节点中,以便于之后按顺序唤醒,接下来看一看线程加入队列的细节,在看的过程中牢记上面提到了三目运算。
private void doAcquireSharedInterruptibly(int arg) throws InterruptedException {
// 创建新的节点,加入到队列尾,并标记为共享节点
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
// 如果上一个节点是头节点,检查自己是否需要唤醒
if (p == head) {
// 三目运算返回计数器是否已减到0
int r = tryAcquireShared(arg);
if (r >= 0) {
// 计数器为0,替换头节点为自己,这一步是为了依次唤醒后置的节点
setHeadAndPropagate(node, r);
p.next = null; // help GC
failed = false;
return;
}
}
// 如果前置节点是signal状态则将自己挂起,如果不是则将前置节点置为signal然后自旋
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
如果对线程的入队出队,挂起和唤醒的细节不熟悉,可以先看一下之前的笔记(十四)Java可重入互斥锁实现——ReentrantLock详解,上述的代码关键点加上了注释,为了更好的理解这段代码,我们通过前置节点是不是head
节点来将它分为两部分。
前置节点不是head
的时候,只需要依次将节点挂接到队列尾部,并将当前线程挂起就可以了,如下图所示。
前置节点是head
节点会多一个判断,判断当前state
是否已经减为0,如果当前state
还不是0,就和上面的前置节点不是head
的处理方式一致。
如果当前state
是0,自然就没必要阻塞了,除此之外还需要依次唤醒队列中挂起的线程,setHeadAndPropagate(node, r)
方法和线程的唤醒息息相关,会在下面的线程唤醒中去讲。
2.2.线程唤醒的实现
在ReentrantLock
中,阻塞队列中的线程唤醒是在当前持有锁的线程释放锁时,找到head
的后置节点,然后将其唤醒。在CountDownLatch
中也是一样的,每个节点都是被前置节点唤醒的。
但是CountDownLatch
不像ReentrantLock
有一个当前持有锁的线程处于活跃状态,这个活跃状态的线程可以作为唤醒后置节点的起始线程。CountDownLatch
使用的是AQS
的共享模式,这个模式下,进入队列中的线程在满足state
不为0的情况下都会将自己挂起,如果不做其它操作的话,它们是不可能被唤醒的。
那线程的唤醒操作是什么情况下发起的呢?
除了await()
方法,还有一个将计数器依次递减的方法countDown()
,如果一个线程在调用countDown()
时,将state
从1替换为了0,那么它将作为唤醒队列中线程的起始线程,调用releaseShared()
发起唤醒操作。
public final boolean releaseShared(int arg) {
// 如果当前state递减后刚好等于0,则返回true
if (tryReleaseShared(arg)) {
// 唤醒head的后置节点
doReleaseShared();
return true;
}
return false;
}
在doReleaseShared()
会去找head
的后置节点,如果后置节点是signal
就尝试唤醒它。
private void doReleaseShared() {
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
// 头节点取消或被其他线程修改了状态自旋重试,如果没有就唤醒下一个节点。
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
unparkSuccessor(h);
}
else if (ws == 0 &&
// 替换失败表示头节点的状态被其它线程修改了
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue;
}
// 唤醒操作向后传播时会替换头节点,头节点替换后就自旋重试
// 如果当前的头节点没有变化,就没必要做重复操作了,这里就break
if (h == head)
break;
}
}
与独占锁不同的事,独占锁只会唤醒一个后置节点,而共享锁会将唤醒的操作依次往后传递,在unparkSuccessor()
中会唤醒后置节点,我们回到await()
方法中阻塞线程的位置。
private void doAcquireSharedInterruptibly(int arg) throws InterruptedException {
// 省略...
try {
for (;;) {
final Node p = node.predecessor();
// 如果上一个节点是头节点,检查自己是否需要唤醒
if (p == head) {
// 三目运算返回计数器是否已减到0,减到0返回1,没有则返回-1
int r = tryAcquireShared(arg);
if (r >= 0) {
// 计数器为0,替换头节点为自己,这一步是为了依次唤醒后置的节点
setHeadAndPropagate(node, r);
// 省略...
}
被唤醒的线程通过自旋再次进入三目运算判断state
是否已经减到0,此时已经减到0了,会进入到传播唤醒操作的方法setHeadAndPropagate()
。
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head;
setHead(node);
// 此时propagate由三目运算返回1,判断通过
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}
方法最终又进入了doReleaseShared()
,里面再次unparkSuccessor()
唤醒后置节点。
3.总结
总得来说,CountDownLatch
的API是非常简单的,主要分为3个步骤:
- 创建
CountDownLatch
对象,并指定一个计数器值。 - 调用
await()
方法,将需要挂起的线程加入到CLH
中挂起。 - 调用
countDown()
方法,依次减少计数器值,到减到0的时候,从CLH
队列的头节点开始,依次唤醒后置节点,直到所有节点都被唤醒。
它主要可以用在需要使用多线程来查询数据,然后都返回到同一个位置做数据组装的功能上,起到一个类似于fork/join
的效果。