java并发-AbstractQueuedSynchronizer

大城市里の小女人 提交于 2020-01-21 16:48:55

AQS是个什么东西

AbstractQueuedSynchronizer是java并发包下大部分的同步组件的底层基础框架,包括ReentrantLockSemaphoreCountDownLatch等,有点类似在上文中可以基于ThreadPoolExecutor构造FixedThreadPool,CachedThreadPool,SingleThreadExecutor。虽然在开发中很少基于AQS直接开发,但在JUC下的一些标准同步器却难勉会接触到,想要了解上层的同步器(显式锁,闭锁,信号量等)的原理,还是有必要起起AQS的底。

AQS主要做着什么

AQS主要做着如下三件事:

  1. 原子性地管理着同步状态,这个同步状态就是多线程进行竞争的资源,多线程下必需保证同一时刻只有一个能成功修改同步状态
  2. 维护同步队列: 1)新同步节点进入队尾排队进行等待。2)同步节点释放资源时出队并唤醒下一个同步节点,且被唤醒的节点成为头节点。3)对队列中一些已经取消的节点移出队列
  3. 线程的阻塞与解除阻塞: 线程竞争不到同步资源时使线程进入阻塞等待,已释放资源的线程唤醒下后继线程,使后继线程解除阻塞进行资源竞争

AQS的实现基础

  1. 变体CLH

整个框架的核心是基于CLH队列(通常用于自旋锁)实现变体的CLH锁,但是使用相同的基本策略,在其节点的前置节点中保留关于线程的一些控制信息,以下是其中两个比较大的差别:

  • 自旋锁中各节点一直在自旋,线程一直在运行中,而在AQS中的线程只有被唤醒的时候进行自旋,如果没资格得到资源则进入阻塞
  • 自旋锁中每个节点是通过自旋去获取pre节点的状态,而AQS为了将CLH队列用于阻塞式同步器,一个节点需要显式地唤醒其后继节点,因此每个节点增加了next属性以定位到后继节点(下一个被唤醒的线程)

next链接仅是一种优化。可能通过next进入遍历队列时会,即使未到达tail也会出现断裂(next==null)的情况,但是利用pre返向遍历时一定是可以访问到所有节点,在unparkSuccessor中体现到,具体原因下面分析。

  1. 非阻塞原子操作CAS

修改同步状态compareAndSetWaitStatus、同步队列中插入一个新的节点到尾部compareAndSetTail、同步队列初始化时设置head节点compareAndSetHead,这3个更新操作都要保证并发的情况下某时刻只有一个线程成功,这里采用的CAS机制,直接依赖于CPU指令实现,是一个非阻塞原子操作。CAS的全称是Compare And Swap,故名思久,它包括两步操作:1)先比较需要修改的值是不是预期中的值 2)如果是1成立,则把值修改为新值。在JUC包下很多组件都有着它的踪影。

在这里会有想过用隐式锁synchronized来替换CAS对state,tail,head进行同步控制是否可行?synchronized作为一个独占锁使得获取不到锁的线程必需阻塞等待,AQS的场景下频繁发生的线程上下文切换会严重浪费cpu资源,抛开性能考虑,是否真的行,会不会影响到AQS的设计初衷,有新角度的请指教。

  1. LockSupport阻塞与解除阻塞

上面说到AQS中的线程在未够资格竞争资源的时候会进入阻塞并在合适的时候被主动唤醒,在前文Thread类相关方法有分析过suspend(),resume()天生是容易发生死锁的,AQS使用更高级别的同步工具LockSupport来实现对线程的阻塞与解除阻塞,完美支持AQS的超时机制,底层和CAS都是依赖于sun.misc.Unsafe

AQS类属性

  • state:int类型,表示同步状态,例如用0和1就可以表示互斥锁,还有个AbstractQueuedLongSynchronizer是用long类型的
  • head:同步队列的头节点,永远指向一个已经获取了同步资源的节点,或者一个无需获取资源的dummy节点(初始化)
  • tail:同步队列的尾节点,延迟初始化,初始化时head与tail指向同一个节点

同步状态(state)是一个比较抽象的概念,这要结合所实现的同步组件的语义来理解,当基于AQS实现了某个同步组件,同步资源才被赋予具体意义。以重入锁为例,同步资源表示某线程获得锁的次数,每次acquire时state+1,每次release时state-1,所以在acquire(arg),release(arg)的调用都是以1为参数,当state=0才表示持有锁的线程完全释放了锁可以唤醒下一个等待锁的线程。

3个属性都是使用volatile类型修饰符,保证了不同线程对这些变量进行操作时的可见性

同步队列节点Node的属性

  • waitStatus:表示同步节点的状态,
状态 说明
SIGNAL(-1) 节点的状态为SIGNAL时,表示它的后继节点正阻塞或即将阻塞(后继节点在阻塞之前会先去设置它的状态为SIGNAL)
CONDITION(-2) 节点处于条件队列中,条件队列中使用的状态,节点在某条件下从条件队列转换到同步队列
PROPAGATE(-3) 用于共享模式的状态,表示需要向后传播唤醒,引入该状态是为了解决一些场景上的缺陷
CANCELLED(1) 超时或中断的时候(获取不到资源中退出了自旋: tryAcquireNanos,acquireInterruptibly等),节点会被设置为该状态,是一个最终的状态
0 节点的初始状态值,AQS中一般用小于0来判断需要向后唤醒

Non-negative values mean that a node doesn’t need to signal.

javadoc中有这样的描述,不太理解这句话的真正含义,非负不就是0与CANCELLED,0不需要signal?网上也有的文章描述,大于0表示节点为取消作态,小于0表示有效状态,但是节点刚入队是初始状态为0,此时0应该也是有效状态吧?

  1. 其实无论在独占还是共享模式中,如果节点处于队列中,通常会被过渡到其它状态(SIGNAL,PROPAGATE),除非刚入队马上就拿到资源然后出队,这种情况可能会一直是0状态(没被后继节点修改为SIGNAL或者传播时没被修改为PROPAGATE),这种情况表示0状态节点的后继节点都没阻塞
  2. 另外一种情况就是,整个队列中只有一个非dummy节点,所以它的状态一直为0,但是由于没有后继节点,所以它无需向后唤醒

在后面的代码分析中,很多地方的细节逻辑难以理解,但如果劳记这句话,不纠结于每行代码的背景,都可以比较容易在致脉络上理解AQS

  • EXCLUSIVE:标志位,独占模式下的节点
  • SHARED:标志位,共享模式下的节点
  • pre:指向同步队列前置节点,通过pre一定可以遍历所有节点
  • next:指向同步队列的后继节点,在并发的场景下并非所有时候都能得到期望值,有可能指向null,所以next是作为一个优化手段
  • thread:当前线程对象

在这里插入图片描述

独占模式实现

synchronized的语义一样,同一时刻只能一个线程获取到同步资源

顶层入口acquire

以独占模式获取同步资源,主要用于实现接口Lock.lock()

    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&     //入队前尝试抢占同步资源
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))  //节点添加到队列并排队等待资源,这个过程不会被线程中断,被外部中断也不响应
            selfInterrupt();    //节点获取到资源,结束排队,如果等待过程有被中断过,那么进入自我中断 
    }

逻辑如下:

  1. tryAcquire: 新来的线程会有一次抢占资源的机会,尽管同步队列是FIFO,但是节点在入队之前会尝试一次,成功的话新节点会抢先于所有队列节点得到资源(队列中节点释放资源并唤醒下一个节点并不是一个原子过程,所以有机会被抢占,体现了不公平性,但是可以提高吞吐量)
  2. addWaiter: 添加线程到队列尾部
  3. acquireQueued: 线程入队后,重复阻塞与解除阻塞直到调用tryAcquire获取资源成功
  4. selfInterrupt: 如果线程在 acquireQueued过程中有被中断过,会保留中断标志并返回,然后进入自我中断
新线程进入队过程addWaiter

将线程包装成独占标志的Node,并添加到队列中,直到成功为止(并非一次就可以成功)

    private Node addWaiter(Node mode) {
        Node node = new Node(Thread.currentThread(), mode);
        // 快速尝试一次能不能添加到队列,如果不能则进入enq()中循环到添加成功为止
        Node pred = tail;
        if (pred != null) {   //队列不为空
            node.prev = pred;  //步骤1
            if (compareAndSetTail(pred, node)) {  //步骤2,并发情况下可能该操作不成功,pred的期望值不再是tail
                pred.next = node;   //步骤3
                return node;
            }
        }
        enq(node);
        return node;
    }

入队的逻辑就是把新节点设置为tail,并调整新节点与前置节点的next,pre关系,并发时会影响到步骤2的CAS操作,导致步骤3不能如常,一次尝试失败后会进入到enq()方法

    private Node enq(final Node node) {
        for (;;) {  // 循环到成功添加到队列为止
            Node t = tail;
            if (t == null) {  // Must initialize 队列为空,需要进行初始化
                if (compareAndSetHead(new Node()))   //初始化队列,dummy节点入队,head和tail都指向dummy节点
                    tail = head;
            } else {  //队列不为空(已经初始化过),下面的逻辑和addWaiter一样,只是简单把节点插入队列过程,如果失败则继续for循环
                node.prev = t;     
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

看到代码注释中有这样一句话Must initialize,为什么必需初始化一个无意义的节点?

tail,head初始值为null所以第一次有节点入队前要初始化队列(即是初始化head,tail)以至于后续对tail,head的操作不会异常。这里用一个默认初始值的Node(姑且叫做dummy节点)作为队列的初始化节点,为什么不将即将要入队的第一个线程作为节点入队?回到上文对head属性的描述:永远指向一个已经获取了同步资源的节点,或者一个无需获取资源的dummy节点(初始化),继续往下看就会明白,第一个入队的节点作为head是不会竞争资源的(根本不是一个独立线程),当第二个节点竞争到资源就成为新head会把旧head踢出队列。

如果不断有节点新加入到CLH队列,可以脑子里会出现文章上面第一张图的样子,但是这里补充一种情况以便下文分析。假设在某时刻有多线程同时进入队列,并且同时运行到compareAndSetTail(t, node),这时候还没来得及执行下一行代码,那么会出现下面的情形:
在这里插入图片描述

node1,node2,nodex都执行了compareAndSetTail(t, node),说明pre是全部指向正确的,但是都还没来得及修改next的指向,或许这种情形只会出现很短暂一会就回恢正常,但是我们要认识到一点就是:非队尾的节点,它的next可能是null,毕竟节点入队操作不是一个原子操作

线程竞争资源过程acquireQueued

线程在该方法中进行自旋,一直等到获取得到资源,可能会多次被阻塞以及解除阻塞,并且在其间如果被中断过则会记录下来,最后方法会返回在等待期间是否被中断过

    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)) {   //前置节点为head且获取到资源
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&   //获取资源失败,判断是否需要阻塞
                    parkAndCheckInterrupt())   //判断到线程需要阻塞,线程进入阻塞,并检查是否中断
                    interrupted = true;  //如果检查到线程中断了,则记录中断标志
            }
        } finally {
            if (failed)  //退出自旋并且未获取到资源,则取消节点
                cancelAcquire(node);
        }
    }
  1. 如果节点是head的后继节点才会有资格尝试获取资源tryAcquire,如果成功则成为新的head,并且旧head出队
  2. 如果不是head的后继节点或者是但获取资源失败,则判断(根据前置节点的状态判断)是否需要阻塞
  3. 如果确定要阻塞,则调用LockSupport.lock()进入阻塞,并且检查是否被中断过
  4. 如果被中断过,则记录中断标志
  5. 最后,退出自旋时会判断在自旋中是否已经成功获取到资源,如果否,则调用cancelAcquire()取消节点。在上面的代码中,退出自旋后才会进入finally块,而且只有failed==true时才会进行取消,但是正常情况下,线程是获取到资源才退出自旋(failed=false),所以只有在自旋的时候发生异常导出自旋中止,此时failed才为true,什么情况下会发生异常?线程被interrupt()会吗,不会,因为调用LockSupport.lock()进入的阻塞就算被中断也不会擦除中断位,也无异常,那么剩下发生异常最大的机率就是tryAcquire(自行实现同步器,或者可中断xxxInterruptibly(),超时xxxNanos())

根据以上顺序的逻辑往下分析各个步骤的方法:

步骤2,阻塞判断

    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL) //前置节点的状态为SIGNAL,表示前置节点释放资源时会唤醒当前节点
            return true; //返回true表示当前节点node可以安心进入阻塞了
        if (ws > 0) { //前置节点状态为CANCELLED时,相邻的所有CALCELLED状态的前置节点从队列中移除
            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.
             */
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

tryAcquire失败后,需要判断节点是否需要进行阻塞

  1. 如果前置节点状态为SIGNAL,表示前置节点在释放资源后会唤醒自己,那么就可以安心地进入阻塞了
  2. 如果前置节点状态>0,即CANCELLED,清除无效前置节点,不阻塞,继续自旋
  3. 如果前置节点状态为0或PROPAGATE,则把前置节点设置为SIGNAL,不阻塞,继续自旋

步骤3,阻塞及中断检查

    private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
    }

比较简单,使用LockSupport将自身线程阻塞,并且返回中断标志

步骤5,取消节点

    private void cancelAcquire(Node node) {
        if (node == null)
            return;
        node.thread = null;  

        Node pred = node.prev;
        while (pred.waitStatus > 0)   //这里再一次做了清除无效节点操作
            node.prev = pred = pred.prev;
 
        Node predNext = pred.next;
 
        node.waitStatus = Node.CANCELLED;

         if (node == tail && compareAndSetTail(node, pred)) {    //取消的节点是尾节点,出队
            compareAndSetNext(pred, predNext, null);
        } else {
             int ws;
            if (pred != head &&   //取消的节点不是尾节点,也不是头节点的后继节点
                ((ws = pred.waitStatus) == Node.SIGNAL ||  //如果前置节点为SIGNAL
                 (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&  //或者前置节点状态不为SIGNAL(0或PROPAGATE),则把前置节点状态设置为SIGNAL
                pred.thread != null) {
                Node next = node.next;
                //取消的节点出队,将前置节点与后继节建立连接
                if (next != null && next.waitStatus <= 0)   
                    compareAndSetNext(pred, predNext, next);  
            } else {  //取消的节点不是尾节点,且是头节点的后继节点,则唤醒取消节点的后继节点
                unparkSuccessor(node);
            }

            node.next = node; // help GC
        }
    }

取消节点可以分为3种情况来处理:

  1. 取消的节点是尾节点,将前置节点设置为新的尾节点,新的尾节点next设为null
  2. 取消的节点不是尾节点,也不是头节点的后继节点:如果前置节点状态不为SIGNAL则设为SIGNAL,然后将前置节点与后继节点建立连接,取消的节点出队
  3. 取消的节点不是尾节点,且是头节点的后继节点,则唤醒取消节点的后继节点,这里有点不一样,不会通过操作前置与后继节点来使得取消的节点出队,因为在唤醒后继节点且获得资源后,后继节点会成为新的head,所以取消的节点将不再在队列中
释放独占资源release
    public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)  //这里判断了h.waitStatus != 0,那么h.waitStatus < 0(SIGNAL),有效状态,需要唤醒后继节点
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

释放过程比较简单

  1. 调用tryRelease判断是否完全释放了同步资源
  2. 如果完全释放了,则进入unparkSuccessor进行唤醒头节点的下一个节点。这里的头节点其实就是当前要进行释放资源的线程(因为线程在获取到资源的时候会成为head)。

这里如果h.waitStatus == 0,则不会进入unparkSuccessor唤醒后继节点,为什么?有两种情况:

  1. h.waitStatus == 0是刚入队的初始状态,表示没变化过,h没后继节点
  2. h.waitStatus == 0是刚入队的初始状态,表示没变化过,h有后继节点,但是h执行到release此行判断时,后继节点还没执行到shouldParkAfterFailedAcquire->compareAndSetWaitStatus(pred, ws, Node.SIGNAL)将h设置为SIGNAL状态,后继节点需要先将h设置为SIGNAL才进行阻塞,所以h.waitStatus == 0时,后继节点还活跃着,不需要唤醒
    private void unparkSuccessor(Node node) {
 
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);  //node是正在释放资源的节点,如果它的状态还是有效状态,那么将状态设置为0,允许失败

        //从当前释放的节点选择一个后继节点,通过LockSupport.unportk唤起线程
        Node s = node.next;
        if (s == null || s.waitStatus > 0) {
            s = null;
            for (Node t = tail; t != null && t != node; t = t.prev)  //从tail往前遍历找到node的后缀节点
                if (t.waitStatus <= 0)
                    s = t;
        }
        if (s != null)
            LockSupport.unpark(s.thread);
    }
  1. 节点释放了资源,即将要出队,所以如果当前还是有效状态(<0),则将状态重置为0,失败也无所谓
  2. 唤醒后继节点,这里有一个问题,node.next可能为null,此时需要从tail往前(pre)找到node1进行唤醒,参考**新线程进入队过程addWaiter**的分析

共享模式实现

共享模式下,允许在同一时间多个线程访问同步资源.

顶层入口acquireShared
    public final void acquireShared(int arg) {
        if (tryAcquireShared(arg) < 0)
            doAcquireShared(arg);
    }
  1. 尝试获取资源tryAcquireShared(arg)
  • 返回负数:需要的资源arg>当前剩余的资源state,失败,剩余的资源数不变
  • 返回0:竞争成功,但没有剩余可用资源,更新剩余资源数state=0
  • 正数:表示成功,且有剩余资源,更新剩余资源数state=state-arg
  1. 如果失败则进入同步队列等待doAcquireShared
入队及等待资源
    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) {     //r>=0表示当前线程竞争资源成功
                        setHeadAndPropagate(node, r);   //当前线程成为head并根据剩余资源数量判断是否需要进行广播
                        p.next = null; // help GC
                        if (interrupted)
                            selfInterrupt();  //获取到资源后才响应中断,与独占一样
                        failed = false;
                        return;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node) &&  //与独占一样
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);//与独占一样
        }
    }

在独占模式中入队和等待分为两个方法,而在共享模型中统一到doAcquireShared,逻辑上和独占过程差不多,主要的区别是setHeadAndPropagate方法:当前线程竞争到资源后会成为head,但是在共享模式下还需要做多一件事,就是判断是否还有剩余资源,如果还有,则继续唤醒下一个线程(在独占模式时是在释放资源才唤醒后继节点的,共享模式允许多个线程同时占有资源,资源的获取与释放同时可能有多个线程在进行,如果资源还有剩余,则不必等持有资源的线程释放)

往后传播唤醒
    private void setHeadAndPropagate(Node node, int propagate) {
        Node h = head; // 记录旧头节点
        setHead(node);  //node成为新头节点

        //propagate > 0:表示还有资源剩余,肯定要唤醒后继共享节点
        //但是至于if中的其它判断条件看不明白为什么,既然资源都没剩余了,那么判断head和waitStatus有什么意义??
        if (propagate > 0 || h == null || h.waitStatus < 0 ||
            (h = head) == null || h.waitStatus < 0) {
            Node s = node.next;
            if (s == null || s.isShared())  //根据前面的分析,通过next获得的后继节点可能会得到null,依然传播下去。后继节点是共享节点,需要进行唤醒,如果后继节点是独占则不需要唤醒,唤醒一个独占节点将有很大机会再次进入阻塞,所以对于独占节点将由最后一个共享节点释放了资源后进行唤醒 
                doReleaseShared();
        }
    }

正如方法名称,方法做两件事:

  1. 获取到资源的node成为新的头节点
  2. propagate唤醒后继共享节点,这里的判断不能完全明白if (propagate > 0 || h == null || h.waitStatus < 0 || (h = head) == null || h.waitStatus < 0),有几个问题想不清楚:
  • 问题a. propagate表示剩余资源,是不是只根据propagate判断就可以了,只有propagate>0才进入下面的doReleaseShared
  • 问题b. h表示旧头节点,为什么会hnull这样的判断,好像不会出现hnull的情况啊
  • 问题c. 为什么要(h = head) == null || h.waitStatus < 0

问题a:

在遇到这几个问题的时候,参考了一个文章AbstractQueuedSynchronizer源码解读得到了启发,AQS的早期版本是没有Node.PROPAGATE的,这个文章分析了在引入Node.PROPAGATE前为什么会造成bug 6803402,而在引入Node.PROPAGATE后又是
如何解决了这个bug
在这里插入图片描述
源码对比

在旧版本中并没有对头节点进行判断,直接进入unparkSuccessor,新版有对头节点的判断并增入了doReleaseShared。写这个文章是基于jdk1.8,我们来看看,在1.8中不对头节点进行判断,只判断propagate>0是不是还会出现bug 6803402中有些线程不会被唤醒的现象。在原文中是Semaphore为例子指出bug产生的原因,归根到底,是因为并发释放资源而使得传播唤醒丢失,通过另外一个场景来分析造成问题的过程:

  1. t1时刻:同步状态state=3,分别由node1,node2,node3获取到,但是都没有释放,使得state:3->0。随后(node3 propagate之后)node4和node5入队,由于错过了propatage且获取不到资源,node4将node3.waitStatus设置为-1(SIGNAL)后阻塞,node5将node4.waitStatus设置为-1后阻塞,而node5.waitStatus还维持初始化状态
    在这里插入图片描述

  2. t2时刻: node1释放了资源state=1,在node1->releaseShared->doReleaseShared中把head(node3)的waitStatus设置为0并进入unparkSuccessor唤醒head的后继节点(node4),node4唤醒后tryAcquireShared成功,资源再次归0,但是node4还没执行到setHeadAndPropagate,所以此时head还是node3,state:1->0
    在这里插入图片描述

  3. t3时刻:node4还没执行到setHeadAndPropagate,所以head=node3,此时node2释放资源,state:0->1,node2->doReleaseShared中判断到head.waitStatus==0,所以将head.waitStatus设置为Node.PROPAGATE,并且退出自旋,记住这个时候state=1了,在共享模式下应该往后传播
    在这里插入图片描述

  4. t4时刻:在t2时刻中被唤醒的node4终于进入到setHeadAndPropagate(node4,propagate=0),node4成为新的head,所以如果只根据propagate>0进行判断,node4将不会传播下去唤醒node5,但是事实上在t3时刻node2释放了资源,此时node5如果被唤醒应该是马上获得资源的,就这样错过了一次传播。我们在上面的代码对比截图中看到加入了h == null || h.waitStatus < 0判断,h是旧头节点即node3,node3.waitStatus是t3时刻被node2设置为Node.PROPAGATE的,加上这个判断可以让传播顺利执行。
    在这里插入图片描述

在这个例子里面,如果node4释放了资源那当然会重新唤醒node5,整个队列也可以正常运行下去,正如文章AbstractQueuedSynchronizer源码解读的举例一样都是很极端的情况,很难控制上层是怎么样使用同步组件。既然并发下各种各样情况都会出现,那么直接去掉if判断是不是更加稳健?

问题b:
h==null,通篇AQS代码看了,除了在队列初始化的时候(第一个节点入队之前)head为null,但是当执行到setHeadAndPropagate之前肯定已经执行了addWaiter,说明了队列已经被初始化了,那么head应该不会再出现null的情况啊,为什么???

问题c:

我想不到如果没有(h = head) == null || h.waitStatus < 0会发生什么异常情况,网上也找不到推论,但是在源码变更的记录上看到一些作者提交的注解
在这里插入图片描述在这里插入图片描述

Recheck need for signal in setHeadAndPropagate

看起来像是为了加强程序的健壮性。在分析setHeadAndPropagate时不应该脱离doReleaseShareddoReleaseShared的自旋以及将状态及时设置为NODE.PROPAGATE与setHeadAndPropagate的if判断其实是相互补充的。

释放共享资源
    private void doReleaseShared() {
        for (;;) {
            Node h = head;
            if (h != null && h != tail) {  //h非null且有后继节点
                int ws = h.waitStatus;
                if (ws == Node.SIGNAL) {    
                    if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))  //确保先把SIGNAL设置为0才进行唤醒后继节点
                        continue;            // loop to recheck cases
                    unparkSuccessor(h);
                }
                else if (ws == 0 &&
                         !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))   //当h.waitStatus==0时,确保把h.waitStatus过渡到PROPAGATE
                    continue;                // loop on failed CAS
            }
            if (h == head)                   //如果在自旋过程中发现头节点已经改变,则继续对新的头节点进行上面的操作,否则退出自旋
                break;
        }
    }

首先要明确一件事是,doReleaseShared方法有两处入口,其一,节点在获取到资源后向后传播唤醒setHeadAndPropagate,另外一处是,节点在释放资源releaseShared
该方法主要做3件事:

  1. 如果头节点状态为SIGNAL,表示后继节点阻塞,需要进行唤醒unparkSuccessor
  2. 如果头节点状态为0,说明后继节点没阻塞或者即将阻塞,需要将状态设置为PROPAGATE,这里结合一下上面问题a来理解,如果不这样做,可能会出现传播丢失。
  3. 在自旋过程中,如果发现头节点改变了,那么要继续自旋对新的头节点重复1,2点操作,这是为了优化便得传播更迅速?

当ws==0时,为什么会出现else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))为false的情况?说明CAS修改为Node.PROPAGATE失败,即在CAS的时候被其它线程抢先修改了,期望值不再是0,那只能是被修改为SIGNAL或PROPAGATE两种

  1. 可能有两个线程同时进入到doReleaseShared,失败的线程在CAS中被成功的线程抢先将0更新为PROPAGATE
  2. 只有一个线程进入到doReleaseShared并刚判断完ws==0,此时,头节点的后继节点执行完shouldParkAfterFailedAcquire将h.waitStatus改为了SIGNAL

超时及响应中断

AQS作为更高级的同步器,无论独占模式还是共享模式都提供了超时以及对中断的响应功能,分别对应xxxInterruptibly,xxxNanos方法。

  • 对于响应中断的实现,只不过是在自旋时如果发现线程被中断过则及时进行中断响应,而不是吞掉到自旋结束才入到selfInterrupt处理中断
  • 而超时机制的实现则是完美地结合了LockSupport自带的超时等待功能parkNanos

AQS相关内容太多了,这里只着重分析了它的队列以及两种模式的实现,条件队列也是很重要的一块,打算把它作为单独出一文,还有也想结合一些常用的同步组件(信号量,重入锁,读写琐)进行分析,在应用层上来观察AQS应该能学到更多不一样的东西。在整个过程中很多细节的代码确实很难理解,如果通过代码倒推,难度实在是太大了,因为在并发的情况下各种各样的情况都会发生。在分析原理的过程最好能找到设计之初的相关资料,如果能明白到设计初衷,那肯定是直指问题本质了,网上很多相关的文章都一忽略了关键点的解释,但是也淘到了一些关于AQS的好文章记录在最后。

参考

https://segmentfault.com/a/1190000016447307

https://mingshan.fun/2019/02/02/aqs-shared/

https://www.cnblogs.com/micrari/p/6937995.html

http://www.qmwxb.com/article/1340258.html

http://gee.cs.oswego.edu/dl/papers/aqs.pdf

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!