简述AQS设计之道

引文

说起AQS(AbstarctQueuedSynchronizer)不得不提起JUC,JUC包中几乎百分之80%以上的同步实现都是采用AQS实现的。即使没有直接用到AQS也是间接的用到了AQS的三大核心思想。所以与其在文中给大家一词一句的分析代码,不如给大家讲讲AQS的背后核心思想(简称AQS三板斧),因为源码大家都安装有jdk,可以自己看。但是很多时候代码是看懂了,其背后的原理没有搞懂。

  • 第一板斧——CAS

用CAS保证并发修改的原子性,同一时刻只有一个线程能够修改成功。内部借助于Unsafe类实现,其实也可以采用AtomicReference类实现,当然AtomicReference内部也是采用Unsafe类实现的。

  • 第二板斧——自旋

用于保证线程不跳出指定的执行逻辑。

  • 第三板斧——LockSupport

由于不停的自旋会导致cpu的空转,非常浪费性能,所以引入LockSupport(底层由Posix线程库pthread中的mutex(互斥量),condition(条件变量)来实现),用于控制线程的休眠与唤醒。

image.png

现在举个例子,假如我们现在有个减库存的操作。代码如下:

public boolean descStock(int number){
    int count = 获取剩余订单数从缓存
    count -= number;
    if(count < 0) 
        throw 数额不够
    将新的count更新到缓存
    return true;
}

上述逻辑中在并发的过程中肯定是有问题的,如何解决这个问题最简单的方法肯定是在第一先获取一把锁,只有拥有此锁的情况下,才可以去操作库存。如果多个线程同时获取锁的话,只有有一个线程获取成功,那么没有获取成功的那个线程咋办呢?这里我们可以使用自旋。没有获取到锁的线程可以自旋等待,代码如下

public boolean descStock(int number){
    lock();//这里其实可以直接采用synchronize实现,但是为什么AQS不直接采用synchronize呢?思考一下
    int count = 获取剩余订单数从缓存
    count -= number;
    if(count < 0) 
        throw 数额不够
    将新的count更新到缓存
    unlock();
    return true;
}

public void lock(){
    for(;;){
        if(key <= 0){
            continue;
        }
        if(compareAndSwap(key)){
            break;
        }
    }
}

public void unlock(){
    key = 1;
}

这里如果并发数特别大的话肯定会造成系统CPU瓶颈,甚至宕机。

所以在lock操作加入自旋一定的数目则进行休眠,unlock操作中加入唤醒。那个问题来了,唤醒的话需要唤醒谁呢?这个时候我们肯定需要一个队列来维护休眠的线程,解锁后从中指定一个线程来获取锁。代码就不写了,大家肯定都可以写的出,拥有这些够吗?不够,因为还没有考虑到线程中断,如果休眠期间线程中断了咋办?如果我想实现公平锁和非公平锁呢?如果我想实现重入锁咋办呢?如何按照某个条件指定线程唤醒?带着这些问题我们来看看AQS是咋实现的呢?

设计思想

队列同步器AbstractQueuedSynchronizer(以下简称同步器),是用来构建锁或者其他同步组件的基础框架。采用了设计模式中的模板方式模式。暴露出tryAcquire和tryRelease两个public方法,其他的public方法均采用final修饰。

从上文描述的减库存操作我们可以明显看出,AQS最核心的两个操作应该是获取锁和释放锁。AQS的对应的方法是acquire和release操作,其背后的原理也非常简单。(下文的同步器与AQS对象是同一个概念)

acquire操作是这样的

while (当前同步器的状态不允许获取操作) {
      如果当前线程不在队列中,则将其插入队列
      阻塞当前线程
  }
  如果线程位于队列中,则将其移出队列

release操作是这样的:

更新同步器的状态
  if (新的状态允许某个被阻塞的线程获取成功)
       解除队列中一个或多个线程的阻塞状态

从这两个操作中的思想中我们可以提取出三大关键操作:同步器的状态变更、线程阻塞和释放、插入和移出队列。所以为了实现这三个操作,引申出来的三个基本组件:

  • 同步器状态的原子性管理

AQS类使用单个int(32位)来保存同步状态,并暴露出getState、setState以及compareAndSet操作来读取和更新这个同步状态。其中属性state被声明为volatile,并且通过使用CAS指令来实现compareAndSetState,使得当且仅当同步状态拥有一个一致的期望值的时候,才会被原子地设置成新值,这样就达到了同步状态的原子性管理,确保了同步状态的原子性、可见性和有序性。

  • 线程阻塞与解除阻塞

采用LockSupport,需要更多了解请自行查阅。

  • 队列的管理

利用同步队列与条件队列,下文介绍。

接下来我们重点介绍一下AQS的数据结构,这也是AQS的特性所在,上述提到的三板斧是AQS以及其他的同步工具类实现的基本原理。

数据结构

同步队列

整个框架的核心就是如何管理线程阻塞队列,该队列是严格的FIFO队列,因此不支持线程优先级的同步。同步队列的最佳选择是自身没有使用底层锁来构造的非阻塞数据结构,业界主要有两种选择,一种是MCS锁,另一种是CLH锁。其中CLH一般用于自旋,但是相比MCS,CLH更容易实现取消和超时,所以同步队列选择了CLH作为实现的基础。

CLH队列实际并不那么像队列,它的出队和入队与实际的业务使用场景密切相关。它是一个链表队列,通过AQS的两个字段head(头节点)和tail(尾节点)来存取,这两个字段是volatile类型,初始化的时候都指向了一个空节点。

 入队操作:CLH队列是FIFO队列,故新的节点到来的时候,是要插入到当前队列的尾节点之后。试想一下,当一个线程成功地获取了同步状态,其他线程将无法获取到同步状态,转而被构造成为节点并加入到同步队列中,而这个加入队列的过程必须要保证线程安全,因此同步器提供了一个CAS方法,它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。入队操作示意图大致如下:

出队操作:因为遵循FIFO规则,所以能成功获取到AQS同步状态的必定是首节点,首节点的线程在释放同步状态时,会唤醒后续节点,而后续节点会在获取AQS同步状态成功的时候将自己设置为首节点。设置首节点是由获取同步成功的线程来完成的,由于只能有一个线程可以获取到同步状态,所以设置首节点的方法不需要像入队这样的CAS操作,只需要将首节点设置为原首节点的后续节点同时断开原节点、后续节点的引用即可。出队操作示意图大致如下:

条件队列

AQS只有一个同步队列,但是可以有多个条件队列。AQS框架提供了一个ConditionObject类,给维护独占同步的类以及实现Lock接口的类使用。

  ConditionObject类实现了Condition接口,Condition接口提供了类似Object管程式的方法,如await、signal和signalAll操作,还扩展了带有超时、检测和监控的方法。ConditionObject类有效地将条件与其它同步操作结合到了一起。该类只支持Java风格的管程访问规则,这些规则中,当且仅当当前线程持有锁且要操作的条件(condition)属于该锁时,条件操作才是合法的。这样,一个ConditionObject关联到一个ReentrantLock上就表现的跟内置的管程(通过Object.wait等)一样了。两者的不同仅仅在于方法的名称、额外的功能以及用户可以为每个锁声明多个条件。

  ConditionObject类和AQS共用了内部节点,有自己单独的条件队列。signal操作是通过将节点从条件队列转移到同步队列中来实现的,没有必要在需要唤醒的线程重新获取到锁之前将其唤醒。signal操作大致示意图如下:

  await操作就是当前线程节点从同步队列进入条件队列进行等待,大致示意图如下:

 实现这些操作主要复杂在,第一、因超时或Thread.interrupt导致取消了条件等待时,该如何处理?第二、await和signal几乎同时发生就会有竞态问题。

针对第一个问题,最遵照内置管程相关的规范。JSR133修订以后,就要求如果中断发生在转移到同步队列之前,await方法必须在重新获取到锁后,抛出InterruptedException。但是,如果中断发生在转移到同步队列之后,await必须返回且不抛异常,同时设置线程的中断状态。具体可以看await方法的源码,至于为什么这么做是由于LockSupport是支持中断,但是收到中断后并不会抛出中断异常。

第二个问题解决方案是只支持对于独占模式下的条件队列操作。

核心源码介绍

源码部分我们只介绍独占锁的获取与释放流程,关于共享锁的获取是释放流程可以自行查看源码,与独占锁的流程差不多。

独占锁流程图(此图来源为:https://www.processon.com/view/5d0c845ee4b024f7b6760f88?fromnew=1

当然此图有点瑕疵,在c==0为true的分支没有进一步描述获取流程。

image.png

加锁逻辑源码

public final void acquire(int arg) {
        /**
         * 先调用子类实现的tryAcquire方法,该方法保证线程安全的获取同步状态,
         * 如果同步状态获取失败,则构造独占式同步节点(同一时刻只能有一个线程成功获取同步状态)
         * 并通过addWaiter方法将该节点加入到同步队列的尾部
         * 最后调用acquireQueued方法,使得该节点以自旋的方式获取同步状态。
         * 如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现。
         */
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            //如果被中断过,则自行调用中断方法标识自身被中断过。
            selfInterrupt();
 }
private Node addWaiter(Node mode) {
        //当前线程构造成Node节点 node.nextAwait为null
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
        //尝试快速在尾节点后新增节点 提升算法效率 先将尾节点指向pred
        Node pred = tail;
        if (pred != null) {
            //尾节点不为空  当前线程节点的前驱节点指向尾节点
            node.prev = pred;
            //并发处理 尾节点有可能已经不是之前的节点 所以需要CAS更新
            if (compareAndSetTail(pred, node)) {
                //CAS更新成功 当前线程为尾节点 原先尾节点的后续节点就是当前节点
                pred.next = node;
                return node;
            }
        }
        //第一个入队的节点或者是尾节点后续节点新增失败时进入enq
        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
                    tail = head;
            } else {
                //所有的线程节点在构造完成第一个节点后 依次加入到同步队列中
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }
final boolean acquireQueued(final Node node, int arg) {
        //标志获取状态失败
        boolean failed = true;
        try {
            //中断标志
            boolean interrupted = false;
            //节点自旋    CAS自旋volatile变量
            for (;;) {
                //如果前驱是head,即该结点已成老二,那么便有资格去尝试获取资源(可能是老大释放完资源唤醒自己的,当然也可能被interrupt了)。
                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);
        }
    }
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        //前驱节点的状态决定后续节点的行为
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
            //如果是SIGNAL,就可以放心的被堵塞
            /*
             * This node has already set status asking a release
             * to signal it, so it can safely park.
             */
            //代表阻塞中
            return true;
        //如果前驱结点的状态不是SIGNAL,那么自己就不能安心去休息,也就是只有当前驱节点为SIGNAL时这个线程才可以进入等待状态
        if (ws > 0) {
            // 如果前驱放弃了,那就一直往前找,直到找到最近一个正常等待的状态,并排在它的后边。
           //注意:那些放弃的结点,由于被自己“加塞”到它们前边,它们相当于形成一个无引用链,稍后就会被保安大叔赶走了(GC回收)!
            /*
             * Predecessor was cancelled. Skip over predecessors and
             * indicate retry.
             */
            do {
                node.prev = pred = pred.prev;//循环不停的往前找知道找到节点的状态是正常的
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            /*
             * waitStatus must be 0 or PROPAGATE.  Indicate that we
             * need a signal, but don't park yet.  Caller will need to
             * retry to make sure it cannot acquire before parking.
             */
            //问题:这是直接返回true是否可以??
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

解锁逻辑源码

public final boolean release(int arg) {
        if (tryRelease(arg)) {//同步状态释放成功
            Node h = head;
            if (h != null && h.waitStatus != 0)
                //直接释放头节点,并且唤醒下一个节点
                unparkSuccessor(h);
            return true;
        }
        return false;
    }
private void unparkSuccessor(Node node) {
        /*
         * If status is negative (i.e., possibly needing signal) try
         * to clear in anticipation of signalling.  It is OK if this
         * fails or if status is changed by waiting thread.
         */
        //获取头节点的状态
        int ws = node.waitStatus;
        if (ws < 0)
            //通过CAS将头节点的状态设置为初始状态,这样其后续节点就会在下次shouldParkAfterFailedAcquire将其替换掉
            compareAndSetWaitStatus(node, ws, 0);

        /*寻找符合条件的后续节点
         * Thread to unpark is held in successor, which is normally
         * just the next node.  But if cancelled or apparently null,
         * traverse backwards from tail to find the actual
         * non-cancelled successor.
         */
        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);
    }

上述简单介绍了一下AQS的加锁和解锁逻辑,接下来我们来回答一下我们前面提出的几个问题。

1.如果堵塞期间线程中断了咋办?

AQS的做法是设置中断标识,但是不抛出中断异常。

2.如果我想实现公平锁和非公平锁呢?

其实所谓公平锁与非公平锁实现很简单,公平锁就是严格的按照FIFO,获取锁前先判断是否存在等待队列,如果不存在则直接获取锁,否在添加到等待队列自旋获取锁,而非公平锁则是在线程第一次获取锁时,直接获取锁。如果获取失败,则添加到队列,然后获取锁。

3.如果我想实现重入锁咋办呢?

通过保存持锁线程对象实现

4.如何按照某个条件指定线程唤醒?

通过条件队列实现,具体一个源码可以参考ReentrantLock

5.如何实现共享锁与独占锁

共享模式获取锁逻辑跟独占模式比区别在于,共享模式获取锁后有剩余的话还会唤醒之后的节点。那么问题就来了,假如老大用完后释放了5个资源,而老二需要6个,老三需要1个,老四需要2个。老大先唤醒老二,老二一看资源不够,他是把资源让给老三呢,还是不让?答案是否定的!老二会继续park()等待其他线程释放资源,也更不会去唤醒老三和老四了。独占模式,同一时刻只有一个线程去执行,这样做未尝不可;但共享模式下,多个线程是可以同时执行的,现在因为老二的资源需求量大,而把后面量小的老三和老四也都卡住了。当然,这并不是问题,只是AQS保证严格按照入队顺序唤醒罢了(保证公平,但降低了并发)

共享模式释放资源规则很简单:释放掉资源后,唤醒后继。跟独占模式下的release()相似,但有一点稍微需要注意:独占模式下的tryRelease()在完全释放掉资源(state=0)后,才会返回true去唤醒其他线程,这主要是基于独占下可重入的考量;而共享模式下的releaseShared()则没有这种要求,共享模式实质就是控制一定量的线程并发执行,那么拥有资源的线程在释放掉部分资源时就可以唤醒后继等待结点。

好了,AQS的解读就到这里,有兴趣的可以再完整的自己过一遍源码。

参考链接

AQS(AbstarctQueuedSynchronizer)

发布了115 篇原创文章 · 获赞 57 · 访问量 20万+

猜你喜欢

转载自blog.csdn.net/qq_35211818/article/details/104231345
AQS