JAVA并发编程之AQS(1)— AQS论文分析总结

1,642 阅读9分钟

什么是AQS

全称 AbstractQueuedSynchronizer,它是一个框架,为同步状态的原子性管理线程的阻塞和唤醒以及队列模型提供一种通用机制。

JAVA中的同步器(ReentrantLockCountDownLatchSemphore等等)都基于他所构建的

为什么要学

  • 理解各类同步器是怎么实现的,理解并发
  • 理解这个框架的设计思路和方法,可以学到一些抽象的思维
  • 变的更强

基本功能

AQS定义了一个同步器至少包含两种方法

  • acquire:阻塞线程,直到同步状态允许其继续执行
  • release:释放线程,通过某种方式改变同步状态,使得一或多个被Acquire的线程继续执行

j.u.c包中并没有对同步器的API做一个统一的定义。有一些类定义了通用的接口(如Lock),而另外一些则定义了其专有的版本。因此在不同的类中,以acquire和release操作的名字和形式会各有不同。 例如:Lock.lockSemaphore.acquireCountDownLatch.awaitFutureTask.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(状态允许一个阻塞的线程去获取){
    释放一个或者多个在入列的线程
}

为了实现上面的acquirerelease操作,需要下面这三个组件相互协作

  • 同步状态的原子性管理
  • 线程的阻塞与解除阻塞
  • 队列的管理

创建一个框架分别实现这三个组件是有可能的。但是,这会让整个框架既难用又没效率。例如:存储在队列节点的信息必须与解除阻塞所需要的信息一致,而暴露出的方法的签名必须依赖于同步状态的特性。

所以AQS的核心其实是为以上三个组件提供一个具体的实现

下面我们来聊一下这个具体的实现

实现

同步状态

AQS类使用单个int(32位)来保存同步状态,并暴露出getStatesetState以及compareAndSet操作来读取和更新这个状态。这些方法都依赖于j.u.c.atomic包的支持。

这个包提供了volatile在读和写上的语义,并且通过使用本地的compare-and-swapload-linked/store-conditional指令来实现compareAndSetState,使得仅当同步状态拥有一个期望值的时候,才会被原子地设置成新值。这个也就是我们常说的CAS操作

基于AQS的具体实现类必须根据暴露出的状态相关的方法定义tryAcquiretryRelease方法,以控制acquirerelease操作。

当同步状态满足时,tryAcquire方法必须返回true

而当新的同步状态允许后续acquire时,tryRelease方法也必须返回true。

这些方法都接受一个int类型的参数用于传递想要的状态。

这个参数主要用来实现不同子类功能的,例如ReentrantLock使用该参数去操作线程的同步状态实现了重入的计数

阻塞

AQS没有采用Thread.suspendThread.resume这两种方式,以上两种方式都有严重的安全问题,例如容易造成死锁等。

AQS采用了j.u.c包下的LockSupport类。该类可以响应中断操作,可以设置超时时间等。

队列

整个框架的关键就是如何管理被阻塞的线程的队列,该队列是严格的FIFO队列,因此,框架不支持基于优先级的同步。

AQS的锁策略采用的CLH而不是MCS,原因是CLH要比MCS更适合处理取消和超时。

因此我们选择了CLH锁作为实现的基础。但是最终的设计已经与原来的CLH锁有较大的出入。


这里简单介绍一下CLH

CLH队列实际上并不那么像队列,因为它的入队和出队操作都与它的用途(即用作锁)紧密相关。它是一个链表队列,通过两个字段headtail来存取,这两个字段是可原子更新的,两者在初始化时都指向了一个空节点。

一个新的节点,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的实现方式。

另一方面,在没有“取消”操作的情况下,每一个组件的acquirerelease都是一个O(1)的操作(忽略park中发生的所有操作系统线程调度)

本文参考