CountDownLatch源码解读

目录

前言

正文

CountDownLatch使用场景

CountDownLatch简单的使用

CountDownLatch源码解读

CountDownLatch结构和构造方法

await()方法

countDown()方法

总结


前言

目前也是金三银四跳槽找工作的最好时机,可能很多小伙伴在面试中被面试官问到Java并发包方面的问题。比如你对Java并发包的了解有多少?有了解是吧,那你在项目中使用过CountDownLatch吗?使用的场景有哪些?对它的原理知道多少?等等一系列问题。所以特意写一篇关于CountDownLatch的源码解读,帮助大家顺利通过面试。

正文

CountDownLatch使用场景

比如说,在多线程情况下,其中一个或多个线程需要等待其他线程执行完毕才能执行。

比如执行A业务需要先查B业务和C业务,获取到B和C的返回值才能执行A业务,就可以使用到CountDownLatch,虽然也可以使用到传统的join关键字来实现。

CountDownLatch简单的使用

/**
 * @Author liha
 * @Date 2022-02-20 20:49
 * 李哈YYDS
 */
public class CountDownLatchTest {
    
    public static void main(String[] args) {

        final CountDownLatch countDownLatch = new CountDownLatch(2);

        new Thread(()->{
            System.out.println("此线程模拟A业务");
            try {

                // 开始等待B和C业务执行完毕
                countDownLatch.await();

                // A业务开始执行
                TimeUnit.SECONDS.sleep(1);
                System.out.println("A业务执行完毕");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();

        new Thread(()->{
            System.out.println("此线程模拟B业务");

            try {
                // 模拟B业务的执行时间
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("B业务执行完毕");
            countDownLatch.countDown();
        }).start();

        new Thread(()->{
            System.out.println("此线程模拟C业务");

            try {
                // 模拟C业务的执行时间
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("C业务执行完毕");
            countDownLatch.countDown();
        }).start();
        
    }
}

CountDownLatch源码解读_第1张图片

 如果传统的单线程情况下先查B再查C,要花费4秒,使用到多线程+CountDownLatch只需要花费3秒,如果业务更多的话效率会更高,并且建议一定要有一个全局的线程池来管理线程。

CountDownLatch源码解读

CountDownLatch结构和构造方法

先查看到CountDownLatch的一个内部结构

CountDownLatch源码解读_第2张图片

 可以看到也是内部维护了一个Sync对象,Sync对象继承AQS。 所以也是一个传统基于AQS框架的一个技术。并且与ReentrantLock那些技术相比内部方法比较少,比较简答。

// 内部构造方法,new一个Sync传一个计数器值进去。
public CountDownLatch(int count) {
    if (count < 0) throw new IllegalArgumentException("count < 0");
    this.sync = new Sync(count);
}


// 给AQS内部维护的state传值。
Sync(int count) {
    setState(count);
}

对于AQS做一个解释吧,考虑可能有新手

AQS算是Java并发包下的一个框架,很多并发技术都是基于AQS来实现。

AQS内部维护了一个state,一个队列,一个当前线程的引用。

state:因为有些情况是可重入锁所以使用控制state来判断是否还有线程占用锁,内部使用volition和CAS来保证state的原子性。

队列:存放获取锁失败或阻塞的线程。内部是一个双向链表,往往head节点是一个sentinel节点,算是一个标识节点,作用是用来唤醒sentinel的next节点的线程。

await方法:线程休眠,当state为0被唤醒

countDown方法:计数器-1,也就是state减1,当state为0时,执行唤醒被休眠的线程。

await()方法

先看到await()方法。

CountDownLatch源码解读_第3张图片CountDownLatch源码解读_第4张图片

CountDownLatch源码解读_第5张图片

 构造方法将计数器的值赋值给state,所以这里肯定是-1,进入到doAcquireSharedInterruptibly()方法中

// 首先我们先要明白await()方法多个线程都能使用,目的是休眠线程,等待计数器为0
private void doAcquireSharedInterruptibly(int arg)
    throws InterruptedException {

    // Node.SHARED 代表共享节点,共享节点就代表当线程被唤醒时会唤醒下一个等待的共享节点。
    // 将当前线程节点加入到AQS内部维护的队列中,如果内部队列还未初始化就执行初始化操作
    // 具体的添加节点或者初始化操作方法看最下面的代码
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {

        // 开始自旋
        for (;;) {

            // 获取到当前线程节点的prev节点,也就是当前节点的上一个节点
            final Node p = node.predecessor();

            // 如果上一个节点是sentinel节点,因为head是指向于sentinel节点的,就达标可以尝试获取锁
            if (p == head) {

                // 尝试获取到锁,前面截图过这个方法的实现,也就是判断state的值是不是0。
                int r = tryAcquireShared(arg);

                // state如果不为0就为-1,所以进不了这边,如果为0了就是计数器已经都释放了。
                if (r >= 0) {
        
                    // 将head指向于当前线程节点,并且获取到当前线程节点的next节点,也就是下一个节点
                    // 如果下一个节点是shared节点就唤醒。
                    setHeadAndPropagate(node, r);

                    // 将上一个节点的next指向于null,此时head也不指向于他,所以已经被丢弃出去。
                    p.next = null; // help GC
                    failed = false;
                    return;
                }
            }

            // shouldParkAfterFailedAcquire()将上一个节点的状态位改成-1
            // parkAndCheckInterrupt()将当前线程park休眠让出cpu的占用。
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}



private Node addWaiter(Node mode) {
    // 创建出当前节点,并且当前节点是共享节点
    Node node = new Node(Thread.currentThread(), mode);

    // tail是aqs外部队列指向队列最后一位的全局变量,未初始化的情况下肯定为null,已经初始化的话就获取到最后一位。
    Node pred = tail;
    if (pred != null) {

        // 因为队列是一个双向链表,所以prev就把当前节点的上一个节点指向最后一个节点,也就是插入到最后一位
        node.prev = pred;

        // 把aqs尾部的tail全局变量指向到当前节点,也就是代表当前节点是最后一位。
        if (compareAndSetTail(pred, node)) {

            // 此时的pred是之前的最后一位的引用,所以把当前节点的前一位的next节点指向到当前节点
            pred.next = node;
            return node;
        }
    }

    // 初始化操作。
    // 不追进去了,这里大概讲解一下。
    // 创建一个sentinel节点,将sentinel节点的waitState状态改为-1,并且将AQS维护的head全局变量
    // 指向sentinel节点,并且将sentinel节点的next指向到当前节点,当前节点的prev指向到sentinel节点
    // 其实也就是一个很普通的双向链表操作。
    enq(node);
    return node;
}

doAcquireSharedInterruptibly()方法中,将当前线程进入到park之前还是尝试了几次获取到锁,如果几次尝试并没有成功,计数器并没有减到0,就会进入到休眠状态,让出对cpu占用。

目前内部的队列的结构如下

CountDownLatch源码解读_第6张图片

countDown()方法

再看到计数器减一的方法

public void countDown() {
    sync.releaseShared(1);
}


public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) {
        doReleaseShared();
        return true;
    }
    return false;
}

先看到if判断中的tryReleaseShared()方法。

protected boolean tryReleaseShared(int releases) {
    // Decrement count; signal when transition to zero
    for (;;) {
    
        // 获取到AQS内部维护的state状态位,state状态位是CountDownLatch构造方法传进去的。
        int c = getState();

        // 注意这里是cas+死循环,所以是多线程来改变state状态位,所以可能抢不到的线程要自旋来该状态
        // 并且当状态位为0的时候就代表CountDownLatch的计数器已经为0了,就达标其他线程通过下面的
        // cas操作将state改成为0执行其他语句去了。  所以没抢到线程这里就已经没事可干了。
        if (c == 0)
            return false;

        // 状态位预减1,注意这里是预减,因为多线程的情况下还要cas来保证原子性。
        int nextc = c-1;
        
        //cas操作,c是预期值,nextc是改变值,如果在并发的情况下为false的就继续自旋,为true的就判断是否状态为0,为0就要执行唤醒等待线程
        if (compareAndSetState(c, nextc))
            return nextc == 0;
    }
}

当state状态位为0的时候就执行到doReleaseShared()方法。

    // 能进来
    private void doReleaseShared() {
        // 自旋
        for (;;) {

            // 获取到head的指向节点,head也有可能为null,因为并没有线程在await
            Node h = head;

            // 判断是否有线程在await
            if (h != null && h != tail) {

                // 能进来的话,就代表head和tail有指向,并且head指向的sentinel节点
                int ws = h.waitStatus;

                // 在park之前将sentinel节点的waitStatus改为SIGNAL了
                if (ws == Node.SIGNAL) {

                    // 这里考虑为什么要cas?
                    // 因为来await中的操作,显示初始化head节点,和sentinel节点之类的
                    // 初始化后才通过cas将sentinel节点的waitState改成SIGNAL
                    // 如果失败就自旋重试。
                    if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                        continue;            // loop to recheck cases
                    
                    // 这里是上面的cas操作成功了,就唤醒head指向的节点的next节点。
                    unparkSuccessor(h);
                }

                // PROPAGATE这个标志的修改我翻阅了帖子说是解决bug,但是全局就这里出现了。
                // 后面等我了解了bug以后会写篇帖子来讲解。
                else if (ws == 0 &&
                         !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                    continue;                // loop on failed CAS
            }

            // 这里是多线程情况下预防await()方法对head和tail和队列初始化了。
            if (h == head)                   // loop if head changed
                break;
        }
    }

这里唤醒以后会进入到唤醒线程的park()方法处。

CountDownLatch源码解读_第7张图片

 被唤醒后就查看是否需要继续唤醒当前节点的next节点。并且对链表和head和tail做一些处理。入地就是让当前节点的prev节点也就是sentinel节点指向为null,被gc回收。

被唤醒后就可以正常执行当前线程的业务逻辑了。

总结

总体来说还是比较简单,建议大家debug来调试追一下,结合博主的帖子中代码中的注释来理解。

最后如果本帖对大家有帮助,希望大家点赞+关注!后续一直会出各种源码的解读!

你可能感兴趣的:(源码解读,juc包系列,java,juc,后端,面试,数据结构)