AbstractQueuedSynchronizer (AQS)

Lock 接口

锁一般用来同步多个线程访问共享资源,保证线程安全。锁的内存语义和 volatile 内存语义相同:

这也是为什么 volatile 和锁都具有可见性的原因!!

我们经常使用 synchronized 关键字来充当锁,它可以锁一个对象或者一个类,但它是隐式地获取锁和释放锁,也就是当程序进入被 synchronized 修饰的同步代码块时获取锁,离开同步代码块时释放锁

本部分要介绍的 Lock 属于显示地获取锁和释放锁,也就是需要主动调用lock()获取锁,调用unlock()释放锁。Lock 接口具有 synchronized 关键字所没有的一些特性:

特性描述
尝试非阻塞地获取锁如果线程没有成功获取锁,可以选择自旋等待一段时间,类似于轻量级锁
能被中断地获取锁获取锁的线程可以响应中断,如果发生中断会抛出异常,同时释放锁
超时获取锁在指定时间内获取锁,如果超时仍没有获取锁,直接返回

Lock 是一个接口,它定义了获取锁和释放锁的基本操作,API 如下:(推荐直接去看对应的源码)

方法名称描述
void lock()获取锁
void lockInterruptibly() throws InterruptedException中断的获取锁,可响应中断
boolean tryLock()非阻塞的获取锁,成功返回 true,失败返回 false
boolean tryLock(long time, TimeUnit unit) throws InterruptedException超时获取锁
void unlock()释放锁
Condition newCondition()获取等待通知组件

队列同步器

本篇文章的重点来咯!!

AbstractQueuedSynchronizer 根据字面意思:抽象地队列同步器,简称同步器,它是实现锁的关键,大部分同步组件也是基于 AQS 设计实现的

锁是面向使用者的,它定义了使用者与锁交互的接口,隐藏了实现细节;同步器是面向锁的实现者,简化了锁的实现方式,屏蔽了同步状态管理、线程排队、等待唤醒等底层操作。具体的结构如下:

1

对于锁的使用者,它交互的对象的是上面介绍的 Lock 接口提供的方法;对于锁的开发者,它交互的对象是 AQS (同步器),继承 JDK 提供的 AQS 实现自定义同步器,按需重写五个方法即可

可以看到上面五个方法都是非抽象的,也就表示可以只重写其中部分方法,无须全部重写。一般要么重写独占式的两个方法,要么重写共享式的两个方法

介绍五个可重写方法的时候多次提到同步状态,它是一个 volatile 修饰的整型变量state,具有单操作对其它线程立即可见。AQS 提供了三个具有原子性的方法用来访问和修改state

从底层的设计可以看出实现锁的内存语义至少可以有两种方式:

现在来总结升华一波:对于锁开发者,只需要继承 AQS 类并重写所需的方法即可实现自定同步器,进而实现自定义锁,这个过程几乎不用涉及到操作系统底层,都已经封装好了

锁开发者实现自定义锁几乎就是依赖同步状态,只需要实现同步状态的获取和释放即可。可重入锁就是一个很好的例子,它就是通过控制state实现同一个线程可多次获取同一个锁

当一个线程尝试获取同步状态,会先判断是否为 0,不为 0 表示有其它线程获取了同步状态,然后再判断获取该同步状态的线程是否是当前线程,如果是,同步状态直接 +1,该线程获取到同步状态

释放同步状态也是同理,当线程释放同步状态会 -1,直至变为 0 才表示该线程彻底放弃了该同步状态,其它线程可以获取该同步状态

通过这样一个逻辑实现了可重入的功能,这些仅仅只是重写了tryAcquire()tryRelease()方法,是不是对锁开发者很友好!!

 

最后再扩展一下 AQS 使用到的设计模式!!为什么只重写tryAcquire()方法就可以实现同步状态的获取??这是因为 AQS 使用了模版方法设计模式

模版方法顾名思义就是已经设计好了一个方法的模版,即给出了每一个具体的步骤,可以自己实现部分功能定制化某个步骤以到达实现自定义该方法的效果

举个很常用的例子 🌰,Java 中的Arrays.sort()方法可以传入一个自定义的比较器实现不同效果的排序。它的底层原理就是使用了模版方法,设计好了排序所需的每个步骤,传入的自定义比较器定制化其中一个步骤,即:比较规则,最后实现了不同效果的排序,如:升序,降序,先根据 key 排序,key 相同再根据 value 排序等

这里结合模版方法浅析一下 AQS 获取同步状态的实现步骤:

同步队列结构

到目前为止我们介绍的内容还浮于表面,只是介绍了一下接口层的东西,并未深入到底层,比如 AQS 的结构?如何管理同步状态?未获取到同步状态的线程如何处理?排队?自旋?阻塞?如何等待唤醒线程?对于这些问题,我们一个一个来~

如果同步状态已经被线程 A 独占,那么线程 B 尝试获取失败时,会将线程 B 加入到一个同步队列中 (FIFO 双向队列),队列中的每个节点就是一个线程,结构如下:

2

因为它是一个 FIFO 的双向队列,所以主要操作是从队尾入队、从头部出队。当一个线程调用tryAcquire()获取同步状态失败时,会调用compareAndSetTail(pred, node)将节点插入到队尾

3

同步队列遵循 FIFO,首节点是成功获取同步状态的线程,首节点的线程释放同步状态时,会唤醒后继节点,而后继节点如果成功获取同步状态就会将自己设置为首节点,原首节点出队

由于设置新首节点是由成功获取同步状态的线程完成的,而成功获取同步状态的线程只可能有一个,即原首节点的后续节点,所以设置首节点不需要通过 CAS 来保证原子性

4

这里还要提一个细节:

节点结构

上面说过同步队列中的每一个节点都代表一个线程,那么节点中到底有什么呢?先来看看它的结构:(建议直接看 AbstractQueuedSynchronizer 源码对应的部分)

其它的没什么好说,最主要的就是节点的状态,有 5 种:

总结:负值表示节点处于有效的等待状态,而正值表示节点已被取消。所以源码中很多地方都用到< 0 or 0 <来判断节点的状态是否正常

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

首先解释一下独占的意思:同步状态任何时候都只允许一个线程获取,只有当线程释放同步状态后,其它线程才可以尝试获取

如果使用synchronized,那么当线程竞争失败后会进入阻塞状态;如果使用 AQS,那么当线程竞争失败后会进入等待状态,因为底层是调用LockSupport.park()

获取同步状态

通过上面对 AQS 结构的介绍,可以知道获取同步状态其实就是调用acquire(int arg)方法

函数流程如下:

下面直接上这些函数的源码,具体细节看注释:

如果一个线程的中断标志为 true,那么再调用LockSupport.park()将不会使线程等待,具体原因可见 park()/unpark(),而Thread.interrupted()就变成了关键,它可以清除中断标志

这里来一个小结:

下面给出独占式同步状态获取的流程,也是调用acquire(int arg)方法的流程:

5

释放同步状态

终于把最麻烦的获取部分整完了,剩下的内容就轻松些许了~~

通过上面对 AQS 结构的介绍,可以知道释放同步状态其实就是调用release(int arg)方法

函数流程如下:

下面直接上这些函数的源码,具体细节看注释:

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

共享式和独占式最大的区别就在于:共享式允许多个线程获取一个同步状态。最典型的例子就是对一个文件进行读操作,因为不会改变共享资源,所以可以允许多个线程同时获取一个同步状态

6

获取同步状态

获取同步状态是调用acquireShared(int arg)方法

函数流程如下:

下面直接上这些函数的源码,具体细节看注释:

释放同步状态

释放同步状态是调用doReleaseShared()方法

参考文章