什么是AQS
全称 AbstractQueuedSynchronizer,它是一个框架,为同步状态的原子性管理、线程的阻塞和唤醒以及队列模型提供一种通用机制。
JAVA中的同步器(ReentrantLock,CountDownLatch,Semphore等等)都基于他所构建的
为什么要学
理解各类同步器是怎么实现的,理解并发
理解这个框架的设计思路和方法,可以学到一些抽象的思维
变的更强
基本功能
AQS定义了一个同步器至少包含两种方法
acquire:阻塞线程,直到同步状态允许其继续执行
release:释放线程,通过某种方式改变同步状态,使得一或多个被Acquire的线程继续执行
j.u.c包中并没有对同步器的API做一个统一的定义。有一些类定义了通用的接口(如Lock),而另外一些则定义了其专有的版本。因此在不同的类中,以acquire和release操作的名字和形式会各有不同。
例如:Lock.lock,Semaphore.acquire,CountDownLatch.await和FutureTask.get,在这个框架里,这些方法都是acquire操作
基于约定,每一个同步器还要实现以下的功能
阻塞和非阻塞的尝试(例如tryLock)
可选的超时设置,让调用者可以放弃等待
通过中断实现的任务取消
为了使框架能得到广泛应用,要支持以下两种模式的同步器
独占式 exclusive:在同一时间只有一个线程可以通过阻塞点
共享式 shared:允许多个线程通过阻塞点
例如ReentrantLock是使用独占式模式实现的,而CountDownLatch用的是共享式。
设计
同步器背后的基本思想非常简单。
acquire操作伪代码如下:
while (synchronization state does not allow acquire) {
enqueue current thread if not already queued;
possibly block current thread;
}
dequeue current thread if it was queued;
复制代码
翻译如下:
while (当同步状态不允许获取的时候) {
if(该线程没有入队){
入队
}
阻塞当前线程(可能)
}
将当前线程出队(如果入队)
复制代码
release操作位伪代码如下:
update synchronization state;
if (state may permit a blocked thread to acquire)
unblock one or more queued threads;
复制代码
翻译如下:
更新线程的同步状态
if(状态允许一个阻塞的线程去获取){
释放一个或者多个在入列的线程
}
复制代码
为了实现上面的acquire和release操作,需要下面这三个组件相互协作
同步状态的原子性管理
线程的阻塞与解除阻塞
队列的管理
创建一个框架分别实现这三个组件是有可能的。但是,这会让整个框架既难用又没效率。例如:存储在队列节点的信息必须与解除阻塞所需要的信息一致,而暴露出的方法的签名必须依赖于同步状态的特性。
所以AQS的核心其实是为以上三个组件提供一个具体的实现
下面我们来聊一下这个具体的实现
实现
同步状态
AQS类使用单个int(32位)来保存同步状态,并暴露出getState、setState以及compareAndSet操作来读取和更新这个状态。这些方法都依赖于j.u.c.atomic包的支持。
这个包提供了volatile在读和写上的语义,并且通过使用本地的compare-and-swap或load-linked/store-conditional指令来实现compareAndSetState,使得仅当同步状态拥有一个期望值的时候,才会被原子地设置成新值。这个也就是我们常说的CAS操作
基于AQS的具体实现类必须根据暴露出的状态相关的方法定义tryAcquire和tryRelease方法,以控制acquire和release操作。
当同步状态满足时,tryAcquire方法必须返回true
而当新的同步状态允许后续acquire时,tryRelease方法也必须返回true。
这些方法都接受一个int类型的参数用于传递想要的状态。
这个参数主要用来实现不同子类功能的,例如ReentrantLock使用该参数去操作线程的同步状态实现了重入的计数
阻塞
AQS没有采用Thread.suspend和Thread.resume这两种方式,以上两种方式都有严重的安全问题,例如容易造成死锁等。
AQS采用了j.u.c包下的LockSupport类。该类可以响应中断操作,可以设置超时时间等。
队列
整个框架的关键就是如何管理被阻塞的线程的队列,该队列是严格的FIFO队列,因此,框架不支持基于优先级的同步。
AQS的锁策略采用的CLH而不是MCS,原因是CLH要比MCS更适合处理取消和超时。
因此我们选择了CLH锁作为实现的基础。但是最终的设计已经与原来的CLH锁有较大的出入。
这里简单介绍一下CLH
CLH队列实际上并不那么像队列,因为它的入队和出队操作都与它的用途(即用作锁)紧密相关。它是一个链表队列,通过两个字段head和tail来存取,这两个字段是可原子更新的,两者在初始化时都指向了一个空节点。
一个新的节点,node,通过一个原子操作入队:
do {
pred = tail;
} while(!tail.compareAndSet(pred, node));
复制代码
每一个节点的“释放”状态都保存在其前驱节点中。因此,自旋锁的“自旋”操作就如下:
while (pred.status != RELEASED); // spin
复制代码
自旋后的出队操作只需将head字段指向刚刚得到锁的节点:
head = node;
复制代码
使用CLH锁有以下优点
入队和出队操作是快速、无锁的,以及无障碍的(即使在竞争下,某个线程总会赢得一次插入机会而能继续执行)
判断是否有线程正在等待也很快(测试一下head是否与tail相等)
“释放”状态是分散的(几乎每个节点都保存了这个状态,当前节点保存了其前驱节点的“释放”状态,因此它们是分散的,不是集中于一块的),避免了一些不必要的内存竞争。
为了将CLH队列用于阻塞式同步器,AQS做出了以下改进:
给每一个节点增加next域
在自旋锁中,一个节点只需要改变其状态,下一次自旋中其后继节点就能注意到这个改变,所以节点间的链接并不是必须的
但在阻塞式同步器中,一个节点需要显式地唤醒(unpark)其后继节点
所以AQS增加了节点node访问其后继节点的next域
由于AQS队列是双向队列,所以CAS操作也没有很好的方式对两个方向都做到完全的原子性更新。后继结点的更新就采用了下面的简单赋值
pred.next = node;
复制代码next链接仅是一种优化。如果通过某个节点的next字段发现其后继结点不存在(或看似被取消了),总是可以使用pred字段从尾部开始向前遍历来检查是否真的有后续节点
每个节点都有自己的状态字段用于控制阻塞而非自旋
论文这里作者用了很大的篇幅去写节点状态位的东西,我简单的归纳成两个问题:
一个released状态位够不够?
如果不够,还要哪些?加这些状态位有什么好处?
问题1解答:只有一个released位是不够的,AQS还需要当一个活动线程在头结点时候仅调用tryAcquire
在同步器框架中,仅在线程调用具体子类中的tryAcquire方法返回true时,队列中的线程才能从acquire操作中返回
单个“released”位是不够的,还需要确保一个活动的线程仅在队列的头部,调用tryAcquire方法,这时的acquire可能会失败,然后(重新)阻塞
这时候不需要一个前驱的状态去判断是否阻塞,直接可以判断这个前驱的节点是不是头部,不像自旋锁需要内存复制的竞争
但是取消状态还是要读前驱的状态
这个节点的状态还可以避免不必要的park和unpark,虽然这些方法跟阻塞原语一样快,但在跨越Java和JVM以及操作系统边界时仍有可避免的开销。
在调用park前,线程设置一个“唤醒(signal)”位,然后再一次检查同步和节点状态。一个释放的线程会清空其自身状态,这样线程就不必频繁地尝试阻塞。
依赖JVM回收节点内存,这就避免了一些复杂性和开销
AQS主要使用在出队的时候置null方式回收节点内存,这可以有效的避免复杂的处理和瓶颈。
抛开这些细节,基本的acquire操作的最终实现的一般形式如下
if(!tryAcquire(arg)) {
node = create and enqueue new node;
pred = node's effective predecessor;
while (pred is not head node || !tryAcquire(arg)) {
if (pred's signal bit is set)
park()
else
compareAndSet pred's signal bit to true;
pred = node's effective predecessor;
}
head = node;
}
复制代码
翻译如下
if (!tryAcquire(arg)) {
node = 创建队列并且新入队节点;
pred = 节点的有效前驱节点;
while (pred 不是头节点 || !tryAcquire(arg)) {
if (pred的状态位是Signal信号)
park();
else
CAS操作设置pred的Signal信号;
pred = node节点的有效前驱节点;
}
head = node;
}
复制代码
release的操作如下
if(tryRelease(arg) && head node's signal bit is set) {
compareAndSet head's bit to false;
unpark head's successor, if one exist
}
复制代码
翻译如下
release {
if (tryRelease(arg) && 头节点的状态是Signal) {
将头节点的状态设置为不是Signal;
如果头节点的后继结点存在,则将其唤醒。
}
}
复制代码
acquire操作的主循环次数依赖于具体实现类中tryAcquire的实现方式。
另一方面,在没有“取消”操作的情况下,每一个组件的acquire和release都是一个O(1)的操作(忽略park中发生的所有操作系统线程调度)
本文参考