JDK源码系列 AQS续篇共享锁源码实现分析

CountDownLatch源码分析

之前分析了ReetrantLock源码,可重入锁是基于AQS的独占锁实现。现在我们来分析一下AQS的共享锁模式的实现,由于CountDownLatch便是AQS共享锁模式实现的,我们就从CountDownLatch的源码进行切入,来了解AQS的共享锁。


CountDownLatch整体结构

在这里插入图片描述

Sync内部类
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) {
        // Decrement count; signal when transition to zero
        for (;;) {
            int c = getState();
            if (c == 0)
                return false;
            int nextc = c-1;
            /*只有nextc减少到0才意味着正是释放共享锁*/
            if (compareAndSetState(c, nextc))
                return nextc == 0;
        }
    }
}
AQS的共享锁的获取与实现

在介绍CountDownLatch之前我们先来了解一下AQS共享锁的原理,这里很多内容和AQS的独占锁相似,我们只讲共享锁的部分,其余设计的相关函数可以参考JDK源码系列 AbstractQueuedSynchronizer源码剖析

首先,共享锁的获取acquireShared

public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}

当tryAcquireShared返回的是一个整型:

  • 当返回值小于0,说明当前线程获取锁失败。
  • 当返回值大于0,说明当前线程获取锁成功,并且接下来其他线程尝试获取共享锁的行为很可能成功。
  • 当返回值等于0,说明当前线程获取锁成功,当时接下来其他线程尝试获取共享锁的行为会失败。

只有返回值大于等于0才会获取锁成功,否则将以共享结点的形式加入CLH同步队列中等待获取锁。

tryAcquireShared(arg)的子类是由子类实现的,这里是由CountDownLatch来实现。

接下来我们来看一下doAcquireShared的具体实现:

private void doAcquireShared(int arg) {
	/**
     * 通过addWaiter想CLH队列添加一个共享模式的结点
     */
    final AbstractQueuedSynchronizer.Node node = addWaiter( AbstractQueuedSynchronizer.Node.SHARED);
    boolean failed = true; //判断是否获取资源失败
    try {
        boolean interrupted = false; //判断在获取锁的过程中是否被中断过
        for (;;) {
        	//获取node结点的前驱结点
            final AbstractQueuedSynchronizer.Node p = node.predecessor();
	
			//重点看这部分,其余部分和独占锁的一样的,不再赘述
			//--------------------------------------------------------
            if (p == head) {
            	// 尝试获取状态
                int r = tryAcquireShared(arg);
                // 如果获取状态成功
                if (r >= 0) {
                	//将node结点设置为头结点,若还有可用的资源,可以再唤醒之后的资源
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    if (interrupted)
                        selfInterrupt();
                    failed = false;
                    return;
                }
            }
			//--------------------------------------------------------

            // 如果前驱节点不是头节点或者获取资源失败,尝试将当前线程挂起,使其进入waiting状态
            if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
    	//如果发生异常,则取消获取资源
        if (failed)
            cancelAcquire(node);
    }
}

与独占锁不同的是,独占锁的结点在CLH队列中获取到资源后直接将其设置为头结点后返回,而共享锁执行的
setHeadAndPropagate。我们来分析一下:

setHeadAndPropagate(Node node, int propagate)

当线程获取到资源之后,调用setHead(node)将其设置为头结点,并且会在一定的条件下调用doReleaseShared()来唤醒后继结点。这是因为在共享锁模式下,锁是可以被多个线程共同持有的,既然当前线程获取到了锁,那么就可以通知后继结点来获取锁,而不必等待锁被释放时再进行通知。
而独占锁只有在线程调用release函数释放锁的时候才会唤醒后继结点来尝试获取锁,这是因为锁只有一个,只能有一个线程来持有。

private void setHeadAndPropagate(AbstractQueuedSynchronizer.Node node, int propagate) {
    //获取旧的头结点
    AbstractQueuedSynchronizer.Node h = head; // Record old head for check below
    //将node结点设置为新的头结点
    setHead(node);
    /**
     * 有两种情况需要执行唤醒操作
     * 1. propagate>0 表明了node的后继结点需要被唤醒
     * 2. 头结点后面的结点需要唤醒(waitStatus<0), 无论是旧的头结点还是新的头结点
     */
    if (propagate > 0 || h == null || h.waitStatus < 0 ||
            (h = head) == null || h.waitStatus < 0) {
        AbstractQueuedSynchronizer.Node s = node.next;
        //如果node的后继结点是共享类型或者没有后继结点, 则进行唤醒
        //为null是这期间可能有会有新的结点加入到CLH中,则需要进行唤醒
        if (s == null || s.isShared())
            doReleaseShared();
    }
}

setHeadAndPropagate的整个流程:

  1. 记录旧的头结点
  2. 将当前获取资源的线程所在的结点设置为新的头结点
  3. 执行唤醒后继结点的工作,需要满足一下条件:
    1. propagate>0 表明了node的后继结点需要被唤醒
    2. 头结点后面的结点需要唤醒(waitStatus<0), 无论是旧的头结点还是新的头结点

waitStatus小于0,说明此时头结点的状态为SIGNAL或者PROPAGATE,那么说明CLH队列中有待唤醒的结点,至于为什么旧结点的ws<0也可以执行后继结点的唤醒,我们后面再来讨论。

doReleaseShared() 这个方法是共享锁最需要关注的函数,我们后面再讨论。

共享锁的释放

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

我们会看到,调用releaseShared进行锁的释放的时候也会调用doReleaseShared()方法,而这个方法在setHeadAndPropagate的时候也调用过,也就是说doReleaseShared() 可能会被同一个头节点调用两次。

这里为什么说是可能会被同一个头节点调用两次呢,来解释一下:
在独占锁中,head结点表示的是当前线程获取到了锁,并且正在运行中。由于是独占锁模式,锁只有一个,所以头结点在没有调用release进行锁的释放时是不会易主的。而在共享锁的模式下,锁是有多个的,所以当前获取锁的线程所处的头结点,可能已经被新获取锁的线程而取代,head节点发生变化。

其实以上是为了说明,头结点是随时在更替的,而不是只有在调用releaseShared释放锁时才会替换,这点需要和独占锁区分开。

接下来我们着重来看doReleaseShared这个方法。

private void doReleaseShared() {
    for (;;) {
    	//获取头结点
        AbstractQueuedSynchronizer.Node h = head;
        //说明头结点后还有正在等待获取锁的结点
        if (h != null && h != tail) {
        	//获取头结点的状态
            int ws = h.waitStatus;
            //若头结点的状态为SIGNAL,说明后继结点需要被唤醒
            if (ws == AbstractQueuedSynchronizer.Node.SIGNAL) {
                if (!compareAndSetWaitStatus(h, AbstractQueuedSynchronizer.Node.SIGNAL, 0))
                    continue;            // loop to recheck cases
                //唤醒后继结点
                unparkSuccessor(h);
            }
            //确保将共享状态向后传播
            else if (ws == 0 &&
                    !compareAndSetWaitStatus(h, 0, AbstractQueuedSynchronizer.Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        /**
         * 若头结点没有发生变化,表示设置完成,退出循环。
         * 若头结点发生变化了,例如其他的线程获取了锁,为了使自己的唤醒动作能够传递,必须进行重试。
         */
        if (h == head)                   // loop if head changed
            break;
    }
}

我们来一句一句分析:

h != null && h != tail

这句很明显,说明CLH队列中的结点至少有两个才需要进行后继结点的唤醒,否则不需要做任何操作,因为CLH队列中在此刻没有需要唤醒的后继结点。

if (ws == AbstractQueuedSynchronizer.Node.SIGNAL) {
    if (!compareAndSetWaitStatus(h, AbstractQueuedSynchronizer.Node.SIGNAL, 0))
        continue;            // loop to recheck cases
    unparkSuccessor(h);
}

若当前结点的状态为SIGNAL,则表明需要唤醒后继的结点,而这里采取CAS操作是因为存在并发问题,我们来分析一下。

在上面我们说过,由于共享锁模式下可以有多个线程获取锁,这些线程都可以调用releaseShared来释放锁。而这些已经获取到锁的线程,必定曾经是头结点,或者就是现在的头结点。因此,如果是在releaseShared方法中调用的doReleaseShared,此时调用此方法的线程可能已经不是当前头节点所代表的线程了,头节点可能已经被易主好几次了,那么为了防止多个线程对同一个后继结点进行唤醒,必须使用CAS进行操作,只允许一个线程执行唤醒动作。

else if (ws == 0 &&
    !compareAndSetWaitStatus(h, 0, AbstractQueuedSynchronizer.Node.PROPAGATE))
    continue;

什么时候head结点的waitStatus会是0呢,在分析独占锁的时候我们已经分析过了,具体可以看JDK源码系列 AbstractQueuedSynchronizer源码剖析.
我们取其中一张图来分析:
在这里插入图片描述
我们会看到,如果CLH同步队列中只有头结点时或者老二结点刚刚加入CLH队列中还没有进入waiting状态时,头结点的waitStatus才可能是0。

然后我们再来看一下后面的!compareAndSetWaitStatus(h, 0, AbstractQueuedSynchronizer.Node.PROPAGATE)),这一步失败的话,说明head结点的状态已经发生了改变。至于head结点的状态发生改变的话,说明它的后继结点获取锁失败,然后被park进入waiting状态并将head结点的状态设置为SIGNAL。

我们将整个过程理清一下,首先,h != null && h != tail且head结点的waitState为0,说明其他线程调用addWaiter加入CLH队列并成为老二结点。其次,!compareAndSetWaitStatus(h, 0, AbstractQueuedSynchronizer.Node.PROPAGATE))说明新加入的老二结点获取锁失败,调用shouldParkAfterFailedAcquire(p, node)将其前驱结点(此处是头结点)的waitStatus设置为SIGNAL。

若符合ws == 0 && !compareAndSetWaitStatus(h, 0, AbstractQueuedSynchronizer.Node.PROPAGATE)整个判断,说明head结点的waitStatus已经被设置为了SIGNAL,这样子的话则继续执行循环,尝试唤醒后继结点去获取锁。

那我们来解析一下compareAndSetWaitStatus(h, 0, AbstractQueuedSynchronizer.Node.PROPAGATE)设置成功的情况。

  • 情况一:老二结点获取锁失败。此时将head结点的waitStatus设置为PROPAGATE,在随后执行的shouldParkAfterFailedAcquire函数中,0和PROPAGATE其实都是一样的(cas操作在这种情况是可有可无的),最后头结点会被设置为SIGNAL。处于该状态的head结点在获取锁成功的时候肯定会唤醒后继的结点。
  • 情况二:老二结点获取锁成功。cas的作用是确保将共享状态向后传播。假设我们没有cas这一步的话,我们来分析一下,由于头结点和老二结点的waitStatus都为0,并且两个结点都获取锁成功。老二结点成为新的头结点,可是在setHeadAndPropagate中,由于新旧两个头结点的waitStatus都是0,所以不会执行唤醒后继结点的操作(要考虑旧的头结点已经退出了唤醒后继结点的循环)。那么,即使此时有新的结点加入到CLH队列中,也只有等到有线程执行releaseShared时才会唤醒后继的结点。若有该cas操作,那么新的头结点(ws为0)会继续执行唤醒后继结点的工作,将共享状态向后传播下去。我觉得这也是在setHeadAndPropagate函数中为什么需要根据新旧两个头结点来判断是否需要执行唤醒后继结点的工作的原因,其实就是为了防止这种情况的发生。

以上便是我关于compareAndSetWaitStatus(h, 0, AbstractQueuedSynchronizer.Node.PROPAGATE)的一些见解。

最后这句:
if (h == head) break;
如果当前头结点没有发生改变(说明没有新的线程调用过setHead方法)的话,则直接退出;如果头结点发生改变,说明有其他线程获取到了锁并且成为了新的头结点,那么就继续执行唤醒后继结点的操作(该后继结点是最新的头结点的后继结点)。

举个例子来说明一下:

例如:

(A)->B->C->D

此时,A执行doReleaseShared()操作唤醒了B,随即B立马获取到锁并成为新的头结点。

(B)->C->D

此时B执行doReleaseShared()准备唤醒后继结点C,可是A执行doReleaseShared()唤醒B后并没有停止,它发现head结点已经易主了(head结点从A变成了B),也准备唤醒C。此时,唤醒C的线程就有A和B(基本上A的唤醒动作先行,且CAS保证只有一个线程执行成功),如此往复。因为有大量线程调用doReleaseShared(),这样极大地加速了后继结点的唤醒。

以上,便是关于AQS共享锁源码的分析,至于acquireSharedInterruptibly等对中断敏感的函数我们就不分析了,原理其实差不多的。

CountDownLatch的核心API
await()
public void await() throws InterruptedException {
	sync.acquireSharedInterruptibly(1);
}

public final void acquireSharedInterruptibly(int arg)
        throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    if (tryAcquireShared(arg) < 0)
        doAcquireSharedInterruptibly(arg);
}

结合Sync的tryAcquireShared(arg)我们可以看到,在AQS的state不为0之前,当前调用await的线程会被加入CLH队列,以共享结点的形式存在。

那调用await的线程什么时候才能继续运行呢,我们来看一下countDown函数。

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

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

结合Sync的tryReleaseShared方法,只有count个线程调用CountDown()方法后,调用await函数的线程会被唤醒并取争取资源,注意是最后一个调用countDown的线程来进行唤醒的。

其实CountDownLatch很简单,简单的利用了AQS共享锁的机制来实现:在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待


以上,便是AQS共享锁源码的分析,以及相关类的简单分析。

参考文章:

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值