深度解析Java 同步框架(java.util.concurrent包下的同步类)

简介

从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类至少要提供两个方法:

  • 获取锁方法(acquire),当线程调用获取锁方法时会出现两种情况
    • 锁空闲,允许调用线程直接获取,不会阻塞线程
    • 锁使用中,不允许调用线程直接获取,需要阻塞线程,直到锁出现空闲状态
  • 释放锁方法(release),拥有锁的线程调用释放锁方法将锁状态更改为空闲中,允许其它线程获取锁

这里需要注意上述两个方法不是接口定义,不同的同步类可以采用不同的方法名来表示上述功能,例如:Lock.lock、Semaphore.acquire、 CountDownLatch.await、FutureTask.get,这些方法都表示获取锁。在上述功能的基础上,同时还提供了其它非常实用的功能:

  • 非阻塞式获取锁,例如tryLock方法,无论能否成功获取到锁,都会直接返回,不会阻塞当前线程
    • 锁可用:返回true,并且获取到锁
    • 锁不可用:返回false,没有获取到锁
  • 指定超时时间获取锁,当线程获取锁时,可以指定超时时间,会出现以下三种情况
    • 当前锁可用:直接获取到锁
    • 当前锁不可用、但在超时时间内获取到锁,例如:线程调用获取锁方法,指定最多等待3s(这个时间可以根据业务进行调整),此时锁状态不可用,幸运的是在等待2s后获取到了锁
    • 锁不可用、并在超时时间内未获取到锁,例如:线程调用获取锁方法,指定最多等待3s(这个时间可以根据业务进行调整),此时锁状态不可用,在等待3s后还是没有获取到锁,只能返回超时异常,但是没有获取到锁
  • 可取消等待锁,线程因为获取锁而出现阻塞时,可以通过取消方法中断阻塞状态,直接返回(没有获取到锁,而是退出等待状态)

AQS必须支持两种模式锁:

  • 独占锁,在任何时候同一时间只允许一个线程拥有锁,大多数情况下独占锁模式就可以满足需求
  • 共享锁,同一时间允许多个线程拥有锁,例如计数信号量,可以设置一定数量线程拥有锁,秒杀活动只有5名用户可以参与购买,那么可以设置计数信号量为5,最多允许同时5个线程持有该锁,从而保证不会超卖。

在包java.uitl.concurrent下还提供了Condition接口,该接口实现了类似对象锁功能,Object.wait / Object.notify (通过对象对应的Monitor维护一个线程队列,记录线程状态)来阻塞和唤醒线程,Condition也支持监控模式,提供await/signal方法实现线程等待和唤醒线程操作。

性能指标

Java内置锁(synchronized)的性能一直以来都是焦点问题,当前有很多文献提出了多种优化方法,但是大多数优化方法关注于降低空间消耗(这也是明智的选择,毕竟Java中一切皆是对象,而每一个对象都可以成为对象锁)。而对时间性能优化方法通常关注于无竞争条件下的优化,但是上述优化方法可以在编程上进行实施,例如开发人员只在需要同步的情况下设置锁,并且只有在有竞争的情况下设置同步方法。除此之外,JVM方面没有提供有效的方式来真正降低同步成本(存在竞争的条件下)。

AQS的理性性能目标是提供可伸缩性(scalability ):无论是否处于竞争状态,都能够可预见性地保持高效率。理想的情况下无论有多少线程参与竞争,所需的性能开销是常量(这个一般很难实现,这也是理想情况下的设想),当然这里理想目标很难实现,AQS希望最小化当允许某个线程通过同步点但当前还没有通过所需要花的时间(这里比较难以理解,我的理解是这样子的在多个线程竞争锁的条件下,如果锁处于可获取状态,那么唤醒等待线程来获取锁的时间要尽可能少,并且尽可能的避免线程竞争,因为竞争会导致必要的时间花销,例如5个线程竞争锁,那么4个线程竞争锁话的时间是浪费的),当然要考虑各方面资源消耗,例如CPU时间、内存竞争、线程调度开销,我们常见的自旋锁通常比阻塞锁能更快获得锁,自旋锁通过消耗CPU时间来避免阻塞(也会带来更多的内存竞争,不断的监控内存池中的变量状态,查看锁是否可用),不具有普遍适用性。

使用多线程通常可以分为两类目标(多线程使用才会带来锁的问题,因此也可以说是锁的使用方式):

  • 提高系统吞吐量,多个线程一起干活,这里的原则是谁能干就多干,例如有一个订单支付系统,将订单支付消息通过MQ系统传递给统计服务,统计服务使用多个线程来并行处理消息,但是要保证不重复处理消息,一条消息只能处理一次,那么我们不关注某一条消息具体是哪个线程来处理,而是关注于这些消息什么时候能处理完成,需要多少个线程来处理
  • 资源分配需要公平性,这里需要考虑资源分配原则,讲究公平性,例如外卖派单系统,有10个外卖小哥,因为外卖订单不同,派送费也不一样,必须保证公平性,10个外卖小哥排队等待订单,顺序领取,不能插队。

无论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也能很好的完成这些任务。

同步状态(state)

在AbstractQueuedSynchronizer类中使用int(32位,4个字节,在JDK1.6中,新增了AbstractQueuedLongSynchronizer类提供了long(64bit,8字节)类型state)类型state,同时提供了

  • getState 获取状态
  • setState 非原子性设置状态
  • compareAndSetState CAS方式设置状态,可以理解为原子性操作state

使用修饰符volatile修饰state,变量state就具有了以下特性(特性具体含义可以查看参考文档):

  • 可见性(对JVM中的主存变量修改,其它线程能够立即获得)
  • 禁止指令重排优化(JVM会在保证程序正确执行的条件下对指令执行顺序进行优化,可以理解为多核CPU充分利用资源,加快程序执行,但是volatile变量存在时会禁止指令重排优化)

这样可以保证线程A修改state,线程B能够马上知道,但是要明白这不能保证线程操作安全,毕竟只有原子性操作才能保证状态同步。

compareAndSetState操作使用了CAS来保证操作state线程安全(CAS说明可以查看参考文档)。

将state限定为32bit int类型是基于实用性而做的决定,在JVM中(发表文章的时候)还没有全面提供long的原子性操作(例如在32位JVM存在非原子性访问风险),当然在JDK中提供了AtomicLong类可以实现原子性操作,但是内部使用了内部锁,因此无法保证性能,而且int类型足以保证绝大部分程序使用需求。

AQS类必须提供非阻塞方法tryAcquire和tryRelease,这些方法可以配合实现acquire和release操作

  • tryAcquire能够立即返回,无论锁是否可用
    • 锁可用时,获取到锁,并直接返回true
    • 锁不可用时,放弃获取锁,直接返回false
  • tryRelease能够立即返回,无论是否可以释放锁
    • 释放成功,直接返回true, 注意这里的释放成功表示锁可用,对于可重入锁ReentrantLock来说,state为0
    • 释放失败,直接放回false,表示锁不可用,对于可重入锁ReentrantLock来说,state大于0

阻塞、唤醒线程

在JSR166提案之前,JDK没有提供可以阻塞和唤醒线程的API(通常说的API指的是java包下提供的公共类,像sun包下的类都不建议使用,只有java包的类提供的方法才是稳定的,sun包下的类提供的方法不保证在版本上的兼容性),AQS必须不依赖于内置监控器(同步变量对象都会有一个监控器来帮助实现线程阻塞、唤醒功能),那么只有两个可选的方法(官方标记为弃用状态):

  • Thread.suspend, 将线程进行挂起操作,调用完成后线程处于阻塞状态,注意这个操作不会释放对象锁,那么线程无法获取到对象锁而出现等待
  • Thread.resume,将线程进行恢复操作, 调用完成后将唤醒处于阻塞状态的线程

这种独占模式很容易产生死锁,在论文提到这么一个问题,线程先调用了resume,然后在调用suspend方法,那么这个resume方法将不会起作用(是我的问题吗,我没明白这个会产生什么问题,或许是我功力不够吧)。

在java.util.concurrent.locks包下提供了LockSupport类来解决阻塞、唤醒线程问题。

  • LockSupport.park方法用于阻塞线程,除非调用了LockSupport.unpark方法来唤醒线程(方法的参数是线程)
  • LockSupport.unpark方法用于唤醒线程

LuckSupport可以解决上面提到问题(就是咱不理解的问题),假设有线程A,线程B,

  • 神奇情况:
    • 首先线程B中调用了方法LockSupport.park(A), 线程A处于阻塞状态
    • 之后线程B中调用方法LockSupport.unpark(A),线程A被唤醒
    • 再次在线程B中调用方法LockSupport.unpark(A),线程A还是唤醒状态
    • 神奇的是此时线程B再次调用方法LockSupport.park(A),线程A将处于唤醒状态(上一次的unpark被记录了,下一次的park被抵消了)

这里需要特别注意LockSupport.unpark(A)方法不会被计数,也就是说不论上面是调用了1次还是1次以上LockSupport.unpark(A)方法,之后调用两次LockSupport.park(A)线程A肯定会进入阻塞状态。

线程队列

AQS的核心是维护一个先进先出的线程队列,用于管理阻塞线程,因此不适合实现基于优先级的同步模式(排序操作对于维护同步阻塞线程队列的性能开销太大,不建议这样使用)。

使用不包含系统级别锁结构来实现线程队列将是最好的选择,这样就不会因为使用系统锁带来额外的性能损耗,当前有两种队列模式可以选择:

  • Mellor-Crummey and Scott 提出的称为MCS结构队列
  • Craig, Landin, and Hagersten 提出的称为CLH结构队列

上述两种队列结构是基础,基于这两种结构后续提出了多种衍生变体。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有很多优点,入队和出队速度快,无锁(即使有竞争,也总有一个线程会成功执行入队操作),实现其他功能也很方便:

  • 检查是否有线程在等待:只需要检查head和tail变量是否相等即可,相等说明没有线程在等待,不想等说明有线程在等待
  • 同时每个节点的status变量是分散的,不会产生内存竞争问题

在原始的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方法之前会执行以下操作:

  • 尝试获取锁(调用tryAcquire)
  • 没有获取到锁加入队列(调用addWaiter方法将节点设置为tail)
  • 准备执行park
    • 1、首先判断是否为队列中的第一个节点
      • 1.1 如果是继续尝试获取锁(调用tryAcquire)
        • 1.1.1 获取成功,直接退出
        • 1.1.2 获取失败,继续1、2循环
    • 2、如果不是队列中的第一个节点,那么依据前节点状态字段status进行下一步操作
      • 2.1、如果前节点为Signal me(在程序中值为-1),那么说明前节点等待唤醒,可以预见的是当前节点需要执行park进行等待(前面有节点在等待,说明有线程获取到了锁,因此需要等待前节点获取到锁,执行完释放锁之后才能获取锁)
      • 2.2、如果前节点status值大于0,那么表示前节点已取消,需要往前寻找到小于0的前节点(大于0的节点表示取消,那么需要清除这些节点)
      • 2.3、其它情况下需要设置前节点状态为Signal me,表示当前节点需要进行唤醒操作,然后准备执行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
  • await,实现阻塞等待,类似于wait

我们使用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框架需要主动释放,这也提供了灵活性(特别注意获取和释放操作必须成对出现,并且注意在异常情况下必须保证释放操作执行,否则容易造成死锁问题),操作步骤如下:

  • 获取锁
  • 获取到锁后、在锁对象下的Condition执行notify/await操作(这里需要特别注意,执行完await操作会释放获取到的锁,并且线程将处于阻塞状态,等待下次唤醒,唤醒后必须要获取到锁才能继续执行,没有获取到锁将继续等待)
  • 释放操作

我们之前讨论AQS框架下的acquire操作时也提供过,执行完acquire操作后有两种情况:

  • 获取到锁,那么直接返回
  • 没有获取到锁,那么将进入到CLH变体队列中(是一个双向链接的链表,同时有waitStatus变量表示节点状态)

执行Condition的await操作也会将当前线程加入到等待队列,这里需要特别注意,这两个队列不是同一个队列,每一个AQS下都有一个CLH变体队列,同时AQS下的每一个Condition也有自己独立的一个链表队列,两个队列中的节点类型是一样的,但是两个队列是独立的。

  • 当线程执行await操作时,将会把当前线程对应的节点加入到Condition维护的队列,同时节点的waitStatus设置为-2,表示这是一个条件队列节点,节点中的线程因为调用await操作进行了阻塞
  • 当执行signal操作时,将会从Condition维护的队列中拿出一个waitStatus为-2的节点,然后设置waitStatus为0,表示这不是一个Condition队列节点,然后加入到AQS队列(需要重新去获取锁,来执行后续操作)尝试获取到锁

可以看出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,

  • 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方法(这个方法由子类实现),可以基于以下逻辑实现:

  • 如果state为0(表示锁空闲),使用CAS方法,进行state原子性操作,compareAndSetState(0, 1),如果设置成功,那么意味当前线程成功获取了锁

这里可以发现当前线程并没有判断队列是否有等待队列,而是直接尝试获取锁,如果恰巧获取到锁的线程释放了锁,但是还没有来得及唤醒后续等待线程,那么当前线程就捡漏的到了锁,可以理解为当前线程没有好好排队,而是进行了插队。这在一些情况下是可以忍受的,例如提高系统的吞吐量为第一目标的消息处理系统,当然在一些情况下是不可行的,例如之前的外卖派单系统。

实现公平性也很简单,只需要先检查队列是否有等待节点,如有那么直接返回false,如果没有再尝试去获取锁。

同步器

java.util.concurrent包下提供了很多不同功能的同步器,底层都是基于AQS实现同步功能,例如:

  • ReentrantLock,可重入锁
    • 使用同步状态state来记录获取锁次数,并记录当前获取到锁的线程信息,只有获取到锁的线程能够重复获取到锁
    • 使用提供的ConditionObject来实现monitor机制
    • 同时提供了两种锁机制:公平锁、非公平锁
  • ReentrantReadWriteLock
    • 读写分离锁,
      • 读锁和写锁是互斥的,同一时间只允许一种锁被锁定,例如读锁被锁定时不允许写锁被锁定,同理写锁被获取时不允许读锁被锁定
      • 读锁是共享模式,可允许多个线程同时获取到读锁
      • 写锁是独占模式,只允许一个线程获取到写锁
    • WriteLock的结构与ReentrantLock结构相同
    • ReadLock使用acquireShared方法实现共享模式
    • state前16位表示读锁获取数量、后16位表示写锁获取数量
  • Semaphore,计数信号量。
    • 使用同步状态state表示计数,计数用于表示可获取的锁数量,同时这是一个共享锁模式,只要state的数量满足acquires数量,任何线程都可以获取
    • acqurieShared用来减少计数,如果剩余数量无法满足当前需要的数量,那么会进行阻塞操作(在信号量中,每次调用成功计数都会减1)
    • tryRelease用来增加计数,如果调用之后是正数,那么可能会唤醒阻塞线程(在信号量中,每次调用成功计数都会加1)
  • CountDownLatch ,也是一个计数信号量,但是与Semaphore不同的时,只有当计数减到0的时候,所有阻塞的线程才会唤醒,只能使用一次,无法重置计数
  • FutureTask通过设置同步状态state来表示当前task的运行状态(初始化、运行中、取消、完成),底层没有使用AQS但是使用了原子性管理同步状态

总结

AQS是一个同步器基础框架,提供了同步器通用功能:

  • acquire,获取锁操作,如果无法获取将会阻塞线程
  • release,释放锁,获取到锁的线程在执行完成后调用release方法释放锁,可能会唤醒之前因获取锁而阻塞的线程

除了上述基础锁功能外还提供了ConditionObject对象来实现监视器模式:

  • await,在ConditionObject对象上进行线程阻塞等待唤醒
  • signal,唤醒在ConditionObject对象上调用await方法阻塞的线程
  • 需要注意的是上述方法必须先获取到CoditionObject所属的AQS锁

同时还提供了取消锁获取、获取锁等待超时方法。

上述功能都是基于以下基础功能实现:

  • 原子性操作同步状态state(基于CAS功能实现原子性操作)
  • 阻塞、唤醒线程(基于LockSupport实现)
  • 线程队列维护(使用CLH变体队列实现)

需要注意的是,本文都是从论文解读的角度介绍同步框架实现思路,在实际实现上有很多需要注意的细节,例如先前提到的同步节点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 论文

  • 地址: https://pan.baidu.com/s/1kSE59Zd8xHoKLFgVpGfcuw?pwd=3y1y

参考文档

 

联系方式

技术更新换代速度很快,我们无法在有限时间掌握全部知识,但我们可以在他人的基础上进行快速学习,学习也是枯燥无味的,加入我们学习牛人经验:

点击:加群讨论 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值