聊聊并发:(八)concurrent包之AbstractQueuedSynchronizer源码实现分析

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/wtopps/article/details/82432066

前言

在上一篇中,聊聊并发:(七)concurrent包之AbstractQueuedSynchronizer分析

我们介绍了concurrent包的大体结构,讲到了Lock接口的实现类是基于AbstractQueuedSynchronizer同步器进行实现的,对AbstractQueuedSynchronizer的功能进行了大体的介绍,本章,我们从源码角度,来看一下AbstractQueuedSynchronizer的具体实现机制。

同步队列

同步队列数据结构

同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。

我们来看一下源代码中的Node实现:

static final class Node {

        static final Node SHARED = new Node();

        static final Node EXCLUSIVE = null;

        static final int CANCELLED =  1;

        static final int SIGNAL    = -1;

        static final int CONDITION = -2;

        static final int PROPAGATE = -3;

        volatile int waitStatus;

        volatile Node prev;

        volatile Node next;

        volatile Thread thread;

        Node nextWaiter;

        final boolean isShared() {
            return nextWaiter == SHARED;
        }

        final Node predecessor() throws NullPointerException {
            Node p = prev;
            if (p == null)
                throw new NullPointerException();
            else
                return p;
        }

        Node() {    // Used to establish initial head or SHARED marker
        }

        Node(Thread thread, Node mode) {     // Used by addWaiter
            this.nextWaiter = mode;
            this.thread = thread;
        }

        Node(Thread thread, int waitStatus) { // Used by Condition
            this.waitStatus = waitStatus;
            this.thread = thread;
        }
    }

AbstractQueuedSynchronizer在类的内部维护了一个Node类,可以从上面的源码看到,Node有几个属性:waitStatus、prev、next、nextWaiter、thread,其具体功能如下:

image

同步队列结构机制

节点是构成同步队列的基础,同步器拥有首节点(head)和尾节点(tail),没有成功获取同步状态的线程将会成为节点加入该队列的尾部。

image

上图是同步队列的结构示意图,从图中可以看到,同步器包含了两个节点类型的引用,一个指向头节点,而另一个指向尾节点。

试想一下,当一个线程成功地获取了同步状态(或者锁),其他线程将无法获取到同步状态,转而被构造成为节点并加入到同步队列中,而这个加入队列的过程必须要保证线程安全,因此同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Node expect,Nodeupdate),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。

/**
     * CAS tail field. Used only by enq.
     */
    private final boolean compareAndSetTail(Node expect, Node update) {
        return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
    }

独占式同步状态获取与释放

通过调用同步器的acquire(int arg)方法可以获取同步状态,该方法对中断不敏感,也就是由于线程获取同步状态失败后进入同步队列中,后续对线程进行中断操作时,线程不会从同步队列中移出。

image

上述代码主要完成了同步状态获取、节点构造、加入同步队列以及在同步队列中自旋等待的相关工作,

其主要逻辑是:
- 首先调用自定义同步器实现的tryAcquire(int arg)方法,该方法保证线程安全的获取同步状态。
- 如果同步状态获取失败,则构造同步节点(独占式Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过addWaiter(Node node)方法将该节点加入到同步队列的尾部。
- 最后调用acquireQueued(Node node,int arg)方法,使得该节点以“死循环”的方式获取同步状态。
- 如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现。

同步队列加入新节点

下面来看一下同步器将节点加入到同步队列的过程:

image

从上图中我们可以看到,接入新的节点进入队列时,最后一个节点会将它的“next”节点指向新的节点,新的节点的“prev”会指向老的尾部节点,同时,同步器的“tail”节点会更新,指向新加入的节点。

我们再来看一下JDK中的源代码实现:

image

上述代码通过使用compareAndSetTail(Node expect,Node update)方法来确保节点能够被线程安全添加。

试想一下:如果使用一个普通的LinkedList来维护节点之间的关系,那么当一个线程获取了同步状态,而其他多个线程由于调用tryAcquire(int arg)方法获取同步状态失败而并发地被添加到LinkedList时,LinkedList将难以保证Node的正确添加,最终的结果可能是节点的数量有偏差,而且顺序也是混乱的。

image

在enq(final Node node)方法中,同步器通过“死循环”来保证节点的正确添加,在“死循环”中只有通过CAS将节点设置成为尾节点之后,当前线程才能从该方法返回,否则,当前线程不断地尝试设置。可以看出,enq(final Node node)方法将并发添加节点的请求通过CAS变得“串行化”了。

当第一次构建队列的时候,此时头结点与尾节点全部都是空的,这时,首先会新增一个空的头结点,然后将新的节点放置到尾部,这里的空的头结点非常精妙,后面我们再来说这里的设计。

同步队列首节点设置

同步队列遵循FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点。

image

设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要使用CAS来保证,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的next引用即可。

我们来看一下JDK源代码实现:

image

在acquireQueued(final Node node,int arg)方法中,当前线程在“死循环”中尝试获取同步状态,而只有前驱节点是头节点才能够尝试获取同步状态,这是为什么?原因有两个,如下:

  • 第一,头节点是成功获取到同步状态的节点,而头节点的线程释放了同步状态之后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点。
  • 第二,维护同步队列的FIFO原则。该方法中,节点自旋获取同步状态的行为。

image

  • 由于非首节点线程前驱节点出队或者被中断而从等待状态返回,随后检查自己的前驱是否是头节点,如果是则尝试获取同步状态。
  • 节点和节点之间在循环检查的过程中基本不相互通信,而是简单地判断自己的前驱是否为头节点,这样就使得节点的释放规则符合FIFO,并且也便于对过早通知的处理(过早通知是指前驱节点不是头节点的线程由于中断而被唤醒)。

节点进入同步队列之后,就进入了一个自旋的过程,每个节点(或者说每个线程)都在自省地观察,当条件满足,获取到了同步状态,就可以从这个自旋过程中退出,否则依旧留在这个自旋过程中(并会阻塞节点的线程)。

上面说了很多,想必大家可能有点晕,下面我用一张流程图来简化说明一下同步器获取同步状态的流程:

image

当同步状态获取成功之后,当前线程从acquire(int arg)方法返回,如果对于锁这种并发组件而言,代表着当前线程获取了锁。

独占式同步状态的释放

当前线程获取同步状态并执行了相应逻辑之后,就需要释放同步状态,使得后续节点能够继续获取同步状态。

通过调用同步器的release(int arg)方法可以释放同步状态,该方法在释放了同步状态之后,会唤醒其后继节点(进而使后继节点重新尝试获取同步状态)。

我们来看一下JDK的源码实现:

image

上面就是释放同步资源的操作,我们来一步一步的分析一下,首先,会通过模板方法调用子类的tryRelease(),如果释放成功,获取当前头结点,如果头结点不为空,同时头结点的等待状态不等于0,则执行unparkSuccessor()方法,唤醒等待的队列中的下一个节点的线程。

image

在unparkSuccessor()方法中,首先先获取头结点的等待状态,如果等待状态为-1,则将状态置为0。

接下来,找到下一个需要唤醒的结点s,如果它为空或已取消,则从队列中去寻找最前边的那个未放弃的节点。

最后,执行唤醒的操作,通过LockSupport提供的工具类。下一个等待节点的线程被唤醒后,它在自旋tryAcquire()方法会返回true,则表示自己拿到资源,将前一个头结点踢出队列,将自己设置为头结点。

还记得我们之前提到的,当第一次构建队列的时候,此时头结点与尾节点全部都是空的,这时,首先会新增一个空的头结点,其实这里的设计非常精妙。

我在看源码的时候,是很好奇为何要初始化的时候设置一个空的头结点,其主要的原因是:如果没有一个空的头结点,在acquireQueued()方法中的自旋就会出现问题,因为自旋是判断的自己的前一个节点是否为头节点,如果第一次构建队列,就把当前等待节点放置在头结点,它就没有前置节点了,它的自旋条件永远无法成立。因此,空的头结点的创建是非常必要的。

好啦,我们刚刚分析了独占式同步状态获取与释放以及同步队列的原理,我们来总结一下这块:
- 线程获取锁失败,线程被封装成Node进行入队操作,核心方法在于addWaiter()和enq(),同时enq()完成对同步队列的头结点初始化工作以及CAS操作失败的重试;
- 线程获取锁是一个自旋的过程,当且仅当当前节点的前驱节点是头结点并且成功获得同步状态时,节点出队即该节点引用的线程获得锁,否则,当不满足条件时就会调用LookSupport.park()方法使得线程阻塞;
- 释放锁的时候会唤醒后继节点。

总体来说:在获取同步状态时,AQS维护一个同步队列,获取同步状态失败的线程会加入到队列中进行自旋;移除队列(或停止自旋)的条件是前驱节点是头结点并且成功获得了同步状态。在释放同步状态时,同步器会调用unparkSuccessor()方法唤醒后继节点。

独占可中断式获取同步状态

我们在前面提到过,Lock锁的实现与synchronized相比,更加的灵活,可以响应中断以及超时时间设置等特性,而Lock的这些特性的实现也是基于AQS的acquireInterruptibly()方法实现的,我们现在来看一下源码的实现。

image

image

从上面的代码中可以看到,基本上实现与acquire()一致,唯一的区别是当parkAndCheckInterrupt返回true时,即线程阻塞时该线程被中断,代码抛出被中断异常。

独占超时等待式获取同步状态

AQS中的tryAcquireNanos()方法可以设置一个超时时间,该方法会在三种情况下才会返回:

  • 在超时时间内,当前线程成功获取了锁;
  • 当前线程在超时时间内被中断;
  • 超时时间结束,仍未获得锁返回false。

我们来看一下其源码实现:

image

image

我们用一张流程图拉来描述一下其流程:

image

程序逻辑同独占锁可响应中断式获取基本一致,唯一的不同在于获取锁失败后,对超时时间的处理上,

在第1步会先计算出按照现在时间和超时时间计算出理论上的截止时间,比如当前时间是8h10min,超时时间是10min,那么根据deadline = System.nanoTime() + nanosTimeout计算出刚好达到超时时间时的系统时间就是8h 10min+10min = 8h 20min。

然后根据deadline - System.nanoTime()就可以判断是否已经超时了,比如,当前系统时间是8h 30min很明显已经超过了理论上的系统时间8h 20min,deadline - System.nanoTime()计算出来就是一个负数,自然而然会在3.2步中的If判断之间返回false。

如果还没有超时即3.2步中的if判断为true时就会继续执行3.3步通过LockSupport.parkNanos使得当前线程阻塞,同时在3.4步增加了对中断的检测,若检测出被中断直接抛出被中断异常。

共享式获取同步状态

上面我们了解到了独占式获取同步状态的实现,AQS中还有共享式获取同步状态的实现,我们继续来看一下。

AQS中共享式获取同步状态的方法是acquireShared(),我们来看一下它的源码实现:

image

image

与独占式获取同步状态类似,首先通过模板方法,调用子类的实现获取同步状态,如果成功,则获取状态成功,如果失败,则进入同步队列;

doAcquireShared()方法中,首先新增一个共享模式的节点到队列尾部,然后进入死循环,判断当前节点的前驱节点是否为头结点,如果是,则获取同步状态,获取成功,则将head指向自己,还有剩余资源可以再唤醒之后的线程;

然后将头结点释放。

是不是感觉和acquireQueued()很像?其实基本流程差不多,只不过这里将补中断的selfInterrupt()放到doAcquireShared()里了,而独占模式是放到acquireQueued()之外。

共享式同步状态释放

共享锁的释放在AQS中会调用方法releaseShared:

我们来看一下源码实现:

image

image

此方法的流程也比较简单,一句话:释放掉资源后,唤醒后继。跟独占模式下的release()相似,但有一点稍微需要注意:

独占模式下的tryRelease()在完全释放掉资源(state=0)后,才会返回true去唤醒其他线程,这主要是基于独占下可重入的考量;

而共享模式下的releaseShared()则没有这种要求,共享模式实质就是控制一定量的线程并发执行,那么拥有资源的线程在释放掉部分资源时就可以唤醒后继等待结点。

例如,资源总量是13,A(5)和B(7)分别获取到资源并发运行,C(4)来时只剩1个资源就需要等待。A在运行过程中释放掉2个资源量,然后tryReleaseShared(2)返回true唤醒C,C一看只有3个仍不够继续等待;

随后B又释放2个,tryReleaseShared(2)返回true唤醒C,C一看有5个够自己用了,然后C就可以跟A和B一起运行。

而ReentrantReadWriteLock读锁的tryReleaseShared()只有在完全释放掉资源(state=0)才返回true,所以自定义同步器可以根据需要决定tryReleaseShared()的返回值。

共享式可中断(acquireSharedInterruptibly()方法),超时等待(tryAcquireSharedNanos()方法)

关于可中断锁以及超时等待的特性其实现和独占式锁可中断获取锁以及超时等待的实现几乎一致,具体的就不再说了,如果理解了上面的内容对这部分的理解也是水到渠成的。

结语

本文,我们深入了解了AQS的源码实现,了解了AQS的队列实现结构,学习了独占式获取同步状态与共享式获取同步状态的实现机制,了解AQS的实现机制对于我们后续学习ReentrantLock、ReentrantReadWriteLock等Lock的实现非常至关重要,后续的文章中,我们会陆续介绍这几个锁的使用及实现机制,敬请期待!

本文参考:

JDK1.8源代码

JDK1.8中文文档

Java并发之AQS详解

深入理解AbstractQueuedSynchronizer(AQS)

更多Java干货文章请关注我的个人微信公众号:老宣与你聊Java

这里写图片描述

猜你喜欢

转载自blog.csdn.net/wtopps/article/details/82432066