AQS队列同步器实现分析

2020-07-08  本文已影响0人  菠萝丶丶

队列同步器AbstractQueuedSynchronizer(以下简称同步器),是用来构建锁或者其他同步组 件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获 取线程的排队工作,并发包的作者(Doug Lea)期望它能够成为实现大部分同步需求的基础。

本节内容将会对AQS内部实现进行分析,如果还不太了解AQS的同学可以先看上一节AQS队列同步器进行简单了解,同步器内部主要通过一个同步队列来完成对同步状态的管理。
下面我们来简单介绍一下同步队列:

同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。 同步队列中的节点(Node)用来保存获取同步状态失败的线程引用、等待状态以及前驱和 后继节点,节点的属性类型与名称以及描述如下表所示:

属性类型和名称 描述
int waitStatus 等待状态,状态值请看下方介绍
Node prev 前驱节点,当节点加入同步队列是设置(尾部添加)
Node next 后继节点
Node nextWaiter 等待队列中的后继节点,如果当前节点是共享的,
那么这个字段将是一个SHARED常量,也就是说节点类型(独占和共享)
和等待队列中的后继节点共用同一个字段
Thread thread 获取同步状态的线程

节点属性waitStatus的值:

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


同步队列的基本结构

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

同步器将节点加入到同步队列的过程如下图所示:


节点加入到同步队列

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


首节点的设置

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

上面对AQS内部结构做了一些简单了解,下面我们来讲一下AQS两种主要的资源获取方式

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

独占式的同步状态获取与释放主要是通过调用同步器的acquire(int arg)方法,该方法对中断不敏感,也就是由于线程获取同步状态失败后会进入同步队列中,后续对线程进行中断操作时,线程不会从同步队列中移出,该方法实现细节如下所示:

    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

我们以之前实现的Mutex类为例,上述代码首先会调用我们实现的tryAcquire(int arg)方法,如果尝试获取资源成功,则直接返回,如果失败,将会进行构建节点,添加节点到队列,使节点以“死循环”获取同步状态,如果获取不到则会阻塞,阻塞时主要依靠前驱节点的出队或线程中断来唤醒。

下面我们看一下addWaiter方法的相关实现:

    private Node addWaiter(Node mode) {
        Node node = new Node(Thread.currentThread(), mode);
        // 快速尝试在尾部添加
        Node pred = tail;
        if (pred != null) {
            node.prev = pred;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
    }
    private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) { // Must initialize
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

上述代码通过使用compareAndSetTail(Node expect,Node update)方法来确保节点能够被线程安全添加。并且确保在队列为空时能正确的指定头节点,而后续的资源获取主要也是依靠头节点来处理(下面有解释)。

节点进入同步队列之后,就开始自旋的尝试获取同步状态,成功就可以从这个自旋过程中退出,否则依旧留在这个自旋过程中(并会阻塞节点的线程),代码如下所示:

    final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

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

acquire独占式获取资源以及添加节点阻塞为获取到资源的线程以及释放资源唤醒后继节点的具体流程如下图所示:

独占式获取资源流程图

上图所描述中,有1,2,3,4线程同时调用acquire(int arg)方法尝试获取资源,依次可以分为如下步骤:

  1. 时间线1开始,线程1优先执行先获取到了资源
  2. 其中线程2进入同步队列,由于头节点为空,所以在enq(final Node node)方法中初始化了一个空节点作为前驱节点
  3. 线程2,3,4依次在acquireQueued(final Node node,int arg)方法中调用parkAndCheckInterrupt()方法进入阻塞
  4. 进入时间线2,线程1释放资源,并唤醒线程2,线程2的前驱节点(空节点)为头节点,再调用tryAcquire(arg)方法成功获得资源,将自己设置为头节点,并从acquire(arg)方法中返回
  5. 线程2获取到资源,线程3,4继续阻塞等待前驱节点唤醒
  6. 进入时间线3,线程2释放资源,并唤醒线程3,线程3的前驱节点为线程2的节点也就是现在的头节点,后面与步骤4中流程一致,直到最后一个后继节点也获取到资源

独占式同步状态获取流程,也就是acquire(int arg)方法调用流程如下图所示:

独占式同步状态获取流程

parkAndCheckInterrupt()方法中为什么要调用Thread.interrupted(),为什么在acquireQueued()方法返回true后又要调用selfInterrupt()

    private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
    }
    static void selfInterrupt() {
        Thread.currentThread().interrupt();
    }

由于LockSupport.park()方法可能是由于中断被返回,而如果是中断被返回下次重新进入阻塞时LockSupport.park()在中断状态下是不会生效(无法阻塞线程)的,所以需要调用Thread.interrupted()来修复中断标志位,以确保被中断唤醒的情况下没获取到资源下次还能正确被阻塞。
如果恰好由于中断从LockSupport.park()方法返回时有可用资源,线程会从acquireQueued方法中返回,而由于外面的线程调用中断方法之后会立即被Thread.interrupted()方法清除,所以此处的selfInterrupt()是为了补偿之前清除的标记位。
此处参考知乎AQS组件acquire(int)方法是否有必要调用selfInterrupt方法?

共享式同步状态获取与释放

总的来说共享式同步状态获取与释放与独占式差别不大,依旧是有阻塞队列和前驱结点唤醒后继节点这些相关处理,区别在于共享式在同一时刻可以有多个线程获取到共享资源,具体同一时刻可以获取到资源的线程数量由程序自身实现决定。下面以之前的TwinsLock类为例,来讲解共享式同步状态的获取的具体实现细节

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

在调用TwinsLocklock()方法时,实现类会调用AQS类的acquireShared(int arg)方法,上面是该方法的实现细节,可以看到此处调用了我们继承AQS类中覆盖的tryAcquireShared(int arg)方法,如果该方法返回值大于0时,则表示成功获取到共享资源,而由于我们设置的资源数量为2,则此处最多能有2个线程能同时获取到共享资源,而其他获取失败的线程则会调用doAcquireShared(int arg)方法被加入到同步队列中进行等待(此处与独占式有点类似),下面来看一下doAcquireShared(int arg)方法的具体实现:

    private void doAcquireShared(int arg) {
        final Node node = addWaiter(Node.SHARED);
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                if (p == head) {
                    int r = tryAcquireShared(arg);
                    if (r >= 0) {
                        setHeadAndPropagate(node, r);
                        p.next = null; // help GC
                        if (interrupted)
                            selfInterrupt();
                        failed = false;
                        return;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

上述代码可以看到,在共享式同步状态的获取主要分为以下几个步骤:

与独占式一样,共享式也需要释放同步状态,可以通过调用releaseShared(int arg)方法,该方法在释放同步状态后同时会唤醒后继节点,让处于同步队列中阻塞的节点醒来并并尝试获取同步状态并成功返回。下面是该方法的实现细节:

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

独占式超时获取同步状态
顾名思义,该方法在独占式获取同步状态的基础上增加了超时时间,通过调用tryAcquireNanos(int arg, long nanosTimeout)方法实现,其具体实现细节与独占式获取同步状态基本无异,区别则是在doAcquireNanos(int arg, long nanosTimeout)方法中增加了对睡眠的时间的计算,类似的计算可以结合之前的文章【线程应用实例-手写连接池(超时等待模式)】与前面独占式获取同步状态结合理解

上一篇下一篇

猜你喜欢

热点阅读