CountDownLatch实现原理

 

.使用场景

可以使一个或多个线程等待其他线程各自执行完毕后再执行。

CountDownLatch可以解决那些一个或者多个线程在执行之前必须依赖于某些必要的前提业务先执行的场景。

比如有展示多个统计报表,可以使用多个线程分别获取统计报表然后将结果统一返回。

二.实现原理

CountDownLatch 定义了一个计数器,和一个阻塞队列, 当计数器的值递减为0之前,阻塞队列里面的线程处于挂起状态,当计数器递减到0时会唤醒阻塞队列所有线程,这里的计数器是一个标志,可以表示一个任务一个线程,也可以表示一个倒计时器。

2.1、内部类Sync

/**同步控制,使用AQS state来表示计数 */

private static final class Sync extends AbstractQueuedSynchronizer {

  private static final long serialVersionUID = 4982264981922014374L;

 

  Sync(int count) {

    setState(count);

  }

 

  int getCount() {

    return getState();

  }

 

  protected int tryAcquireShared(int acquires) {

    return (getState() == 0) ? 1 : -1;

  }

 

  protected boolean tryReleaseShared(int releases) {

    // 递减计数;过渡到零时发出信号

    for (;;) {

      int c = getState();

      if (c == 0)

        return false;

      int nextc = c-1;

      if (compareAndSetState(c, nextc))

        return nextc == 0;

    }

  }

}

 

private final Sync sync;

2.2、CountDownLatch构造方法

当我们调用CountDownLatch countDownLatch=new CountDownLatch(n) 时候,此时会创建一个AQS的同步队列,并把创建CountDownLatch 传进来的计数器赋值给AQS队列的 state,所以state的值也代表CountDownLatch所剩余的计数次数

public CountDownLatch(int count) {

  if (count < 0) throw new IllegalArgumentException("count < 0");

  this.sync = new Sync(count); //count设置为state

}

2.3、CountDownLatch的await方法

当调用countDownLatch.await()的时候,会创建一个节点,加入到AQS阻塞队列,并同时把当前线程挂起

public void await() throws InterruptedException {

  sync.acquireSharedInterruptibly(1);

}

2.3.1、AQS的acquireSharedInterruptibly方法

判断计数器是计数完毕,未完毕则把当前线程加入阻塞队列。

以共享模式获取,如果中断则中止。首先检查中断状态,然后至少调用一次tryAcquireShared,并成功返回。否则,线程将排队,可能反复阻塞和解除阻塞,调用tryAcquireShared 直到成功或线程被中断。

public final void acquireSharedInterruptibly(int arg)

  throws InterruptedException {

    if (Thread.interrupted()) // 如果线程被中断则抛出异常

      throw new InterruptedException();

    //锁重入次数大于0 则新建节点加入阻塞队列,挂起当前线程

    if (tryAcquireShared(arg) < 0) // 尝试获取共享锁,该方法在Sync类中实现

      // 如果获取失败,需要根据当前线程创建一个mode为SHARE的的Node放入队列中并循环获取

      doAcquireSharedInterruptibly(arg);

}

这里的tryAcquireShared方法在Sync中被重写。

2.3.2、CountDownLatch的tryAcquireShared方法

protected int tryAcquireShared(int acquires) {

  return (getState() == 0) ? 1 : -1; //判断state是否等于0

}

2.3.3、AQS的doAcquireSharedInterruptibly方法

构建阻塞队列的双向链表,挂起当前线程。

在共享可中断模式下获取。

private void doAcquireSharedInterruptibly(int arg)

throws InterruptedException {

  //新建节点加入阻塞队列

  final Node node = addWaiter(Node.SHARED);

  boolean failed = true;

  try {

    for (;;) {

      final Node p = node.predecessor(); //获得当前节点pre节点

      if (p == head) { // 如果 p == head 表示是队列的第一个节点,尝试获取

        int r = tryAcquireShared(arg); //返回锁的state

        if (r >= 0) { // 设置当前节点为head,并向后面的节点传播

          setHeadAndPropagate(node, r);

          p.next = null; // help GC

          failed = false;

          return;

        }

      } //重组双向链表,清空无效节点,挂起当前线程

      if (shouldParkAfterFailedAcquire(p, node) &&

        parkAndCheckInterrupt())

        throw new InterruptedException();

    }

  } finally {

    if (failed)

    cancelAcquire(node);

  }

}

这里的重点是setHeadAndPropagate方法。

2.3.4、AQS的setHeadAndPropagate方法

设置队列的头部,并检查后继者是否可能在共享模式下等待,如果正在传播则是否设置了传播> 0或已设置PROPAGATE状态。

参数: propagate表示tryAcquireShared的返回值,对于CountDownLatch来说如果获取成功,则应该是1。

private void setHeadAndPropagate(Node node, int propagate) {

  Node h = head; // 首先先将之前的head记录一下,用于下面的判断

  setHead(node); // 然后设置当前节点为头节点

  // 如果h.waitStatus >= 0,表示是初始状态或者是取消状态,那么当propagate <= 0时将不唤醒节点。

  if (propagate > 0 || h == null || h.waitStatus < 0 ||

    (h = head) == null || h.waitStatus < 0) {

      Node s = node.next;

      //如果node的下一个节点s为null或者是共享的,则释放节点唤醒

      if (s == null || s.isShared())

        doReleaseShared(); //最后在判断是否需要唤醒

  }

}

问题: 为什么s下一个节点为null的时候也需要唤醒操作呢?

这种保守的检查方式可能会引起多次不必要的线程唤醒操作,但这些情况仅存在于多线程并发的acquires/releases操作,所以大多线程数需要立即或者很快地一个信号。这个信号就是执行unpark方法。因为LockSupport在unpark的时候,相当于给了一个信号,即使这时候没有线程在park状态,之后有线程执行park的时候也会读到这个信号就不会被挂起。

简单点说,就是线程在执行时,如果之前没有unpark操作,在执行park时会阻塞该线程;但如果在park之前执行过一次或多次unpark(unpark调用多次和一次是一样的,结果不会累加)这时执行park时并不会阻塞该线程。

所以,如果在唤醒node的时候下一个节点刚好添加到队列中,就可能避免了一次阻塞的操作。

所以这里的propagate表示传播,传播的过程就是只要成功的获取到共享锁就唤醒下一个节点。

2.3.5、AQS的doReleaseShared方法

共享模式下的释放动作-表示后继信号并确保传播。 (注意:对于独占模式,如果需要信号,则释放仅相当于调用head的unparkSuccessor。)

private void doReleaseShared() {

  //唤醒所有阻塞队列里面的线程

  for (;;) {

    Node h = head;

    if (h != null && h != tail) {

      int ws = h.waitStatus;

         // 如果head的状态是SIGNAL(等待唤醒状态),这时尝试将状态复位;

      // 如果复位成功,则唤醒下一节点,否则继续循环。

      if (ws == Node.SIGNAL) {

        if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) //修改状态为初始

          continue; // loop to recheck cases

        unparkSuccessor(h); //成功则唤醒线程

      }

      // 如果状态是0,尝试设置状态为传播状态,表示节点向后传播;

      // 如果不成功则继续循环。

      else if (ws == 0 &&

        !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))

        continue; // loop on failed CAS

    }

    if (h == head) // 如果头节点有变化,则继续循环

    break;

  }

}

什么时候状态会是SIGNAL呢?回顾一下shouldParkAfterFailedAcquire方法

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {

  // 当状态不为CANCEL或者是SIGNAL时,为了保险起见,这里把状态都设置成了SIGNAL,然后会再次循环进行判断是否需要阻塞。

  int ws = pred.waitStatus;

  if (ws == Node.SIGNAL)

    return true; //已经设置好则返回

  if (ws > 0) {

    // 前驱已取消。跳过前驱,并重试。

    do {

      node.prev = pred = pred.prev;

    } while (pred.waitStatus > 0);

      pred.next = node;

  } else {

    / * * waitStatus必须为0或PROPAGATE。表示需要一个信号,但还不要park。调用者将需要重试以确保在park之前无法获取。 * /

    compareAndSetWaitStatus(pred, ws, Node.SIGNAL);

  }

  return false;

}

回到doReleaseShared方法,这里为什么不直接把SIGNAL设置为PROPAGATE,而是先把SIGNAL设置为0,然后再设置为PROPAGATE呢?

原因在于unparkSuccessor方法,该方法会判断当前节点的状态是否小于0,如果小于0则将h的状态设置为0,如果在这里直接设置为PROPAGATE状态的话,则相当于多做了一次CAS操作。unparkSuccessor中的代码如下:

/** 唤醒节点的后继(存在的) */

private void unparkSuccessor(Node node) {

  int ws = node.waitStatus;

  if (ws < 0)

    compareAndSetWaitStatus(node, ws, 0);

 

  Node s = node.next;

  if (s == null || s.waitStatus > 0) {

    s = null;

    for (Node t = tail; t != null && t != node; t = t.prev)

      if (t.waitStatus <= 0)

        s = t;

  }

  if (s != null)

  LockSupport.unpark(s.thread);

}

其实这里只判断状态为SIGNAL和0还有另一个原因,那就是当前执行doReleaseShared循环时的状态只可能为SIGNAL和0,因为如果这时没有后继节点的话,当前节点状态没有被修改,是初始的0;如果在执行setHead方法之前,这时刚好有后继节点被添加到队列中的话,因为这时后继节点判断p == head为false,所以会执行shouldParkAfterFailedAcquire方法,将当前节点的状态设置为SIGNAL。当状态为0时设置状态为PROPAGATE成功,则判断h == head结果为true,表示当前节点是队列中的唯一一个节点,所以直接就返回了;如果为false,则说明已经有后继节点的线程设置了head,这时不返回继续循环,但刚才获取的h已经用不到了,等待着被回收。

2.4、CountDownLatch的countDown方法(计数器递减)

当我们调用countDownLatch.down()方法的时候,会对计数器进行减1操作,AQS内部是通过释放锁的方式,对state进行减1操作,当state=0的时候证明计数器已经递减完毕,此时会将AQS阻塞队列里的节点线程全部唤醒。

/** 减少锁存器的计数,如果计数达到零,则释放所有等待线程。

如果当前计数大于零,则将其递减。

如果新计数为零,则出于线程调度的目的,将重新启用所有等待线程。

如果当前计数等于零,那么什么也不会发生。 */

public void countDown() {

  //递减锁重入次数,当state=0时唤醒所有阻塞线程

  sync.releaseShared(1);

}

2.4.1、 AQS的releaseShared方法

public final boolean releaseShared(int arg) {

  // 尝试释放共享节点,如果成功则执行释放和唤醒操作

  if (tryReleaseShared(arg)) { //递减锁的重入次数,

  doReleaseShared();

  return true;

  }

  return false;

}

这里调用的tryReleaseShared方法是在CountDownLatch中的Sync类重写的,而doReleaseShared方法已在上文中介绍。

2.4.2、 CountDownLatch中的tryReleaseShared方法

protected boolean tryReleaseShared(int releases) {

  // 设置state的操作需要循环来设置以确保成功。

  for (;;) {

    int c = getState(); // 获取计数器数量

    if (c == 0) // 为0是返回false表示不需要释放

      return false;

    int nextc = c-1; // 否则将计数器减1

    if (compareAndSetState(c, nextc))

      return nextc == 0;

  }

}

2.5、CountDownLatch的超时控制的await方法

public boolean await(long timeout, TimeUnit unit)

throws InterruptedException {

  return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));

}

2.5.1、AQS的tryAcquireSharedNanos方法

public final boolean tryAcquireSharedNanos(int arg, long nanosTimeout)

throws InterruptedException {

  if (Thread.interrupted())

    throw new InterruptedException();

  return tryAcquireShared(arg) >= 0 ||

    doAcquireSharedNanos(arg, nanosTimeout);

}

2.5.2、AQS的tryAcquireSharedNanos方法

对应于上文中提到的doAcquireSharedInterruptibly方法,还有一个提供了超时控制的doAcquireSharedNanos方法,代码如下:

/** 超时模式下获取共享锁 */

private boolean doAcquireSharedNanos(int arg, long nanosTimeout)

throws InterruptedException {

  if (nanosTimeout <= 0L)

    return false;

  final long deadline = System.nanoTime() + nanosTimeout; //计算超时截止时间

  final Node node = addWaiter(Node.SHARED);

  boolean failed = true;

  try {

    for (;;) {

      final Node p = node.predecessor();

      if (p == head) {

        int r = tryAcquireShared(arg);

        if (r >= 0) {

          setHeadAndPropagate(node, r);

          p.next = null; // help GC

          failed = false;

          return true;

        }

      }

      nanosTimeout = deadline - System.nanoTime();

      if (nanosTimeout <= 0L) //判断超时, 每次循环判断是否已经超出截止时间

        return false;

      // 判断是否需要park,如果是true, 再判断超时是否>自旋的最小阈值 , 也就是说如果剩余的时间不足1000纳秒,则不需要park。

      if (shouldParkAfterFailedAcquire(p, node) &&

        nanosTimeout > spinForTimeoutThreshold)

        LockSupport.parkNanos(this, nanosTimeout);

      if (Thread.interrupted())

        throw new InterruptedException();

    }

  } finally {

    if (failed)

  cancelAcquire(node);

  }

}

三.总结

本文通过CountDownLatch来分析了AQS共享模式的实现,实现方式如下:

调用await时

  • 共享锁获取失败(计数器还不为0),则将该线程封装为一个Node对象放入队列中,并阻塞该线程;
  • 共享锁获取成功(计数器为0),则从第一个节点开始依次唤醒后继节点,实现共享状态的传播。

调用countDown时

  • 如果计数器不为0,则不释放,继续阻塞,并把state的值减1;
  • 如果计数器为0,则唤醒节点,解除线程的阻塞状态。

在这里再对比一下独占模式和共享模式的相同点和不同点

相同点

  • 锁的获取和释放的判断都是由子类来实现的。

不同点

  • 独占功能在获取节点之后并且还未释放时,其他的节点会一直阻塞,直到第一个节点被释放才会唤醒;
  • 共享功能在获取节点之后会立即唤醒队列中的后继节点,每一个节点都会唤醒自己的后继节点,这就是共享状态的传播。

根据以上的总结可以看出,AQS不关心state具体是什么,含义由子类去定义,子类则根据该变量来进行获取和释放的判断,AQS只是维护了该变量,并且实现了一系列用来判断资源是否可以访问的API,它提供了对线程的入队和出队的操作,它还负责处理线程对资源的访问方式,例如:什么时候可以对资源进行访问,什么时候阻塞线程,什么时候唤醒线程,线程被取消后如何处理等。而子类则用来实现资源是否可以被访问的判断。

 

 

 

 

 

 

参考: https://zhuanlan.zhihu.com/p/95835099

http://www.ideabuffer.cn/2017/03/19/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3AbstractQueuedSynchronizer%EF%BC%88%E4%BA%8C%EF%BC%89/

posted @ 2021-02-18 10:56  将军上座  阅读(748)  评论(0编辑  收藏  举报