从JDK1.5开始,Java提供了程序级同步锁(java.uitil.concurrent包下提供了不同功能的同步锁类),特别感谢Doug Lea大师,不仅提供了理论支持,同时提供了代码实现,本文对<<The java.util.concurrent Synchronizer Framework>>论文展开解读,了解同步锁背后的机制,透过同步机制将会帮助你更好的编写同步锁程序。
在java.util.concurrent包下的大多数同步类底层使用了AbstractQueuedSynchronizer类,AbstractQueuedSynchronizer类提供了通用功能:
JCP(Java社区组织,制订Java标准)在JSR166(Java标准提案-166)中提出需要提供轻量级(程序级别,与系统级别的synchronized相比而言)、通用同步功能的工具类,以13票(共16票)通过提案,在JDK1.5中实现了提案功能。
这些同步类本质上都是通过维护一个同步状态变量(state)来表示当前锁是否可用,同时提供了一系列方法来更新、监控同步状态,当同步状态表示锁不可用时,需要将请求锁的线程进行阻塞,当同步状态恢复为可用状态时,需要有唤醒机制将先前阻塞的线程进行唤醒操作。
我们举一个现实中类似的例子进行对比,有一家炸鸡店铺,店铺比较小,只有一个座位(类似于竞争资源),同一时间只允许一个人使用(这里可以用同步状态来表示,1-表示有人正在使用,0-表示没有人使用),当没人使用的时候可以直接使用,有人使用时,后续的人需要自觉排队等待,直到座位空闲。
同步工具类不仅要提供上述功能,同时需要考虑代码复用、编码的灵活性、功能的可扩展性,AbstractQueuedSynchronizer类抽取出了通用功能,最小化实现了同步功能,下面将详细分析AbstractQueuedSynchronizer类设计思路,需要实现的功能特性。(下文我们使用AQS来简称AbstractQueuedSynchronizer)
AQS类至少要提供两个方法:
这里需要注意上述两个方法不是接口定义,不同的同步类可以采用不同的方法名来表示上述功能,例如:Lock.lock、Semaphore.acquire、 CountDownLatch.await、FutureTask.get,这些方法都表示获取锁。在上述功能的基础上,同时还提供了其它非常实用的功能:
AQS必须支持两种模式锁:
在包java.uitl.concurrent下还提供了Condition接口,该接口实现了类似对象锁功能,Object.wait / Object.notify (通过对象对应的Monitor维护一个线程队列,记录线程状态)来阻塞和唤醒线程,Condition也支持监控模式,提供await/signal方法实现线程等待和唤醒线程操作。
Java内置锁(synchronized)的性能一直以来都是焦点问题,当前有很多文献提出了多种优化方法,但是大多数优化方法关注于降低空间消耗(这也是明智的选择,毕竟Java中一切皆是对象,而每一个对象都可以成为对象锁)。而对时间性能优化方法通常关注于无竞争条件下的优化,但是上述优化方法可以在编程上进行实施,例如开发人员只在需要同步的情况下设置锁,并且只有在有竞争的情况下设置同步方法。除此之外,JVM方面没有提供有效的方式来真正降低同步成本(存在竞争的条件下)。
AQS的理性性能目标是提供可伸缩性(scalability ):无论是否处于竞争状态,都能够可预见性地保持高效率。理想的情况下无论有多少线程参与竞争,所需的性能开销是常量(这个一般很难实现,这也是理想情况下的设想),当然这里理想目标很难实现,AQS希望最小化当允许某个线程通过同步点但当前还没有通过所需要花的时间(这里比较难以理解,我的理解是这样子的在多个线程竞争锁的条件下,如果锁处于可获取状态,那么唤醒等待线程来获取锁的时间要尽可能少,并且尽可能的避免线程竞争,因为竞争会导致必要的时间花销,例如5个线程竞争锁,那么4个线程竞争锁话的时间是浪费的),当然要考虑各方面资源消耗,例如CPU时间、内存竞争、线程调度开销,我们常见的自旋锁通常比阻塞锁能更快获得锁,自旋锁通过消耗CPU时间来避免阻塞(也会带来更多的内存竞争,不断的监控内存池中的变量状态,查看锁是否可用),不具有普遍适用性。
使用多线程通常可以分为两类目标(多线程使用才会带来锁的问题,因此也可以说是锁的使用方式):
无论AQS设计的如何精致,在特定的现实问题中总会存在不适用性,造成性能瓶颈。因此AQS不能设计成解决具体问题,而是需要提供一种通用的操作,能够监视和检查基本操作,让使用者能够更加便捷的通过这些操作发现性能瓶颈,至少要提供一种方法可以让使用者查看当前有多少个阻塞线程。
AQS最基础的功能就是提供一个获取操作(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;
对应的释放操作(release)如下:
/// 更新同步状态,这里不代表直接更新同步状态为空闲状态,因为之前提供共享锁,state状态将不只是空闲和不空闲两种
update synchronization state;
/// 判断是否允许阻塞线程获取锁
if (state may permit a blocked thread to acquire)
/// 唤醒一个或多个线程来获取锁
unblock one or more queued threads;
要实现上述操作,需要三个基础功能协调完成:
我们可以设计三个相互独立的基础模块,但是结构上来这不是很有效,三个基础模块需要相互协作来完成特定功能,例如,保存在队列中的信息必须与解除阻塞所需的信息相匹配,导出方法取决于同步锁模式(独占、共享)。
AQS核心设计思想是要保证上述三个基础模块能够相互协作,并且具体使用方式可以由使用者来决定,例如实现独占锁、共享锁,实现公平性、非公平性,这些特性由使用者来决定,AQS需要对这些功能的实现进行支持,这将限制AQS的技术方案,但同时为使用者提供了强有力的使用理由,并且AQS也能很好的完成这些任务。
在AbstractQueuedSynchronizer类中使用int(32位,4个字节,在JDK1.6中,新增了AbstractQueuedLongSynchronizer类提供了long(64bit,8字节)类型state)类型state,同时提供了
使用修饰符volatile修饰state,变量state就具有了以下特性(特性具体含义可以查看参考文档):
这样可以保证线程A修改state,线程B能够马上知道,但是要明白这不能保证线程操作安全,毕竟只有原子性操作才能保证状态同步。
compareAndSetState操作使用了CAS来保证操作state线程安全(CAS说明可以查看参考文档)。
将state限定为32bit int类型是基于实用性而做的决定,在JVM中(发表文章的时候)还没有全面提供long的原子性操作(例如在32位JVM存在非原子性访问风险),当然在JDK中提供了AtomicLong类可以实现原子性操作,但是内部使用了内部锁,因此无法保证性能,而且int类型足以保证绝大部分程序使用需求。
AQS类必须提供非阻塞方法tryAcquire和tryRelease,这些方法可以配合实现acquire和release操作
在JSR166提案之前,JDK没有提供可以阻塞和唤醒线程的API(通常说的API指的是java包下提供的公共类,像sun包下的类都不建议使用,只有java包的类提供的方法才是稳定的,sun包下的类提供的方法不保证在版本上的兼容性),AQS必须不依赖于内置监控器(同步变量对象都会有一个监控器来帮助实现线程阻塞、唤醒功能),那么只有两个可选的方法(官方标记为弃用状态):
这种独占模式很容易产生死锁,在论文提到这么一个问题,线程先调用了resume,然后在调用suspend方法,那么这个resume方法将不会起作用(是我的问题吗,我没明白这个会产生什么问题,或许是我功力不够吧)。
在java.util.concurrent.locks包下提供了LockSupport类来解决阻塞、唤醒线程问题。
LuckSupport可以解决上面提到问题(就是咱不理解的问题),假设有线程A,线程B,
这里需要特别注意LockSupport.unpark(A)方法不会被计数,也就是说不论上面是调用了1次还是1次以上LockSupport.unpark(A)方法,之后调用两次LockSupport.park(A)线程A肯定会进入阻塞状态。
AQS的核心是维护一个先进先出的线程队列,用于管理阻塞线程,因此不适合实现基于优先级的同步模式(排序操作对于维护同步阻塞线程队列的性能开销太大,不建议这样使用)。
使用不包含系统级别锁结构来实现线程队列将是最好的选择,这样就不会因为使用系统锁带来额外的性能损耗,当前有两种队列模式可以选择:
上述两种队列结构是基础,基于这两种结构后续提出了多种衍生变体。CLH队列特别适合自旋锁,通过研究,发现CLH比MCS更加符合AQS的需求,因为CLH能够更好的实现取消和超时两个功能,因此AQS选用CLH队列作为参考,实际实现上进行了调整,与CLH相比有一定的差别。
CLH实际上并不是一个标准的队列,它通过head和tail两个字段组建成一个链表,通过更新这两个字段完成出队和入队操作
新节点(node)的入队操作如下所示:
do {
///将pred设置为当前尾节点
pred = tail;
} while(!tail.compareAndSet(pred, node));
///在多线程环境下,有多个线程同时执行时会出现同步问题
///假设有A、B、C三个线程同时执行上述操作,三个线程分别将三个节点Na、Nb、Nc进行入队操作
///当A、B、C同时执行tail.compareAndSet(pred, node)时只有一个线程成功
///假设A线程成功执行,这个时候tail为Na,B线程执行tail.compareAndSet(pred, node)操作时,pred不等于Na执行失败
///线程C类似也会不成功,但是下次循环时,也只会有一个线程成功,直到所有线程操作成功为止,所有节点都将入队列
节点的状态变量status将保存在先前节点中,如果要实现自旋锁,要判断先前节点保存的的status变量:
while (pred.status != RELEASED) ; // spin
出队操作只需要将获取到锁的节点赋值给字段head:
head = node;
CLH有很多优点,入队和出队速度快,无锁(即使有竞争,也总有一个线程会成功执行入队操作),实现其他功能也很方便:
在原始的CLH版本中,并没有pred字段用来链接前节点,但是通过pred字段可以很方便地处理前节点的取消和超时情况,检测到前节点的status状态为取消时,可以将当前节点直接替换前节点。
CLH中没有提供后继节点字段next,在自旋锁模式下是不需要这个字段的,因为后继节点没有阻塞,可以检测前节点的状态来判断锁的状态,但是在其它模式下,后节点可能阻塞,因此前节点必须能够主动唤醒后继节点。
AQS队列中的节点包含next字段用于链接后继节点,由于程序上无法实现对双链表节点进行无锁的原子性插入,通俗的说就是没办法实现在一个原子性操作里面设置双向链表的两个节点相互链接(不使用synchronize同步方法的情况下),因此在AQS中将简单的使用赋值方式(其实这没有什么可说的,在先前的入队操作中已经说明多个线程只有一个会成功,那么可以保证成功之后的赋值操作也是正确的):
pred.next = node;
这个next字段只是一个辅助优化字段,即使没有这个字段也可以通过tail字段和节点的pred字段进行遍历,但是有了next字段那就可以很方便的查找后继节点。
相对于CLH的第二大修改就是节点的status不再指示是否自旋,而是用于控制线程阻塞,在AQS中,队列中的线程只有调用tryAcquire方法并返回true时才能返回,返回false将继续留在队列中,同时为了降低同步成本,规定只有头部节点才能执行tryAcquire方法,当然调用tryAcquire方法不一定会成功,仍然有可能失败,重新进入阻塞状态,判断节点是否为头部节点只需要判断节点pred字段是否为head即可。
节点状态字段status可以避免不必要的park和unpark方法调用,这些方法相对于synchronize阻塞原语来说,性能上有很大提升,但是JVM底层需要调用系统OS执行阻塞,因此不可避免的会出现性能开销,所以减少不必要的调用将提升程序性能。节点调用park方法之前会执行以下操作:
在上面2.1操作会直接导致执行park操作、而2.2和2.3将会再次执行1、2操作,直到成功执行1.1.1或者是2.1操作。
使用Java实现CLH与其它语言实现CLH的会有很大的不同,主要在于GC机制,Java自动GC会降低实现复杂性,当你确定用不使用时,可以将变量设置为null,加快回收,这些操作可以在出队操作中进行实现。
省略集体实现细节,acquire伪代码如下:
///尝试获取锁
if (!tryAcquire(arg)) {
///获取失败,将当前节点(节点包含当前线程、节点模式),放入队列
node = create and enqueue new node;
当前节点的前节点,可以通过node.pred获取
pred = node's effective predecessor;
///判断pred是否为head,如果是表示当前节点是第一个节点,允许尝试获取锁
while (pred is not head node || !tryAcquire(arg)) {
/// 如果前节点的status是signal值,那么表示前节点也在等待唤醒,当前节点执行park等待唤醒
if (pred's signal bit is set)
park();
else
///设置前节点status为signal
compareAndSet pred's signal bit to true; pred = node's effective predecessor;
}
head = node;
}
release伪代码如下:
///尝试释放锁,释放成功并且队列中的head的状态是signal,那么需要线程去主动唤醒
if (tryRelease(arg) && head node's signal bit is set) {
compareAndSet head's signal bit to false;
/// 如果存在下一个节点,主动唤醒下一个节点中的线程
unpark head's successor, if one exists
}
AQS实现了取消操作,这会导致acquire和release操作进行循环判断,取出已取消的节点,如果禁用取消操作,那么acquire和release操作的时间函数是将会是O(1),当然这要忽略park操作开销。
取消操作会从当前节点开始循环检测前节点是否为取消状态,将取消状态的节点从队列中剔除,这里需要注意,这些操作不一定会成功,因为存在多线程同时执行取消操作以及入队操作,如果操作失败,那也没关系,这表明有其它线程完成了该工作,循环的范围是0~N,N是队列长度,所有节点都取消的情况下会出现最坏情况,循环N次。取消操作不会导致线程阻塞,因此操作所花的开销有限。
在经典的生产者/消费者模式中,使用原生synchronized语法书写的伪代码如下:
class Container {
Object producterSignal;
Object consumerSignal;
public void notifyConsumer {
synchronized(consumerSignal) {
consumerSignal.notify()
}
}
public void waitConsumer {
synchronized(consumerSignal) {
consumerSignal.wait()
}
}
public void notifyProducter {
synchronized(producterSignal) {
producterSignal.notify()
}
}
public void waitProducter {
synchronized(producterSignal) {
producterSignal.wait()
}
}
}
这里忽略breads面包房获取操作和生产操作的线程安全问题
生产者
class Producer {
List breads;
Container signal;
public void run() {
while(true) {
/// 如果面包库存数量大于100,说明仓库满了,停止生产
if (breads.size() > 100) {
/// 唤醒处于饥饿的消费者
signal.notifyConsumer()
/// 库存足够、阻塞当前生产者,
signal.waitProducter()
return
}
生成面包
breads.add(new Bread())
///生产了面包,那么需要唤醒那些饥饿的消费者
signal.notifyConsumer()
}
}
}
消费者
class Consumer {
List breads;
Container signal;
public void run() {
while(true) {
/// 如果面包库存不足,需要唤醒生产者继续生产
if (breads.size() < 1) {
/// 唤醒停止生产的生产者
signal.notifyProducter()
/// 阻塞消费者等待新的面包
signal.waitConsumer()
return
}
消费面包
breads.get()
///消费了面包,那么需要唤醒生产者
signal.notifyProducter()
}
}
}
上述代码中的producterSignal、consumerSignal对象提供了monitor监视锁,当执行wait操作时,当前线程将会进入monitor的等待队列(同时会释放monitor锁,Java中的每一个对象都可以拥有一个monitor监视锁) ,AQS也提供了同样的功能,具体由ConditionObject类实现Condition接口(ConditionObject是AQS的内部类,因此必须先有AQS才能创建,ConditionObject实例只能和一个AQS绑定,一个AQS可以拥有多个ConditionObject实例):
signal,实现唤醒,类似notify
我们使用AQS来解决生产者消费者问题(实例中使用ReentrantLock,ReentrantLock底层使用AQS框架):
class Container {
Lock containerLock = new ReentrantLock();
Condition producerCondition = containerLock.newCondition();
Object consumerCondition = containerLock.newCondition();
public void notifyConsumer {
containerLock.lock()
try {
consumerCondition.signal()
} finally {
containerLock.unlock()
}
}
public void waitConsumer {
containerLock.lock()
try {
consumerCondition.await()
} finally {
containerLock.unlock()
}
}
public void notifyProducer {
containerLock.lock()
try {
producerCondition.signal()
} finally {
containerLock.unlock()
}
}
public void waitProducer {
containerLock.lock()
try {
producerCondition.await()
} finally {
containerLock.unlock()
}
}
}
这里忽略breads面包房获取操作和生产操作的线程安全问题
生产者
class Producer {
List breads;
Container signal;
public void run() {
while(true) {
/// 如果面包库存数量大于100,说明仓库满了,停止生产
if (breads.size() > 100) {
/// 唤醒处于饥饿的消费者
signal.notifyConsumer()
/// 库存足够、阻塞当前生产者,
signal.waitProducer()
return
}
生成面包
breads.add(new Bread())
///生产了面包,那么需要唤醒那些饥饿的消费者
signal.notifyConsumer()
}
}
}
消费者
class Consumer {
List breads;
Container signal;
public void run() {
while(true) {
/// 如果面包库存不足,需要唤醒生产者继续生产
if (breads.size() < 1) {
/// 唤醒停止生产的生产者
signal.notifyProducer()
/// 阻塞消费者等待新的面包
signal.waitConsumer()
return
}
消费面包
breads.get()
///消费了面包,那么需要唤醒生产者
signal.notifyProducer()
}
}
}
通过对比可以发现,在结构上都是相同的,唯一不同的是在锁的使用上,原生synchronized语法不需要主动释放锁,因为使用了{}划定了同步代码块,因此JVM会自动释放,AQS框架需要主动释放,这也提供了灵活性(特别注意获取和释放操作必须成对出现,并且注意在异常情况下必须保证释放操作执行,否则容易造成死锁问题),操作步骤如下:
我们之前讨论AQS框架下的acquire操作时也提供过,执行完acquire操作后有两种情况:
执行Condition的await操作也会将当前线程加入到等待队列,这里需要特别注意,这两个队列不是同一个队列,每一个AQS下都有一个CLH变体队列,同时AQS下的每一个Condition也有自己独立的一个链表队列,两个队列中的节点类型是一样的,但是两个队列是独立的。
可以看出Condition的await和signal的操作将会导致线程对应的节点在AQS下的队列和Condition下的队列之间进行转换,在这里要啰嗦一句,Condition下的await操作针对的是当前线程,而signal操作针对的是Condition队列中的节点(节点包含线程信息)
Condition不仅提供了基础的await、signal操作,而且提供了可取消等待(通过线程的interrupted标记位实现)、超时等待等方法。
AQS类将上述功能进行封装,作为一个同步器模版类,子类只需要实现:
tryAcquire、tryAcquireShared,分别作用于独占锁和共享锁
tryRelease、tryReleaseShared,分别作用于独占锁和共享锁
isHeldExclusively
需要注意的是AQS中的API方法不能直接提供给外部使用,因此需要将AQS子类作为类变量使用,重新包装对外暴露的方法,例如:ReentrantLock内部类Sync继承AQS,并且在ReentrantLock类中有一个Sync类型的成员变量,使用这种组合模式对外提供以下方法:
lock, 获取锁,阻塞式调用,会一直阻塞直到获取到锁
tryLock,尝试获取锁,无论是否获取到锁都会立即返回boolea类型值,true表示获取到锁
unlock,释放锁
上述的操作,都是通过委托给Sync类型的成员变量进行执行。
我们使用AQS来实现一个最小化的同步器Mutex,state的取值范围为0,1,
伪代码实现如下:
class Mutex {
/// 内部类,实现了AQS
class Sync extends AbstractQueuedSynchronizer {
public boolean tryAcquire(int ignore) {
return compareAndSetState(0, 1);
}
public boolean tryRelease(int ignore) {
setState(0);
return true;
}
}
/// 成员变量,负责实现具体同步操作
private final Sync sync = new Sync();
委托 Sync 类型成员变量执行获取操作
public void lock() {
这里的0表示获取资源数量,因为不需要计数因此设置为0
sync.acquire(0);
}
/// 委托 Sync 类型成员变量执行释放操作
public void unlock() {
sync.release(0);
}
}
AQS中的等待队列基于先进先出的策略进行操作,但是这不能完全保证公平性,例如之前说的tryAcquire方法(这个方法由子类实现),可以基于以下逻辑实现:
这里可以发现当前线程并没有判断队列是否有等待队列,而是直接尝试获取锁,如果恰巧获取到锁的线程释放了锁,但是还没有来得及唤醒后续等待线程,那么当前线程就捡漏的到了锁,可以理解为当前线程没有好好排队,而是进行了插队。这在一些情况下是可以忍受的,例如提高系统的吞吐量为第一目标的消息处理系统,当然在一些情况下是不可行的,例如之前的外卖派单系统。
实现公平性也很简单,只需要先检查队列是否有等待节点,如有那么直接返回false,如果没有再尝试去获取锁。
java.util.concurrent包下提供了很多不同功能的同步器,底层都是基于AQS实现同步功能,例如:
AQS是一个同步器基础框架,提供了同步器通用功能:
除了上述基础锁功能外还提供了ConditionObject对象来实现监视器模式:
同时还提供了取消锁获取、获取锁等待超时方法。
上述功能都是基于以下基础功能实现:
需要注意的是,本文都是从论文解读的角度介绍同步框架实现思路,在实际实现上有很多需要注意的细节,例如先前提到的同步节点Node,Node中的nextWaiter在AQS队列中存储的是锁模式,是否为共享锁,而在Condition队列中则表示下一个等待节点,还有Node在AQS中的入队操作以及出队操作都是细节满满,不理解这些细节内容你将很难理解特定情形的操作逻辑,例如,判断AQS中是否有等待节点的方法如下:
public final boolean hasQueuedPredecessors() {
// The correctness of this depends on head being initialized
// before tail and on head.next being accurate if the current
// thread is first in queue.
Node t = tail; // Read fields in reverse initialization order
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
同步类涉及的知识面很广,包括现代计算机处理架构、内存缓存结构、JVM中的线程实现模式(一对一、一对多、多对多)、JVM中的内存结构(主内存、线程内存)等等,有兴趣的同学在地下留言,我们可以继续展开讨论
The java.util.concurrent Synchronizer Framework 论文
技术更新换代速度很快,我们无法在有限时间掌握全部知识,但我们可以在他人的基础上进行快速学习,学习也是枯燥无味的,加入我们学习牛人经验:
点击:加群讨论