java并发之 AQS 详详详解

什么是AQS

在我们平常使用的jdk中,有这样一个包java.util.current,它是一个并发工具包,使得我们的并发编程变得轻松。
其中,有不少我们比较熟悉的工具,比如ReentrantLock,Semaphore,CountDownLatch,CycliBarrier等。它们都是并发的工具,也属于java中的锁一类的。
当我们开心的用它们时(也许并不开心),有没有想过这样一个问题:它们是怎么实现的呢?

于是,我们本文的主角AQS出场了,它是构建上述工具的核心或者说是基础。

AQS 的全称为(AbstractQueuedSynchronizer),这个类在 java.util.concurrent.locks 包下面。
可以叫它同步器。

  • 它是用来构建锁或者其他同步组件的基础框架
  • 上面提到的工具都是通过AQS来实现的
  • 我们自己也可以用AQS来自定义我们自己的同步组件(比如自己写一个互斥锁,twins锁什么的)
  • AQS的作用在于:将底层的如何竞争资源的机制给封装起来,提供简单的方法给程序员来实现资源获取;同时在获取共享资源失败时,提供线程阻塞、排队等待、唤醒竞争的机制来处理这些失败的线程。于是,使用了AQS之后,我们来定义同步组件时就不用担心如何来实现资源的获取,如果来建立同步队列排队机制,我们便可以把精力放在如何实现自定义组件的逻辑上来。
  • AQS 核心思想是:如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

AQS的组成

将AQS分为两部分理解的话会比较容易。

第一部分是AQS对同步状态的管理

  • AQS 使用一个 int 成员变量来表示同步状态
  • AQS提供了protected 类型的getState,setState,compareAndSetState三个方法来帮助我们对同步状态进行操作,这些方法的含义见名知意。通过这三个方法,我们可以获取同步状态,设置同步状态,以及CAS的修改同步状态。(AQS同步状态管理的核心)
  • 同时AQS要求我们要重写一定的方法,如下所示:
isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。
  • 这里的话,需要重写的方法分为两类,独占式和共享式,这个后面再说。总之这两类至少要实现一类,也可以都实现
  • 最终在使用AQS时,我们使用的并不是自己重写的方法,而是同步器提供的模板方法(模板方法可以理解为,整体的大逻辑已定,而具体小的实现方法是刚刚我们自己定义的。举个例子:我们有一个模板方法,其目的是吃饭,逻辑流程是 打开冰箱-取出食材-做菜-吃饭。整个大流程是固定的,但是取出什么样的食材,做什么样的菜,便是我们自己重写的方法)
  • 用这样一个示意图来理解吧:用户使用AQS提供的三个管理同步状态方法,重写一些方法,然后最后使用的是模板方法
    在这里插入图片描述
  • 经过这一个步骤,我们便可以基于AQS的机制来实现我们的自己的想法。

第二部分是AQS关于当同步状态获取失败之后,如何进行线程阻塞、线程排队、线程唤醒操作。
这里使用的是同步队列
在这里插入图片描述
如图所示:当线程获取同步状态失败之后,同步器会将当前线程以及等待状态信息构造成一个节点加入到同步队列中。

  • 这里实际是一个CLH锁
  • 同步器中,对于这个队列,只维护头节点与尾结点。
  • 当前获取同步状态的节点是头节点。
  • 当新的节点加入同步队列中时,需要考虑线程安全,所以有一个compareAndSetTail方法来保证(如果当时没有入队成功,会一直自旋尝试入队)。
  • 该队列明显是FIFO
  • 非头节点,之后会进入阻塞状态
  • 当头结点释放同步状态时,会唤醒自己的后继节点
  • 而关于同步队列中的节点,它有前驱有后继,同时,它有独占和共享两种类型。其中有一个十分关键的属性为:
    在这里插入图片描述
  • 这里主要就是signal,它非常重要,当前节点

AQS的使用

AQS的主要使用方式是继承,子类通过继承AQS并重写其所指定的方法来管理同步状态,在重写过程中,免不了要对同步状态进行修改,此时需要使用AQS提供的三个方法来进行操作(上面讲过,不赘述)。
子类推荐被定义为自定义同步组件的静态内部类。
具体我们可以看一个例子:
在这里插入图片描述
这是并发包里的Semaphore的结构示意图,它内部是集成了一个(定义了)静态内部类,这里因为要实现公平锁和非公平锁,所以总的来了一个抽象的Sync,不管,反正他是集成在自定义组件之中的,然后该类继承了我们的AQS。
在自定义组件具体实现功能时,就可以用Sync.acquire或则release等形式进行调用。

两种资源共享方式的实现

AQS针对共享资源有两种共享方式

  • 独占式:同一时刻只有一个线程持有同步状态,实现有:ReentrantLock等。
  • 共享式:同一时刻可以有多个(一般有上限)线程持有同步状态,实现有:Semaphore,CountDownLatch、CycliBarrier

(当然还有的同步组件,它是二者都用了,比如读写锁,它是读共享,写独占)

这里我们强调一下,只有获取同步状态失败的线程才会创建节点进入同步队列。

独占式的资源获取与释放:
我们这里直接看模板方法。
首先是acquire方法。获取

public final void acquire(int arg) {
    
    
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}
  • 该方法首先调用tryAcquire方法(我们重写的方法),来尝试获取同步状态,如果失败,进入下一步
  • 先调用addWait方法,该方法是将当前线程创建对应的节点,然后加入到同步队列中。这里具体的逻辑是先快速在尾部添加,如果添加失败(CAS失败或者尾结点为空),则进入循环,不断的尝试CAS添加。这里是用CAS来保证线程安全的。
  • 这里我们可以看到,如果此时队列为空时,会创建一个虚拟节点,虚拟节点存在的意义我们下面再讲。这里虚拟节点存在的意义是在于,保证每个真实节点前面都有一
 	//将节点加入同步等待队列,mode为Node.EXCLUSIVE
	private Node addWaiter(Node mode) {
    
    
	    //封装当前线程的Node节点对象
	     Node node = new Node(Thread.currentThread(), mode);
	     // 获取尾节点
	     Node pred = tail;
	    //如果尾节点不为空
	     if (pred != null) {
    
    
	         //将当前线程的前驱指针指向尾节点
	         node.prev = pred;
	         //cas方式将当前线程的节点对象设置为尾节点,如果成功,则将pred节点的后继节点指向当前线程节点
	         if (compareAndSetTail(pred, node)) {
    
    
	             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
	              //新建一个空节点,并将tail和head都指向这个节点
	              if (compareAndSetHead(new Node()))
	                  tail = head;
	          } else {
    
    
	              //如果尾节点不为空,则再次尝试将当前线程的节点添加至队列尾部
	              node.prev = t;
	              if (compareAndSetTail(t, node)) {
    
    
	                  t.next = node;
	                  return t;
	              }
	          }
	      }
  • 然后调用acquireQueued方法,该方法是一个死循环,逻辑为 先判断当前节点的前驱节点是否是头结点,如果是,则尝试获取同步状态。然后下一步是调用LockSupport提供的方法阻塞当前线程,等待头结点唤醒,源码如下
final boolean acquireQueued(final Node node, int arg) {
    
    
    //failed默认为true
    boolean failed = true;
    try {
    
    
        //中断标志默认为false
        boolean interrupted = false;
        //死循环
        for (;;) {
    
    
            //获取当前线程节点的前驱节点
            final Node p = node.predecessor();
            // 如果前驱节点是头节点,则再次尝试获取锁,如果成功,则将当前节点设置为头节点
            if (p == head && tryAcquire(arg)) {
    
    
                //设置头节点
                setHead(node);
                //将前驱节点的next指针置为null,方便gc回收
                p.next = null; // help GC
                //修改failed标识为false
                failed = false;
                //返回中断标志false
                return interrupted;
            }
            //如果前驱节点不是头节点或者获取锁失败,则判断是否需要挂起
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
    
    
        //如果获取状态失败,则取消同步器状态的获取,什么情况下会失败,可能只有异常的情况
        if (failed)
            cancelAcquire(node);
    }
}
  • 这里如果添加失败
    我们简单总结一下:当前线程首先尝试获取同步状态,如果失败,则创建节点,努力进入同步队列。
    进入之后判断一下自己的前驱节点是不是头结点,如果是,则尝试获取。前一步失败的话,则阻塞自己,等待头节点唤醒。(在阻塞之前,尝试获取了两次)

这里的阻塞,是调用的LockSupport的park方法,该方法会导致线程阻塞。
线程被唤醒有三种:其他线程调用unpark,线程被打断,其他异常情况。

这里关于同步状态,对于自定义同步组件,同步状态需要我们自己来设置,可以在构造函数中使用setState来设置,这里的逻辑我们可以定义同步状态变量无锁为0,有锁为1,所以在进行CAS的时候,就可以expect=0,update=1来获取同步状态了。

然后是释放

	public final boolean release(int arg) {
    
    
	    //如果释放锁成功
	    if (tryRelease(arg)) {
    
    
	        //获取头节点
	        Node h = head;
	        //如果头节点不为空且头节点的状态不等于0
	        if (h != null && h.waitStatus != 0)
	            //唤醒后继节点线程
	            unparkSuccessor(h);
	        return true;
	    }
	    return false;
	}

头结点作为持有同步状态的线程,当释放同步状态的时候,会唤醒自己的后继节点,让它来竞争同步状态。(如果是公平锁,肯定是头结点的后继节点了,但是如果是非公平锁,就可能是其他新来的线程了)

共享式的资源获取与释放:
也是来看模板方法的源码
获取

	public final void acquireShared(int arg) {
    
    
	   if (tryAcquireShared(arg) < 0)
	       doAcquireShared(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);
	  }
	}
  • 因为是共享式,所以和之前独占式的同步状态变量不太一样。比如我们设定state为N,当有一个线程获取同步状态时,便进行减一操作,如果为0,则说明当前持有同步状态的线程达到最大值了,其他线程需要像独占式里的那样,进入队列排队等待。
  • 分析下上面的代码。
  • 首先是模板方法acquireShared方法,其中先调用我们重写的tryAcquireShared方法,进行尝试获取,如果其返回值大于等于0,则说明成功,反之失败,进入下一步
  • 执行doAcquireShared方法,这里也是一个死循环,当前驱节点是头节点时,尝试获取同步状态,如果失败,阻塞自己,等待唤醒

独占式获取与共享式获取的区别
着重说一下,这里整体和独占式是没有什么区别的,只有一个很关键的地方setHeadAndPropagate(node, r);这个方法,我们可以翻到上面,发现在独占式中,使用的是setHead(node);
二者的区别在哪里呢:
来看前者的源码:

//设置队列的头节点,传入的参数为node和允许获取同步器状态的值
private void setHeadAndPropagate(Node node, int propagate) {
    
    
     //获取头节点
        Node h = head; // Record old head for check below
     //将当前线程设置为头节点
        setHead(node);        
     /**
     * 这里我们主要关注释放同步器状态的条件:
     * 1)propagate>0 表示允许获取同步器状态
     * 2)刚才拿到的head节点为null或者head节点的状态小于0即非CANCLLED状态
     * 3)再次去head节点,如果head节点为null或者head节点的状态小于0即非CANCLLED状态
     */
        if (propagate > 0 || h == null || h.waitStatus < 0 ||
            (h = head) == null || h.waitStatus < 0) {
    
    
            //获取当前节点的后继节点
            Node s = node.next;
            //后继节点为空或者后继节点是共享的
            if (s == null || s.isShared())
                //共享模式下修改头节点状态唤醒后继线程
                doReleaseShared();
        }
    }
//判断是否共享模式
 final boolean isShared() {
    
    
     return nextWaiter == SHARED;

唯一不一样的在于,独占模式下获取了同步状态,只是将当前线程节点设置为了头节点。
而共享模式在此基础上,唤醒后继的非独占模式节点。
(独占只有在释放时会唤醒)
这里有一个唤醒操作,所以该方法也是命名为Propagate,传递同步状态。

这里我们看一下doReleaseShared方法的源码 (这里有点写不动了,暂时就到这)
释放

	 //共享模式下释放同步器状态
	public final boolean releaseShared(int arg) {
    
    
	    //是否允许在共享模式下释放同步器状态,如果释放成功则返回true,失败返回false
	     if (tryReleaseShared(arg)) {
    
    
	         //如果释放同步器状态成功,则修改头节点,唤醒后继节点
	         doReleaseShared();
	         return true;
	     }
	     return false;
 }

这里跟独占式的差别在于此处的tryRelease方法必须确保同步状态安全释放,意味共享式有多个线程持有同步资源。

(后面的AQS相关同步组件再开一篇文章来写吧,太多了有点)

共享的话,实际上看源码的话,似乎在同步队列中的话,最多只有两个节点能够获取到同步状态,新来的线程应该是可以直接进行资源获取的,不用在队列中竞争,所以这是一种非公平的机制。所以在Semaphore中也有公平/非公平的模式来进行。公平的需要额外判断队列中是否有节点。

参考资料

AQS之共享模式
AQS之独占模式
JavaGuide
并发编程之 Semaphore 源码分析

猜你喜欢

转载自blog.csdn.net/qq_34687559/article/details/114240245
今日推荐