0、引言
自J2SE1.5开始,java中的同步类(Lock,Semphore等等)都基于AbstractQueuedSynchronizer(后文简称AQS)。AQS提供了一种原子式管理同步状态、阻塞和唤醒线程功能以及队列模型的简单框架。
本文主要是分析此框架的实现者Doug Lea写的一篇介绍AQS的论文(→猛戳这里拿原文←),并没有完全翻译原文,所以想看原文的在上面拿原文。
1、基本功能
同步器至少要有以下两种类型的方法acquire和release
- acquire:至少要有一个操作能实现对调用线程的阻塞,直到同步器允许它进行操作。
- release:至少要有一个操作能用一种方式解锁一个或者更多个已经阻塞的线程改变同步状态。
同时,同步器还需要支持以下几种功能:
- 非阻塞式的同步过程尝试(tryLock)
- 可选的超时机制,可以允许程序放弃等待
- 可以通过中断执行取消
而为了适应不同的同步器,同步器要支持两种模式
- 独占式exclusive。要保证一次只有一个线程可以经过阻塞点
- 共享式shared。可以允许多个线程阻塞点
2、性能要求
AQS对性能改进的关注点不是主要在于减小空间的开销和时间的开销。
原因是:对于开发者来说,只在需要的时候构建同步器,实在没有必要为了这部分空间的消耗去压缩空间。与此同时,同步器大部分情况是用在多线程的情况下,产生一些竞争也是可以想象到的。
AQS的性能重点在于可扩展性
AQS的几个改进目标:
- 可预见性的保证同步器的效率,甚至在其发生竞争的情形下。
- 减少那些已经被允许通过阻塞点的线程但是没有通过的消耗时间。
对比自旋锁来说,自旋锁的响应速度很快,但是在线程竞争特别激烈的情况下,由于大量的内存读取,会降低其响应的速度。
AQS的框架必须能够提供一些监视和检查的基本操作,以便用户发现和缓解瓶颈。例如:提供一个方式,决定多少个线程会被阻塞。
3、设计与实现
acquire和release操作的伪代码可以很容易的写出来如下:
acquire{
while (同步状态不允许获取) {
若当前线程没有入队,那么就将其入队;
可能会阻塞当前线程;
}
如果它已经入队了,就将其出队
}
release {
更新同步状态
if(状态表示允许一个阻塞的线程去acquire)
释放一个或多个队列中的线程
}
3.1、同步状态State
AQS采用了int(4个字节)的变量来持有同步状态。使用getState, setState, compareAndSetState方法来进行状态的获取和更新。
以上的方法,它们依赖于volatile这个机制来进行读写,compare-and-swap机制去实现compareAndSetState方法。(CAS操作如果不清楚的可以自行搜索相关的内容)
AQS是一个抽象类,它的tryAcquire和tryRelease方法都需要子类去实现。两个方法都支持传入一个int类型的参数。这个参数主要用来实现不同子类功能的。【例如】:reentrant lock,当在返回一个条件等待后重新去获取锁权限是,它会重新建立一个递归计数。
3.2、阻塞
AQS没有采用Thread.suspend
和Thread.resume
这两种方式,以上两种方式都有严重的安全问题,容易造成死锁等。
AQS采用了java.util.concurrent.locks包下的LockSupport类。该类可以响应中断操作,可以设置超时时间等。此机制与Win32内的“消费事件”机制,Linux NPTL线程库的方式类似。
3.3、核心队列
AQS框架的核心是阻塞线程的队列。也就是一个FIFO(先进先出)队列。AQS不支持基于优先级的同步器。
AQS的锁策略采用的CLH而不是MCS,原因是CLH要比MCS更适合处理取消和超时。
CLH队列的入队和出队操作是与它的锁操作息息相关。它有两个原子操作更新域,head和taiil。初始化时,将指向一个虚假的节点。
每个节点的release状态都保存在它的前驱节点内,while (pred.status != RELEASED);
后就可以开始自旋。若持有前驱节点的域,CLH锁可以处理超时和其他形式的取消操作。
AQS对CLH机制有两点修改
3.3.1、修改点1:增加后继节点访问域
AQS增加了节点node访问其后继节点的next域。由于AQS队列是双向队列,所以CAS操作也没有很好的方式对两个方向都做到完全的原子性更新。后继结点的更新就采用了
pred.next = node;
这种方式。
当后继结点可能出现退出的情形,AQS会通过pred域往前遍历确定是否是真的退出了。
3.3.2、修改点2:采用状态域控制阻塞
CLH采用自旋进行线程的阻塞,AQS没有采用这种方式,而采用之前介绍过的State字段进行阻塞。
AQS需要控制在头节点调用tryAcquire方法适合才允许通过,其他情况acquire和block都会失败。每次只需检查当前节点的前驱节点是不是head,这一点减少了CLH对内存的读取竞争,同时还能避免不必要的阻塞和唤醒操作。
【原因】:在调用park方法前,线程会设置一个“SIGNAL”信号,然后重新检查同步状态,再确定是否需要再次调用park方法。
3.3.3、修改点3:利用垃圾回收机制进行节点存储管理
AQS主要使用在出队的时候置null方式回收节点内存,这可以有效的避免复杂的处理和瓶颈。
这里我们可以给出更加具体的acquire方法
acquire {
if (!tryAcquire(arg)) {
node = 创建队列并且新入队节点;
pred = 节点的有效前驱节点;
while (pred 不是头节点 || !tryAcquire(arg)) {
if (pred的状态位是Signal信号)
park();
else
CAS操作设置pred的Signal信号;
pred = node节点的有效前驱节点;
}
head = node;
}
}
而release方法也可以得到如下:
release {
if (tryRelease(arg) && 头节点的状态是Signal) {
将头节点的状态设置为不是Signal;
如果头节点的后继结点存在,则将其唤醒。
}
}
【时间复杂度】acquire的循环次数由tryAcquire方法的性质决定。在不考虑线程等消耗,以及取消操作的情况下,它的时间复杂度是O(1)。在取消操作的情况下,确认前驱和后继结点后重置同步状态需要O(n)次遍历(n为队列的长度)。
3.4、条件队列
AQS中的ConditionObject提供一个能让同步器使用的类。它既符合Lock接口,又能持有互斥的同步机制。
ConditionObject类提供了类似await,signal,signalAll操作的API。这些方法的作用与Object.wait方法是一样的。ConditionObject使用与同步器同样的内部队列,不过与同步器存储在分开的条件队列中。
3.4.1、基本操作流程
基本的await操作如下:
- 创建并添加新的节点到条件队列中;
- 释放锁;
- 阻塞直到节点在锁队列中
- 重新获取锁。
基本的signal操作如下:
- 传递条件队列的第一个节点到锁队列中
3.4.2、取消流程
上述基本操作的实现并不困难,而处理取消,超时和线程的中断是实现的难点。
- 中断发生在await操作之前,此方法一定要抛出一个InterruptedException
- 中断发生在await操作之后,此方法不抛出异常,而是系统的中断状态集
条件队列需要一个状态位,当出现Signal信号失败,就将信号传递到队列的下一个节点内。而如果出现Cancel信号失败,就取消传递操作,唤醒锁的重新获取操作。
4、用法
4.1、控制公平性
AQS并不保证同步器一定是公平的。tryAcquire方法是在入队操作前的一个检验,因此完全可以在入队前,“偷取”获取的权限。
非公平的FIFO策略(获取到锁的顺序不一定是队列中的顺序),将tryAcquire方法中每次进入都会进行竞争,无论当前线程是否是队列的头节点。只要进入的线程速度更快,那么队列中的节点即使解除了阻塞,依然会重新阻塞回去。
公平的FIFO策略,只需要将tryAcquire方法在当前线程不是队列的头节点时放回失败就行。次之的方式,只需要判断队列是否为空,空队列就可以放回tryAcquire成功。
同步器的公平性设置主要是在多处理器情况下,才能发挥出其水平。多处理器往往会有更多的竞争,也就更有可能发生一个线程发现锁现在被其他线程需要的情形。
4.2、同步器
- ReentrantLock
- ReentrantReadWriteLock
- Semaphore
- CountDownLatch
- FutureTask(1.5以后不再使用AQS)
- SynchronousQueue
自我总结
这里开始就不是论文中的内容了。AQS的基本理解就在上述的文章中,后面我们再深入到AQS的源码中看它具体的实现方式。
AQS的核心内容就在于它处理阻塞和非阻塞的方式,如何用队列实现不同功能的同步器,如何控制同步器的性能。
如果你对上述问题还不了解,可以再深入看看原文。
参考文章
- Doug Lea. The java.util.concurrent Synchronizer Framework.
如有翻译不周到的地方,欢迎批评指正!