JUC【2】Lock接口和CDL

180 阅读3分钟

Lock

JUC中各种锁的主要继承对象之一。

 public interface Lock {
     
     /**
         获取锁。如果当前获取不到锁,不进行线程调度且休眠直到已获取到锁。
     **/
     void lock();
     
     /**
     获取锁,除非当前线程被Thread.interrupt了。
     **/
     void lockInterruptibly() throws InterruptedException;
     
     /**
     尝试获取锁。如果可以获取就立即返回true且获取到锁,如果获取不到就立即返回false。
     **/
     boolean tryLock();
     
     boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
     
     void unlock();
     
     Condition newCondition();

看过了使用AQS的ReentrantLock,lock接口也很容易理解了,除了最后一个:Condition。

其实这里的Condition就关联到了另外一个接口:Condition。

Lock+Condition就可以构建一个单锁多条件队列的多线程工作环境,这里可以比较成synchronized关键字+Object,在Condition的头上可以看到Doug Lea的实现note:

 {@code Condition} factors out the {@code Object} monitor
 methods ({@link Object#wait() wait}, {@link Object#notify notify}
 and {@link Object#notifyAll notifyAll}) into distinct objects to
 give the effect of having multiple wait-sets per object, by
 combining them with the use of arbitrary {@link Lock} implementations.
 Where a {@code Lock} replaces the use of {@code synchronized} methods
 and statements, a {@code Condition} replaces the use of the Object
 monitor methods.

简单地说,Condition对比的是Objec类中通过native方法实现的wait和notify、notifyAll,用于构建多组单对象多等待者得等待队列。Condition+Lock的组合可以用于替代Object+Sync。

这里就先不考虑这个Condition了,后面有空再来看看这个Condition,现在看看Lock。

Lock在JUC中有以下实现类:

  • ReentrantLock
  • ReentrantReadWriteLock
  • StampLock
  • ConcurrentHashMap中的Segment

而其实根据我们对于AQS的解析,JUC中的Lock接口不出意外应该基本都是通过AQS进行延展的。也就是说,继承了Lock的类,都是某种意义上的锁,在某些条件下不可共享(当然也有读写锁这种东西)。

CountDownLatch

上一期其实我们解析的和ReentrantLcok有关的AQS,都是独占模式的;接下来我们来看一个共享模式的AQS使用案例:CountDownLatch。

这里需要提前复习一下AQS中的state值:这个值,在ReentrantLock中,代表的含义是当前独占线程重入锁的次数

使用例

CountDownLatch的使用方式也很简单,我们来看看以下的代码示例:

 public static CountDownLatch cdl = new CountDownLatch(100);
 ​
 public static int res = 0;
 ​
 public static void task(){
     res++;
     cdl.countDown();
 }
 ​
 public static void main(String[] args) throws InterruptedException {
     for (int i = 0; i < 100; i++) {
         new Thread(LockDemo::task).start();
     }
     cdl.await();
     System.out.println(res);
 }
  • 首先是 new CountDownLatch cdl = new CountDownLatch(num)

  • 随后在线程任务中,调用这个cdl.countDown()

  • 最后,主线程调用await方法,等待所有相关线程执行

    • 如果我们不调用cdl.await()那么就不控制了,也就是说主线程并不会在此上锁,爱去哪去哪
    • 如果调用次数一定小于num,那么cdl就会一直await,除非我们指定等待时间
    • 如果调用次数大于num,那么cdl只保证num规模下的同步,超过num的部分,不一定是同步的。

这里可以看到,CDL的使用跟ReentrantLock一样简单方便,封装得很好。

这个值很容易让我们想到AQS中的state,我们按照顺序,来看看CountDownLatch的原理。

构造方法

 public CountDownLatch(int count) {
     if (count < 0) throw new IllegalArgumentException("count < 0");
     this.sync = new Sync(count);
 }

而这里的sync,跟ReentrantLock中一样是AQS的继承类,这个实现是:

 Sync(int count) {
     setState(count);
 }

这里就对上了。

既然这里的num是用来设置为state的,那么想必countDown方法就是这个state减1了。

countdown

随后,我们所作的是多线程任务中,进行CDL.countDown

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

cdl内部的sync实现并没有重写这个方法,因此需要到AQS中看。

上一次通过ReentrantLock看AQS,实际上并没有涉及这个方法,因此这里实际上对于我们来说是一个新的AQS使用路径:

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

这里,会先调用tryReleaseShared,这是一个AQS留的钩子方法,cdl的sync重写了:

 protected boolean tryReleaseShared(int releases) {
     // Decrement count; signal when transition to zero
     for (;;) {
         int c = getState();
         if (c == 0)
             return false;
         int nextc = c-1;
         if (compareAndSetState(c, nextc))
             return nextc == 0;
     }
 }

下一步的doReleaseShared被执行的时候就是这里tryReleaseShared,CAS将state值变为0成功的时候才会执行。

  • 这里就解释了上面我们所说的那个现象:当countDown的次数大于num的时候这里就直接返回了,不做其他操作。

根据我们对CDL的使用,其实我们是很清楚:

  • 当new到countDown的时候,CDL中的sync,其实就是一个只有一个节点的AQS。

那么接下来我们看看这个doReleaseShared

这个方法并没有被CDL中的sync重写,因此看AQS中的。

 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;                // loop on failed CAS
         }
         if (h == head)                   // loop if head changed
             break;
     }
 }

可以看到这里是一个自旋操作。

首先先查看头节点的状况:

  • 如果不是空且后面有节点(头节点不是尾节点):

    • 如果头节点状态是signal

      • CAS改为0,失败则继续自旋

      • 如果CAS成功,则唤醒后续节点

        • 这里的unparkSuccssor是不是很眼熟?前面的RL里提到过啦,这里会让休眠的线程重新起来竞争一次。
    • 如果头节点状态是0:

      • CAS设置节点值为PROPAGATE

        • 失败回去自旋
  • 如果是空或头=尾(队列就一个节点)

    • 如果头节点没变,那就退出自旋

      • 因为到了这一步,说明上面的unparkSuccssor并没有让节点往前进,这时候再自旋没意义了

但其实cdl中并没有增加节点的操作,因此到这一步的head和tail都是null。

await

现在来看看cdl.await:

 public void await() throws InterruptedException {
     sync.acquireSharedInterruptibly(1);
 }
 //final,因此是AQS里的啦
  public final void acquireSharedInterruptibly(int arg)
             throws InterruptedException {
         if (Thread.interrupted())
             throw new InterruptedException();
         if (tryAcquireShared(arg) < 0)
             doAcquireSharedInterruptibly(arg);
     }

这里就得看看这俩了:

 protected int tryAcquireShared(int acquires) {
     return (getState() == 0) ? 1 : -1;
 }

从上面看来,这里的tryAcquireShared其实就是获取state值了。

在上一步,我们清楚:这个state其实是不可能小于0的,最多就是等于0。

那么当state不等于0,也就是CDL还没有被全部清除的时候,就会进入下面的doAcquireSharedInterruptibly

 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) {
                 int r = tryAcquireShared(arg);
                 if (r >= 0) {
                     setHeadAndPropagate(node, r);
                     p.next = null; // help GC
                     failed = false;
                     return;
                 }
             }
             if (shouldParkAfterFailedAcquire(p, node) &&
                 parkAndCheckInterrupt())
                 throw new InterruptedException();
         }
     } finally {
         if (failed)
             cancelAcquire(node);
     }
 }

我们清楚:在CDLawait之前,其实sync的队列里是没有节点,头尾都是null的。

那么这里的addWaiter,就会把这个shared节点变成头节点了。

addWaiter在JUC【1】中已经说过了,这里不再赘述,简单点来说就是自旋地将当前线程CAS地塞到AQS队列里,变成最后的节点。

这里在addWaiter之后,当前节点就是最后的节点了,前置节点就是head。

此时回去tryAcquiredShared,跟上面一样,其实就是判断当前CDL是否countDown完了。

  • 如果>0,代表已经完了,那么就把当前节点变为头节点,状态改为propaggate,然后返回;

  • 如果<0,那么跟RL里一样,就是park了。

    • 会在parkAndCheckInterrupt()这里park,直到被上面doReleaseShared中的unparkSuccessor唤醒。

这里也解释了上面CDL次数大于countDown次数时不返回的情况:这里的tryAcquiredShared一直都会返回-1,也就是说当前线程会一直被park。

CDL总结

这样子,整个流程就清晰了:

流程图见:www.processon.com/view/link/6…

总结一下,其实CDL是利用共享条件下的AQS。

类比到RL上,其实很类似于:我们先假设锁重入了N次,然后再一一解锁。只不过这里的线程的解锁,是多个线程到锁上进行解锁。

而等待就是先看看state是否减完,没减完就拿当前线程去初始化AQS。

  • 此时,如果当前线程获取锁失败,则挂起等待解锁完毕被唤醒;
  • 如果获取锁成功,就将当前线程节点设置为头节点,状态设置为propagate,但由于CDL的性质,其实这里的设置没有太大的意义。