• 队列同步器AQSAbstractQueuedSynchronizer原理分析


    AQS队列同步器,是并发包下的重中之重!全称AbstractQueueSynchronizer

    并发包下的许多同步组件都是基于它实现的,比如RetreentLock/CountDownLatch/CyclicBarrier等等,都是基于AQS来实现的;

    AQS内部维护了一个被volatile修饰的int型state变量并提供了3个方法来对state变量进行修改:

    getState()  // 获得stage状态变量
    setState()  // 设置state状态变量
    compareAndSetState() // CAS设置stage状态变量
    

      

    这3个方法的作用分别是获取state状态,设置state状态,以及CAS的方式来设置state()状态;

    同时如果你要使用AQS队列同步器来实现自己的同步组件的话,根据同步组件是独占类型还是共享类型,可以重写以下5个方法:

    tryAcuqire() // 独占式获取同步状态
    tryRelease()  // 独占式释放同步状态
    tryAcquireShared() // 共享式获取同步状态
    tryReleasedShard() // 共享式释放同步状态
    isHeldExcusived() // 判断是否被当前线程独占
    

      

    同步器对外提供的方法有:

    acquire() // 独占获取
    acquireInterruptibly() // 可中断获取
    tryacquireNanos() // 超时获取
    
    
    acquireShared() // 共享获取
    acquireSharedInterruptibly() // 可中断共享获取
    tryacquireSharedNanos() // 超时共享获取
    
    
    release() // 独占释放
    releaseShared() // 共享释放
    
    getQueuedThreads() // 获取在同步队列里等待的线程
    

    接下来我们分别分析一下独占式获取释放同步状态 / 共享式获取释放同步状态 以及 公平非公平的获取同步状态他们分别都诧异在哪里!

    0.前置介绍

    队列同步器看名字就知道内部肯定维护了一个同步队列,获取同步状态失败的线程会加入到同步队列的尾部,然后自旋的观察当前节点的前置节点是否是头节点且成功获取到了同步状态;

    从整体上来说,同步器包含了两个节点的引用,一个是指向头节点,另一个是指向尾节点;如下图所示:

     而获取同步状态失败的线程会构造节点加入到同步队列的尾部,但是同一时刻可能有多个线程获取同步状态失败,因此加入同步队列尾部这个事情是有风险的,所以AQS的处理方式是采用CAS加上失败重试的方式来完成线程节点的有序入队列的这个过程;

    不断的判断当前节点认为的尾节点是不是最新的尾节点,然后将自己加入进去,失败了就表明有其他线程捷足先登了,那这时候就重试,直到成功加入完,这个思路CAS+失败重试可以保证在不需要锁的情况下,完成线程的顺序排队,避免了互斥量的开销;

    而将当前节点设置为头节点的过程则不需要采用这种方式,因为将当前节点设置为头节点的时候,一定是已经获取到同步状态了,所以不需要加锁或者采用CAS进行同步;

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

    看代码:

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

      

    获取同步状态的时候会调用acquire()方法,而acquire方法里有3个非常重要的函数,我们一一来分析;

    adWaiter():

    private Node addWaiter(Node mode) {
     2     //以给定模式构造结点。mode有两种:EXCLUSIVE(独占)和SHARED(共享)
     3     Node node = new Node(Thread.currentThread(), mode);
     4     
     5     //尝试快速方式直接放到队尾。
     6     Node pred = tail;
     7     if (pred != null) {
     8         node.prev = pred;
     9         if (compareAndSetTail(pred, node)) {
    10             pred.next = node;
    11             return node;
    12         }
    13     }
    14     
    15     //上一步失败则通过enq入队。
    16     enq(node);
    17     return node;
    18 }

    首先!tryAcquire()失败才会执行到addWaiter()这个函数,而这个函数的作用就是构造线程节点并加入到同步队列的尾部,加入的时候先快速的使用CAS执行一遍,如果加入成功了就直接返回节点,而如果加入失败了就会进入到enq()函数当中使用自旋+CAS的方式来完成加入到同步队列的尾部的过程;

    private Node enq(final Node node) {
     2     //CAS"自旋",直到成功加入队尾
     3     for (;;) {
     4         Node t = tail;
     5         if (t == null) { // 队列为空,创建一个空的标志结点作为head结点,并将tail也指向它。
     6             if (compareAndSetHead(new Node()))
     7                 tail = head;
     8         } else {//正常流程,放入队尾
     9             node.prev = t;
    10             if (compareAndSetTail(t, node)) {
    11                 t.next = node;
    12                 return t;
    13             }
    14         }
    15     }
    16 }
    

    看enq的代码也可以验证我们刚刚说的加入同步队列尾部这个事情,是通过CAS + 不断自旋(失败重试)的方式来完成的!

    然后是acquiredQueue()这个函数,这个函数的功能主要是解决加入同步队列之后这个节点要干的事情;

    final boolean acquireQueued(final Node node, int arg) {
     2     boolean failed = true;//标记是否成功拿到资源
     3     try {
     4         boolean interrupted = false;//标记等待过程中是否被中断过
     5         
     6         //又是一个“自旋”!
     7         for (;;) {
     8             final Node p = node.predecessor();//拿到前驱
     9             //如果前驱是head,即该结点已成老二,那么便有资格去尝试获取资源(可能是老大释放完资源唤醒自己的,当然也可能被interrupt了)。
    10             if (p == head && tryAcquire(arg)) {
    11                 setHead(node);//拿到资源后,将head指向该结点。所以head所指的标杆结点,就是当前获取到资源的那个结点或null。
    12                 p.next = null; // setHead中node.prev已置为null,此处再将head.next置为null,就是为了方便GC回收以前的head结点。也就意味着之前拿完资源的结点出队了!
    13                 failed = false; // 成功获取资源
    14                 return interrupted;//返回等待过程中是否被中断过
    15             }
    16             
    17             //如果自己可以休息了,就通过park()进入waiting状态,直到被unpark()。如果不可中断的情况下被中断了,那么会从park()中醒过来,发现拿不到资源,从而继续进入park()等待。
    18             if (shouldParkAfterFailedAcquire(p, node) &&
    19                 parkAndCheckInterrupt())
    20                 interrupted = true;//如果等待过程中被中断过,哪怕只有那么一次,就将interrupted标记为true
    21         }
    22     } finally {
    23         if (failed) // 如果等待过程中没有成功获取资源(如timeout,或者可中断的情况下被中断了),那么取消结点在队列中的等待。
    24             cancelAcquire(node);
    25     }
    26 }
    

    看到这个函数的核心也是一个for(;;)不断自旋的过程;

    首先拿到前驱节点,然后判断前驱节点是不是头节点,如果前驱节点是头节点的话就会去尝试获取同步状态,如果获取到同步状态的话(我们自己重写的tryAcquire()方法),就会执行前置知识里我介绍的,将自己设置为头部节点,然后断开前置节点到本节点的引用关系(方便gc回收这个节点)

    而如果前置节点不是头节点的话会调用shouldParkAfterFailedAcquire()函数和parkAndCheckInterrput()函数来完成线程的等待操作;

    可能有同学会有疑问,前置节点如果不是头节点的话为什么这个节点的线程会醒呢?比如说如果主动interrupt来中断这个获取同步状态的线程的话,其实线程是会醒的,从LockSupport.park()中醒来,醒在for(;;)的自旋里然后发现自己好像并不符合获取同步状态的要求,这时候就会连续调用 shouldParkAfterFailedAcquire()函数和parkAndCheckInterrput()函数,然后再次睡过去~

    下面我们来看一下这两个函数的作用分别是什么,第一个函数实现的功能其实是进行前置节点的状态检查,检查自己能不能在后续的过程中被唤醒,也就是检查前置节点是不是SIGNAL(-1)的状态,如果不是就不断的往前找,一直找到一个线程节点的状态是SIGNAL的,然后和这个线程节点建立起引用关系,保证后面的流程中你能够成功的把我叫醒,然后再安心的睡去;而parkAndCheckInterrupt()这个函数的功能就是最终调用unsafe的LockSupport.park()方法,然后安静的睡过去!(不用一直自旋,太消耗cpu了);

    代码如下:

    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
     2     int ws = pred.waitStatus;//拿到前驱的状态
     3     if (ws == Node.SIGNAL)
     4         //如果已经告诉前驱拿完号后通知自己一下,那就可以安心休息了
     5         return true;
     6     if (ws > 0) {
     7         /*
     8          * 如果前驱放弃了,那就一直往前找,直到找到最近一个正常等待的状态,并排在它的后边。
     9          * 注意:那些放弃的结点,由于被自己“加塞”到它们前边,它们相当于形成一个无引用链,稍后就会被保安大叔赶走了(GC回收)!
    10          */
    11         do {
    12             node.prev = pred = pred.prev;
    13         } while (pred.waitStatus > 0);
    14         pred.next = node;
    15     } else {
    16          //如果前驱正常,那就把前驱的状态设置成SIGNAL,告诉它拿完号后通知自己一下。有可能失败,人家说不定刚刚释放完呢!
    17         compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    18     }
    19     return false;
    20 }
    

      

    这个函数的执行流程是,先拿到自己的前驱节点,然后判断前驱节点的状态,如果已经是SIGNAL的话,那就意味着自己肯定有机会被唤醒,这时候就可以执行后面的park函数,睡过去就行了;

    而如果WS > 0 意味着前驱节点已经从中断返回又或者超时返回了,这时候会在SIGNAL节点和当前节点之间横加阻碍,就必须不断的往前找,一直找到一个可以正常通知的状态节点,然后将这个正常的节点的状态设置为SIGNAL状态,然后再安心的睡去;

    再看下parkAndCheckInterrupt这个函数:

    private final boolean parkAndCheckInterrupt() {
    2     LockSupport.park(this);//调用park()使线程进入waiting状态
    3     return Thread.interrupted();//如果被唤醒,查看自己是不是被中断的。
    4 }
    

    这个函数的作用就是调用LockSupport.park()方法来完成线程的睡眠,如果唤醒之后,会判断是否被中断 Thread.interrputed()

    至此,整个acquire()的过程就分析完了,从这整个复杂的过程中我们也能得到一个结论:我们同步组件的实现者只需要利用AQS给出的get/set/CASSet state状态的方法来完成同步资源的获取和释放的能力,至于如何入队列阻塞,如何唤醒,如何再次获取同步状态都是并发包的作者帮我们实现好了的,进一步降低了我们的开发成本;

    同步状态的释放过程:

    看下主要的核心函数:

    public final boolean release(int arg) {
    2     if (tryRelease(arg)) {
    3         Node h = head;//找到头结点
    4         if (h != null && h.waitStatus != 0)
    5             unparkSuccessor(h);//唤醒等待队列里的下一个线程
    6         return true;
    7     }
    8     return false;
    9 }
    

    可以看到,同样也是先调用了我们自己重写的tryRelease()的方法来进行同步状态的释放,这里要提一点,独占式的同步状态的获取或者释放的tryAcquire() / tryRelease()函数返回值都是boolean类型的! 到共享式的时候,就都不是boolean了,先卖个关子

    这个函数调用我们自己重写的tryRealse来进行资源的释放,如果释放成功(boolean返回值为true),就找到头节点,然后调用unparkSuccessor(h)来唤醒头节点的后续节点(仅一个)

    private void unparkSuccessor(Node node) {
     2     //这里,node一般为当前线程所在的结点。
     3     int ws = node.waitStatus;
     4     if (ws < 0)//置零当前线程所在的结点状态,允许失败。
     5         compareAndSetWaitStatus(node, ws, 0);
     6 
     7     Node s = node.next;//找到下一个需要唤醒的结点s
     8     if (s == null || s.waitStatus > 0) {//如果为空或已取消
     9         s = null;
    10         for (Node t = tail; t != null && t != node; t = t.prev) // 从后向前找。
    11             if (t.waitStatus <= 0)//从这里可以看出,<=0的结点,都是还有效的结点。
    12                 s = t;
    13     }
    14     if (s != null)
    15         LockSupport.unpark(s.thread);//唤醒
    16 }
    

    这个函数的作用是如果头节点的状态还<0(正常状态),就将头节点的状态置为初始化状态,然后找到头节点的后续节点,如果后续节点为空或者后续节点的状态已经取消,那就从后往前找同步队列,把里面的有效节点找出来,然后唤醒它,完成了后续节点的唤醒操作!

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

    共享式同步状态的获取与释放用的是acquireShared()和releaseShared()方法!我们来看一下它和独占式同步状态的获取和释放有什么不同?

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

      

    可以看到acquire()方法还是会调用我们自己重写的tryAcquireShared()方法,但是就像我刚刚文章里面说的tryAcquireShared()的方法和独占式的不同之处在于它的返回值是int类型的而不是boolean类型的;

    这时候应该对应着3种状态: > 0 获取同步资源成功且仍然有剩余资源  = 0 获取同步资源成功且没有剩余资源 < 0 获取同步资源失败;

    如果tryAcquireShared()<0的话意味着没有成功获取到同步资源,那么相应的这时候需要构造线程节点然后加入到同步队列的尾部;我们看一下doAcquireShared()是不是做了这些事情:

    private void doAcquireShared(int arg) {
     2     final Node node = addWaiter(Node.SHARED);//加入队列尾部
     3     boolean failed = true;//是否成功标志
     4     try {
     5         boolean interrupted = false;//等待过程中是否被中断过的标志
     6         for (;;) {
     7             final Node p = node.predecessor();//前驱
     8             if (p == head) {//如果到head的下一个,因为head是拿到资源的线程,此时node被唤醒,很可能是head用完资源来唤醒自己的
     9                 int r = tryAcquireShared(arg);//尝试获取资源
    10                 if (r >= 0) {//成功
    11                     setHeadAndPropagate(node, r);//将head指向自己,还有剩余资源可以再唤醒之后的线程
    12                     p.next = null; // help GC
    13                     if (interrupted)//如果等待过程中被打断过,此时将中断补上。
    14                         selfInterrupt();
    15                     failed = false;
    16                     return;
    17                 }
    18             }
    19             
    20             //判断状态,寻找安全点,进入waiting状态,等着被unpark()或interrupt()
    21             if (shouldParkAfterFailedAcquire(p, node) &&
    22                 parkAndCheckInterrupt())
    23                 interrupted = true;
    24         }
    25     } finally {
    26         if (failed)
    27             cancelAcquire(node);
    28     }
    29 }
    

    可以看到同样是构造线程节点,但是这次构造的是共享类型的线程节点;

    然后就会进入到一个自旋的过程,如果前置节点是头节点,就会尝试去获取同步状态,如果同步状态获取成功,就会将当前节点设置为头节点,然后断开原头节点到当前节点的引用,同样如果不满足要求的话(中断或者超时),就会调用shouldParkAfterFailedAcquire() && parkAndCheckInterrupt()函数来完成和前置节点构建通知的关联关系以及睡眠的操作!

    和独占式获取同步状态不同的地方在于 tryAcquireShared()返回值是int类型,所以这里判断是否获取成功用的是 >= 0 并且设置头节点不仅仅是只设置自己为头节点,会调用: setHeadAndpropagte()这个方法!

    我们进一步来看一下这个 setHeadAndPropagate()这方法到底做了啥!

    private void setHeadAndPropagate(Node node, int propagate) {
     2     Node h = head; 
     3     setHead(node);//head指向自己
     4      //如果还有剩余量,继续唤醒下一个邻居线程
     5     if (propagate > 0 || h == null || h.waitStatus < 0) {
     6         Node s = node.next;
     7         if (s == null || s.isShared())
     8             doReleaseShared();
     9     }
    10 }
    

    可以看到,这个函数做了两件事情,将自己设置为头节点,然后如果还有剩余的可用资源或者原来的头节点为空或者原来头节点的状态<0的话,就找到当前节点的后续节点,如果后续节点为空或者是共享状态的话,就调用doReleaseShared()方法来唤醒后续节点!

    至于这个doReleaseShared()我们到共享式释放同步状态的时候再看;

    共享式释放同步状态:

    releaseShared()

    public final boolean releaseShared(int arg) {
    2     if (tryReleaseShared(arg)) {//尝试释放资源
    3         doReleaseShared();//唤醒后继结点
    4         return true;
    5     }
    6     return false;
    7 }
    

     可以看到,这里还是会调用我们自己的tryReleaseShared()的方法来释放共享资源,如果成功释放之后,就会调用doReleaseShared()的方法来唤醒后既节点;和setHeadAndPropogate()方法一样,都是调用doReleaseShared()的方法!

      

    private void doReleaseShared() {
     2     for (;;) {
     3         Node h = head;
     4         if (h != null && h != tail) {
     5             int ws = h.waitStatus;
     6             if (ws == Node.SIGNAL) {
     7                 if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
     8                     continue;
     9                 unparkSuccessor(h);//唤醒后继
    10             }
    11             else if (ws == 0 &&
    12                      !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
    13                 continue;
    14         }
    15         if (h == head)// head发生变化
    16             break;
    17     }
    18 }
    

      

    doReleaseShared()方法,先拿到head节点,然后判读head节点的状态,如果是SIGNAL状态,将head状态置为0后,就继续唤醒后继节点;unParkSuccessor()我们之前已经介绍过了这里就不再介绍了

    公平锁和非公平锁

    这里补充一下,其实我在看AQS的时候一直有一个非常强烈的疑问,那就是AQS既然是队列同步器,而且他维护了一个线程节点的同步队列,这个队列又是FIFO的,那还谈什么公平非公平之说啊!!!!

    都是强先入先出的,而且从上面的代码分析中我们可以看到非常强的顺序性,必须前置节点是head节点的时候才有能力去尝试获取同步资源,那到底是怎么实现这个非公平的呢,或者说基于现有的AQS的架构,是怎么实现非公平的获取锁的机制的呢?

    我们就以ReentrantLock为例子,看一下到底它的tryAcquire()和tryRelease()方法到底是咋写的:

    ReentrantLock tryAcquire()方法:

    final boolean nofaireTryAcquire(int acquires) {
    	final Thread current = Thread.currentThread;
    	int c = getStatus();
    
    	if (c == 0) {
    		if (compareAndSetStatus(0, acquires)) {
    			setExclusiveownerThread(current);
    			return true;
    		}
    	} else if (current == getExclusiveOwnerThread()) {
    		int nextc = c + acquires;
    		if (nextc < 0) { // 溢出
    			throw new Error("Maximum lock count exceeded!");
    		}
    		setStatus(nextc);
    		return true;
    	}
    	return false;
    
    }
    

      可以看到ReentrantLock是一把支持可重入的锁,如果status = 0,那就调用CAS的方式来更新同步状态,而如果不是0,就判断当前线程是不是获取到同步状态的线程,如果不是返回false,如果是的话,就继续可重入,将线程资源继续往上加!其他的情况都返回false

    我们再来看一下公平锁是如何写的这个tryAcquire()的:

    protected final boolean tryAcquire(int acquires) {
    	final Thread current = Thread.currentThread();
    	int c = getStatus();
    
    	if (c == 0) {
    		if (!hasQueuedPredcessors() && compareAndSetStatus(0, acquires)) {
    			setExclusiveownerThread(current);
    			return true;
    		}
    	} else if (current == getExclusiveOwnerThread()) {
    		int nextc = c + acquires;
    		if (nextc < 0) { // 溢出
    			throw new Error("Maximum lock count exceeded!");
    		}
    		setStatus(nextc);
    		return true;
    	}
    	return false;
    }
    

      可以看到公平锁的tryAcquire方法并不能够一进来就可以去CAS的设置同步资源,必须要先入队列判断一下是否还有在队列中等待的线程,如果有的话那就返回false, 表示tryAcquire过程失败,然后后续就会自动被AQS加入到同步队列中去了,这样就可以成功利用AQS的FIFO的特性实现公平锁;

    对于非公平锁的情况,同一时间有多个线程在获取资源,它们调用tryAcquire()的时候都会同时的尝试去获取同步状态,而不用管队列中还有没有等待的线程节点;就是每个新加入同步资源争夺的线程都有机会尝试获取同步状态,一旦获取不成功就进入到队列中排队获取,所以从这个层面上来说,可以这么理解,就是新来的都是吃香的,蜜月期,待遇不一样,但是一旦你获取失败了进入到地狱AQS中,对不起,强FIFO,给我排队等,就这样,并发获取资源是非公平的,队列里面的线程还是强FIFO的!

  • 相关阅读:
    mysql中删除重复记录,并保留重复数据中的一条数据的SQL语句
    架构设计文档提纲简描
    各大互联网公司架构演进之路汇总
    大数据安全规范
    生活启示
    mysql数据库分库分表(Sharding)
    大数据风控指标----查准率与查全率
    YARN和MapReduce的内存设置参考
    不要在linux上启用net.ipv4.tcp_tw_recycle参数
    [Spark经验一]Spark RDD计算使用的函数里尽量不要使用全局变量
  • 原文地址:https://www.cnblogs.com/kerwins-AC/p/15676068.html
Copyright © 2020-2023  润新知