【并发编程】(十六)栅栏CountDownLatch的使用及实现原理

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的效果。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

挥之以墨

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值