• 欢迎访问 winrains 的个人网站!
  • 本网站主要从互联网整理和收集了与Java、网络安全、Linux等技术相关的文章,供学习和研究使用。如有侵权,请留言告知,谢谢!

队列同步器(AQS)的设计原理

Java技术 winrains 来源:天堂同志 7个月前 (04-23) 45次浏览

1. 前言

  • 在Java中锁所可以分为两大类,一类是通过synchrinized关键字实现的隐式锁,一类是JUC包的锁。前者是通过JVM实现的,后者是根据队列同步器(AQS)实现的,也就是今天的主角。
  • 在JUC包下实现了很多锁以及工具类,例如ReentrantLock、ReadWriteLock、CountDownLatch、CyclicBarrier等,均是通过队列同步器实现的,所以理解了队列同步器的实现原理,对使用这些锁及工具类或者阅读这些类的源码会有很大帮助。

2. 什么是AQS

  • 队列同步器的全称是AbstractQueuedSynchronizer,简称AQS,翻译过来就是抽象的队列同步器。从命名就能猜出,这个类是一个抽象类,且是基于队列来实现的一个同步器。JUC包下所有的锁都是基于它来实现的。在AQS中定义了一个int类型的变量:state,用它来表示同步状态,哪个线程成功对state变量进行了加1操作,那么这个线程就持有了锁;AQS中还定义了一个FIFO(先进先出)的队列,用来表示等待获取锁的线程。
  • 在计算机领域,锁的实现都可以用管程模型来实现。管程模型的示意图如下,在管程模型中存在两个概念:入口等待队列和条件等待队列。既然锁都可以来管程来实现,那么JUC包下实现的锁中是不是也存在这入口等待队列条件等待队列呢?答案是肯定的。AQS中也存着两个队列:同步队列条件等待队列,它们分别对应管程中的入口等待队列条件等待队列。今天先分析AQS中的同步队列的数据结构和实现原理,关于AQS中条件等待队列会在Condition类的源码分析中讲解。
  • 关于管程的介绍可以参考这篇文章:管程:并发编程的基石。也可以阅读极客时间上《Java并发编程实战》一课中的第一部分第8讲:管程:并发编程的万能钥匙 【图】

3. AQS中的方法

  • AQS类提供了很多方法,既然是一个抽象类,就会有方法需要子类去重写。AQS中有如下方法需要子类重写。
方法 作用
protected boolean tryAcquire(int arg) 独占式尝试获取锁
protected boolean tryRelease(int arg) 独占式尝试释放锁
protected int tryAcquireShared(int arg) 共享式尝试获取锁
protected boolean tryReleaseShared(int arg) 共享式尝试释放锁
protected boolean isHeldExclusively() 当前线程是否独占式的占用锁
  • 上面子类重写的方法中,获取或者释放锁时都会尝试去修改同步状态state的值,在AQS中提供了三个和同步状态相关的方法。(上面的方法说明中都是用尝试二字,这是因为调用这些方法不一定能获取锁成功或者释放锁成功)
方法 作用
int getState() 获取同步状态state的值
void setState(int newState) 修改同步状态。通常是只有已经获取到锁的线程才调用这个方法去修改同步状态,这个时候因为只有一个线程能取到锁,所以不用担心并发问题
boolean compareAndSetState(int expect, int update) 通过CAS的方式去修改同步状态,当多个线程同时尝试修改state时使用,它能保证只有一个线程能修改成功
  • AQS的设计采用了模板设计模式,它定义了很多模板方法,在模板方法中会调用由子类重写的方法。这样就抽象出了锁实现的通用逻辑,而针对不同类型的锁的实现,只需要有给不同类型锁的同步组件在重写的方法中实现自己特有的逻辑即可。下面列出部分模板方法及其作用。
方法 作用
void acquire(int arg) 独占式获取同步状态,如果线程成功获取了同步状态,则方法会返回,如果没有获取到同步状态,那么当前线程就会进入到同步队列中,并阻塞。该方法对中断无法响应
void acquireInterruptibly(int arg) throws InterruptedException acquire()方法一样,不过该方法能响应中断
boolean tryAcquireNanos(int arg, long nanosTimeout) throws InterruptedException acquireInterruptibly()方法的基础上增加了超时限制,当指定时间内如果没有获取到同步锁,就会返回false。
void acquireShared(int arg) 共享式获取同步状态,如果线程成功获取到了同步状态,那么方法就会返回。否则进入到同步队列中进行等待,并阻塞。它与acquire()的区别是,该方法能允许多个线程同时获取到锁
void acquireSharedInterruptibly(int arg) throws InterruptedException acquireShared()方法的基础上增加了响应中断的功能
boolean tryAcquireSharedNanos(int arg, long nanosTimeout) throws InterruptedException acquireSharedInterruptibly()基础上增加了超时功能,在指定时间内如果没有获取到锁,就会返回false
boolean release(int arg) 独占式释放锁
boolean releaseShared(int arg) 共享式释放锁
  • 从上面的方法中可以发现,这几个模板都是成对出现的,独占式和共享式获取锁,能响应中断的独占式和共享式获取锁,能超时的独占式和共享式获取锁,释放独占式和共享式锁。所以实际上我们只需要弄明白acquire()方法和release()方法即可,其他的方法与这两个方法的实现几乎一样,只是改变了部分逻辑。
  • 独占式和共享式的区别:独占式表示的是只能有一个线程获取到锁,而共享式表示的是同一时刻允许有多个线程获取到锁。前者的实际应用有ReentrantLock,后者的实际应用有ReentrantReadWriteLock、CountDownLatch、CyclicBarrier等。这些类的源码以及实现原理后面会有文章专门分析。

4. 同步队列的设计原理

要想读懂AQS的源代码,首先需要明白它的设计原理,否则很难看明白其中的逻辑。毕竟代码只是具体实现的工具,编程语言可以多变,但设计原理是不变的。

4.1 数据结构

  • AQS中两大核心:同步状态同步队列。同步状态由state这个int类型的全局变量实现,哪个线程成功修改了state的值,就表示这个线程获取到了锁或者释放了锁。同步队列是一个遵循先进先出(FIFO)的队列,它是一个由Node节点组成的双向链表。每一个线程在获取同步状态时,如果获取同步状态失败,就会将当前线程封装成一个Node,然后将其加入到同步队列中。Node是AQS里面的一个静态内部类,Node这个数据结构中,包含了5个属性,每个属性的功能如下列表。Node就是通过这5个属性来实现同步队列等待队列的,关于等待队列今天先暂时不分析,后面在分析Condition源码时会详细分析。
属性名 作用
Node prev 同步队列中,当前节点的前一个节点,如果当前节点是同步队列的头结点,那么prev属性为null
Node next 同步队列中,当前节点的后一个节点,如果当前节点是同步队列的尾结点,那么next属性为null
Node thread 当前节点代表的线程,如果当前线程获取到了锁,那么当前线程所代表的节点一定处于同步队列的队首,且thread属性为null,至于为什么要将其设置为null,这是AQS特意设计的。
int waitStatus 当前线程的等待状态,有5种取值。0表示初始值,1表示线程被取消,-1表示当前线程处于等待状态,-2表示节点处于等待队列中,-3表示下一次共享式同步状态获取将会无条件地被传播下去
Node nextWaiter 等待队列中,该节点的下一个节点
  • 通过Node的prev属性和next属性就构成了一个双向链表,也就是AQS中的同步队列,但是想要通过这个队列找到队列中的每一个元素,我们就需要知道这个队列的头结点是谁,尾结点是谁。因此AQS中又提供了两个属性:headtail,这两个属性的类型均是Node类型,它们分别指向同步队列中的头结点和尾结点。这样AQS就能通过head和tail,找到队列中的每一个元素。同步队列的结构示意图如下。
同步队列示意图

4.2 实现原理

  • 当一个线程调用acquire()方法获取同步状态的时候,如果此时能成功获取到同步状态,那么就直接返回;如果不能获取到同步状态,此时就表示同步状态已经被其他线程获取到了,那么这个时候,当前线程就需要开始等待,那么如何实现等待呢?此时当前线程先现将自己封装成一个Node,然后这个Node加入到同步队列中。在加入到同步队列之前,需要判断队列有没有被初始化,即队列中有没有节点存在。如果head=null则表示当前同步队列还没有初始化,所以这个时候当前线程做的第一件事,就是初始化队列。如何初始化呢?当前线程需要先初始化head节点,因此它会new一个Node,然后将这个Node赋值给head,注意head节点表示的是获取到同步状态的线程。接着当前线程再将自己封装成一个Node,然后将head的next属性指向这个Node,这样就将自己加入到了队列中。注意head节点的thread属性始终都是null,因为head节点是当前线程创建的,而当前线程只知道有线程获取到了同步状态,但是却不知道是谁获取到了,所以此时当前线程在初始化head节点的时候,只能让head节点的thread属性为null。当前线程再将自己加入到队列之后,还需要将tail指向自己。在设置head属性和tail属性时,由于存在多个线程并发的可能,所以需要使用AQS提供的compareAndSetHead()、compareAndSetTail()方法,这两个方法会调用Unsafe类的CAS方法,能保证原子性。节点加入到同步队列的示意图如下。
节点加入到队列示意图
  • 同步队列中首节点表示的是获取到同步状态的线程,当首节点代表的线程释放了同步状态,由于AQS遵循FIFO,所以此时线程在释放同步状态后还需要唤醒后面节点的线程去获取同步状态。当有线程获取到同步状态后,需要将自己代表的节点设置为同步队列的首节点。由于此时肯定只有一个线程获取到同步状态,因此此时在更新head属性时,不需要通过CAS方法来保证原子性,只需要使用setHead()方法即可。首节点的设置的示意图如下。
首节点设置示意图

5. 总结

  • 本文主要介绍了AQS中各种API的作用,以及分类。最后通过对Node的数据结构分析了AQS的设计原理。
  • 下一篇文章将结合具体的源码来分析AQS是如何实现锁。

作者:天堂同志

来源:https://juejin.im/post/5db1491bf265da4d2e1215ac


版权声明:文末如注明作者和来源,则表示本文系转载,版权为原作者所有 | 本文如有侵权,请及时联系,承诺在收到消息后第一时间删除 | 如转载本文,请注明原文链接。
喜欢 (0)