CountDownLatch 原理

一、CountDownLatch 原理


1、CountDownLatch在多线程并发编程中充当一个计时器的功能,并且内部维护一个count的变量,并且其操作都是原子操作,该类主要通过countDown()和await()两个方法实现功能的,首先通过建立CountDownLatch对象,并且传入参数即为count初始值。


2、如果一个线程调用了await()方法,那么这个线程便进入阻塞状态,并进入阻塞队列。如果一个线程调用了countDown()方法,则会使count-1;当count的值为0时,这时候阻塞队列中调用await()方法的线程便会逐个被唤醒,从而进入后续的操作。比如下面的例子就是有两个操作,一个是读操作一个是写操作,现在规定必须进行完写操作才能进行读操作。所以当最开始调用读操作时,需要用await()方法使其阻塞,当写操作结束时,则需要使count等于0。因此count的初始值可以定为写操作的记录数,这样便可以使得进行完写操作,然后进行读操作。


二、构造方法:CountDownLatch

内部也是有个Sync类继承了AQS,所以CountDownLatch类的构造方法就是调用Sync类的构造方法,然后调用setState()方法设置AQS中state的值


三、方法:countDown()

这个方法会对state值减1,会调用到AQS中releaseShared()方法,目的是为了调用doReleaseShared()方法,这个是AQS定义好的释放资源的方法,而tryReleaseShared()则是子类实现的,可以看到是一个自旋CAS操作,每次都获取state值,如果为0则直接返回,否则就执行减1的操作,失败了就重试,如果减完后值为0就表示要释放所有阻塞住的线程了,也就会执行到AQS中的doReleaseShared()方法


1、线程进入 countDown() 完成计数器减一(释放锁)的操作

      public void countDown() {
          sync.releaseShared(1);
      }
      public final boolean releaseShared(int arg) {
          // 尝试释放共享锁
          if (tryReleaseShared(arg)) {
              // 释放锁成功开始唤醒阻塞节点
              doReleaseShared();
              return true;
          }
          return false;
      }


2、更新 state 值,每调用一次,state 值减一,当 state -1 正好为 0 时,返回 true

      protected boolean tryReleaseShared(int releases) {
          for (;;) {
              int c = getState();
              // 条件成立说明前面【已经有线程触发唤醒操作】了,这里返回 false
              if (c == 0)
                  return false;
              // 计数器减一
              int nextc = c-1;
              if (compareAndSetState(c, nextc))
                  // 计数器为 0 时返回 true
                  return nextc == 0;
          }
      }
  • a、获取当前状态:调用 getState() 获取当前计数器的值。

  • b、检查计数器是否为零:如果计数器已经为零,则返回 false,表示不需要唤醒等待的线程。

  • c、减少计数器:将计数器减一,得到新的计数器值 nextc。

  • d、CAS 操作:使用 compareAndSetState(c, nextc) 尝试将计数器的值从 c 更新为 nextc。如果成功,则返回 nextc == 0,表示计数器是否减到零。


3、state = 0 时,当前线程需要执行唤醒阻塞节点的任务

   private void doReleaseShared() {
          for (;;) {
              Node h = head;
              // 判断队列是否是空队列
              if (h != null && h != tail) {
                  int ws = h.waitStatus;
                  // 头节点的状态为 signal,说明后继节点没有被唤醒过
                  if (ws == Node.SIGNAL) {
                      // cas 设置头节点的状态为 0,设置失败继续自旋
                      if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                          continue;
                      // 唤醒后继节点
                      unparkSuccessor(h);
                  }
                  // 如果有其他线程已经设置了头节点的状态,重新设置为 PROPAGATE 传播属性
                  else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                      continue;
              }
              // 条件不成立说明被唤醒的节点非常积极,直接将自己设置为了新的head,
              // 此时唤醒它的节点(前驱)执行 h == head 不成立,所以不会跳出循环,会继续唤醒新的 head 节点的后继节点
              if (h == head)
                  break;
          }
   }

小结:当线程使用 countDown() 方法时,其实是使用了 tryReleaseShared() 方法以 CAS 的操作来减少 state ,直至 state 为 0 ,进而释放锁资源,唤醒后续节点。

  • a、判断队列是否是空队列。

  • b、检查头节点的状态:如果头节点的状态为 Node.SIGNAL,则表示需要唤醒后续节点。

  • c、CAS 操作:使用 compareAndSetWaitStatus(h, Node.SIGNAL, 0) 尝试将头节点的状态从 Node.SIGNAL 更新为 0。如果成功,则调用 unparkSuccessor(h) 唤醒后续节点。

  • d、检查头节点是否变化:如果头节点没有变化,则退出循环。


在开始分析 doReleaseShared() 之前,我们先来补全一下 AQS 中 waitStatus 的状态说明

  • 初始化状态:0,表示当前节点在同步队列中,等待获取锁;

  • CANCELLED:1,表示当前节点取消获取锁;

  • SIGNAL:-1,表示后续节点等待当前节点唤醒;

  • CONDITION:-2,表示当前线程正在条件等待队列中;

  • PROPAGATE:-3,共享模式,前置节点唤醒后续节点后,唤醒操作无条件传播下去;


四、方法:await()


1、线程调用 await() 等待其他线程完成任务:支持打断

    public void await() throws InterruptedException {
       sync.acquireSharedInterruptibly(1);
    } 


2、AbstractQueuedSynchronizer#acquireSharedInterruptibly方法

   public final void acquireSharedInterruptibly(int arg) throws InterruptedException {

            // 判断线程是否被打断,抛出打断异常
            if (Thread.interrupted())
                throw new InterruptedException();

            // 尝试获取共享锁,条件成立说明 state > 0,此时线程入队阻塞等待,等待其他线程获取共享资源
            // 条件不成立说明 state = 0,此时不需要阻塞线程,直接结束函数调用
            if (tryAcquireShared(arg) < 0)
                doAcquireSharedInterruptibly(arg);
  }
  • a、检查中断状态:首先检查当前线程是否被中断,如果被中断则抛出 InterruptedException。

  • b、尝试获取共享锁:调用 tryAcquireShared(arg) 方法尝试获取共享锁。在 CountDownLatch 中,tryAcquireShared 的实现是检查计数器是否为零,如果为零则返回 1,否则返回 -1。

  • c、如果获取锁失败:如果 tryAcquireShared 返回 -1,表示当前计数器不为零,调用 doAcquireSharedInterruptibly(arg) 方法将当前线程加入等待队列并阻塞。


3、线程进入 AbstractQueuedSynchronizer#doAcquireSharedInterruptibly 函数阻塞挂起,等待 latch 变为 0:

   private void doAcquireSharedInterruptibly(int arg) throws InterruptedException {

            // 将调用latch.await()方法的线程 包装成 SHARED 类型的 node 加入到 AQS 的阻塞队列中
            final Node node = addWaiter(Node.SHARED);

            boolean failed = true;

            try {
                for (;;) {
                    // 获取当前节点的前驱节点
                    final Node p = node.predecessor();
                    // 前驱节点时头节点就可以尝试获取锁
                    if (p == head) {
                        // 再次尝试获取锁,获取成功返回 1
                        int r = tryAcquireShared(arg);
                        if (r >= 0) {
                            // 获取锁成功,设置当前节点为 head 节点,并且向后传播
                            setHeadAndPropagate(node, r);
                            p.next = null; // 到了这步时p节点会被删除
                            failed = false;
                            return;
                        }
                    }
                    // parkAndCheckInterrupt阻塞在这里
                    if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
                        throw new InterruptedException();
                }
            } finally {
                // 阻塞线程被中断后抛出异常,进入取消节点的逻辑
                if (failed)
                    cancelAcquire(node);
            }
  }
  • a、将当前线程加入等待队列:调用 addWaiter(Node.SHARED) 将当前线程包装成一个 Node 节点并加入等待队列。哨兵节点--->Node节点

  • b、自旋等待:在一个无限循环中,检查当前节点的前驱节点是否是头节点。如果是头节点,则尝试获取共享锁。

  • c、获取锁成功:如果 tryAcquireShared(arg) 返回大于等于 0 的值,表示获取锁成功,调用 setHeadAndPropagate(node, r) 设置头节点并唤醒后续的共享节点。

  • d、检查是否需要阻塞:如果获取锁失败,调用 shouldParkAfterFailedAcquire(p, node) 检查是否需要阻塞当前线程。如果需要阻塞,则调用 parkAndCheckInterrupt() 阻塞当前线程并检查中断状态。

  • e、处理中断:如果在阻塞过程中线程被中断,则抛出 InterruptedException。


4、获取共享锁成功,进入唤醒阻塞队列中与头节点相连的 SHARED 模式的节点:

   private void setHeadAndPropagate(Node node, int propagate) {

          Node h = head;

          // 将当前节点设置为新的 head 节点,前驱节点和持有线程置为 null
          setHead(node);

      	  // propagate = 1,条件一成立
          if (propagate > 0 || h == null || h.waitStatus < 0 || (h = head) == null || h.waitStatus < 0) {
              // 获取当前节点的后继节点
              Node s = node.next;
              // 当前节点是尾节点时 next 为 null,或者后继节点是 SHARED 共享模式
              if (s == null || s.isShared())
                  // 唤醒所有的等待共享锁的节点
                  doReleaseShared();
          }
  }
  • a、将当前的头节点 head 赋值给局部变量 h,以便后续检查使用

  • b、将传入的 node 设置为新的头节点 head

  • c、这个条件判断包含了多个子条件,用于决定是否进行进一步的操作:

    • propagate > 0:如果 propagate 大于 0,说明state已经为0,表示需要传播信号。

    • h == null:如果旧的头节点为空,表示链表或队列之前是空的。

    • h.waitStatus < 0:如果旧的头节点的状态小于 0,表示该节点可能处于某种特殊状态(例如等待唤醒)。

    • (h = head) == null:再次检查新的头节点是否为空。

    • h.waitStatus < 0:再次检查新的头节点的状态。

  • d、当前节点是尾节点时 next 为 null,或者后继节点是 SHARED 共享模式

小结:当线程使用 await() 方法时会将当前线程封装成 node 加入AQS 队列中,如果发现 state 不为0,说明还有任务未执行完成,继续阻塞;如果 state 为0,会释放掉所有的等待线程,执行 await() 之后的数据。


5、doReleaseShared方法,唤醒队列中阻塞节点

  private void doReleaseShared() {

        for (;;) {

            // 此时head已经为传入Node,因为在前面已经重置了
            Node h = head;
            // 判断队列是否是空队列
            if (h != null && h != tail) {
                int ws = h.waitStatus;
                // 头节点的状态为 signal,说明后继节点没有被唤醒过
                if (ws == Node.SIGNAL) {
                    // cas 设置头节点的状态为 0,设置失败继续自旋
                    if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                        continue;
                    // 唤醒后继节点
                    unparkSuccessor(h);
                }
                // 如果有其他线程已经设置了头节点的状态,重新设置为 PROPAGATE 传播属性
                else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                    continue;
            }
            // 条件不成立说明被唤醒的节点非常积极,直接将自己设置为了新的head,
            // 此时唤醒它的节点(前驱)执行 h == head 不成立,所以不会跳出循环,会继续唤醒新的 head 节点的后继节点
            if (h == head)
                break;
        }
    }


6、进入 shouldParkAfterFailedAcquire 逻辑,将前驱 node 的 waitStatus 改为 -1,返回 false;waitStatus 为 -1 的节点用来唤醒下一个节点

    //该方法的作用是保证上一个节点的waitStatus状态为-1(为了唤醒后继节点)
    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {

    	   //获取上一个节点的状态,该状态为-1,才会唤醒下一个节点。
    	   int ws = pred.waitStatus;

    	   // 如果上一个节点的状态是SIGNAL即-1,可以唤醒下一个节点,直接返回true
    	   if (ws == Node.SIGNAL)
    		  return true;

           // 如果上一个节点的状态大于0,说明已经失效了
           if (ws > 0) {
    			 do {
    			 
    				// 将node 的节点与 pred 的前一个节点相关联,并将前一个节点赋值给 pred
    				node.prev = pred = pred.prev;
    			 
    			 } while (pred.waitStatus > 0); // 一直找到小于等于0的
    			
    			 // 将重新标识好的最近的有效节点的 next 指向当前节点
    			 pred.next = node;
    		 
           } else {
    	   
    		  // 小于等于0,但是不等于-1,将上一个有效节点状态修改为-1
              compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
           }
           return false;
     }
  • a、shouldParkAfterFailedAcquire 执行完毕回到 acquireQueued ,再次 tryAcquire 尝试获取锁,这时 state 仍为 1 获取失败(第四次)

  • b、当再次进入 shouldParkAfterFailedAcquire 时,这时其前驱 node 的 waitStatus 已经是 -1 了,返回 true

  • c、进入 parkAndCheckInterrupt, Thread-1 park(灰色表示)


7、parkAndCheckInterrupt() 阻塞当前线程并检查中断状态

   private final boolean parkAndCheckInterrupt() {

        // 阻塞当前线程,如果打断标记已经是 true, 则 park 会失效
        LockSupport.park(this);

        // 判断当前线程是否被打断,清除打断标记
        return Thread.interrupted();
   }


案例:

        import java.util.concurrent.CountDownLatch;
        import java.util.concurrent.Executor;
        import java.util.concurrent.Executors;

        public class CountDownLatchtTest {

            private static final int N = 10; // 乘客数


            public static void main(String[] args) throws InterruptedException {

                //step1:创建倒数闩,设置倒数的总数
                CountDownLatch doneSignal = new CountDownLatch(N);

                Executor e = Executors.newFixedThreadPool(N);

                for (int i = 1; i <= N; ++i) {
                    e.execute(new Person1(doneSignal, i));
                }
                doneSignal.await(); //step2:等待报数完成,倒数闩计数值为0
                System.out.println("人到齐,开车");
            }
        }
      class Person1 implements Runnable{

            private final CountDownLatch doneSignal;

            private final int i;

            Person1(CountDownLatch doneSignal, int i) {
                this.doneSignal = doneSignal;
                this.i = i;
            }

            @Override
            public void run() {
                //报数
                System.out.println("第" + i + "个人已到");
                doneSignal.countDown(); //step3:倒数闩减少1

            }

       }


五、流程图了解一下


六、CountDownLatch的文字描述


假设 CountDownLatch 初始计数器为 3(state = 3),以下是线程并发操作的动态过程:


步骤 1:初始化

  • 状态:state = 3,CLH 队列为空。

  • 线程 T1、T2、T3:调用 countDown() 减少计数器。

  • 线程 W1、W2:调用 await() 等待计数器归零。


步骤 2:线程 W1 调用 await()

  • a、检查计数器:state = 3 > 0,线程 W1 加入 CLH 队列并阻塞。


步骤 3:线程 T1 调用 countDown()

  • a、减少计数器:state = 3 → 2

  • b、未触发唤醒:state > 0,不唤醒队列中的线程


步骤 4:线程 W2 调用 await()

  • a、检查计数器:state = 2 > 0,线程 W2 加入 CLH 队列并阻塞。


步骤 5:线程 T2 调用 countDown()

  • a、减少计数器:state = 2 → 1

  • b、未触发唤醒:state > 0,不唤醒队列中的线程


步骤 6:线程 T3 调用 countDown()

  • a、减少计数器:state = 1 → 0

  • b、触发唤醒:state == 0,唤醒 CLH 队列中的所有线程

  • c、线程 W1、W2 被唤醒:继续执行后续逻辑

posted @ 2024-06-21 21:04  jock_javaEE  阅读(194)  评论(0)    收藏  举报