vlambda博客
学习文章列表

可重入锁是轻量级锁吗?

引言


我们都知道高效并发是从JDK5升级到JDK6后一项重要的改进型,HotSpot虚拟机开发团队在这个版本上花费了大量的资源去实现各种锁优化技术,如适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight/Thin Locking)、偏向锁(Biased Locking)等等,这些技术都是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程序的执行效率。我们也知道可重入锁(ReentrantLock)底层是使用CAS及队列实现的,那么可重入锁是轻量级锁么?要回答这个问题,首先让我们来回顾一下以上各种锁优化技术。

锁优化技术


内置锁是JVM提供的最便捷的线程同步工具,在代码块或者方法声明上添加synchronized关键字即可使用内置锁。内置锁被抽象成监视器锁(monitor),在JDK6之前监视器锁可以认为直接对应底层操作系统中的互斥量(mutex)。这种同步方式的成本非常高,挂起线程和恢复线程的操作都需要转入内核态中完成。因此,这种锁也被叫做重量级锁(heavyweight lock)。


自旋锁


内核态与用户态的切换上不容易优化,同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间。那么我们是不是可以让后面请求锁的线程稍微等一下呢?也就是说让后面请求锁的线程不放弃处理器的执行时间,执行一个忙循环(自旋),看看持有锁的线程是否很快就会释放锁,如果还是没有能获得锁时再来阻塞自己,这项技术就是所谓的自旋锁。通过自旋锁可以减少线程阻塞造成的线程切换。可以使用-XX:+UseSpinning参数开启自旋锁优化(默认是开启的),-XX:PreBlockSpin参数来修改默认的自旋次数。

缺点


  • 单核处理器上,不存在实际的并行,不管自旋多久都是浪费;进而,如果线程多而处理器少,自旋也会造成不少无谓的浪费。

  • 自旋要占用CPU,如果是计算密集型任务,这一优化通常得不偿失,减少锁的使用是更好的选择。

  • 如果锁竞争的时间比较长,那么自旋通常不能获得锁,白白浪费了自旋占用的CPU时间。这通常发生在锁持有时间长,且竞争激烈的场景中,此时应主动禁用自旋锁。


自适应自旋锁


自适应意味着自旋的时间不在固定,而是由前一次在同一个锁上的自旋时间及锁拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而他讲允许自旋等待持续相对更长的时间,比如100个循环。相反的,如果对于某个锁,自旋很少成功,那么以后要获取这个锁时将可能减少自旋时间甚至省略自旋过程,避免浪费处理资源。

自适应自旋解决的是锁竞争时间不确定的问题。JVM很难感知到确切的锁竞争时间,自适应自旋假定不同线程持有同一个锁对象的时间基本相当,竞争趋于稳定,因此可以根据上一次自旋的时间与结果调整下一次自旋的时间。

缺点



  • 自适应自旋也并没有完全解决锁竞争时间不确定的问题,如果默认的自旋次数设置不合理(过高或者过低),那么自适应的过程将很难收敛到合适的值。


轻量级锁


自旋锁的目标是降低线程切换的成本。如果锁竞争激烈,我们不得不依赖于于重量级锁,让竞争失败的线程阻塞;轻量级锁的目标就是,减少无实际竞争情况下,使用重量级产生的性能消耗。轻量级锁名字中的轻量级就是相对于使用操作系统互斥量来实现的传统锁而言的。要理解轻量级锁,以及后面会讲到的偏向锁的原理就必须要对HotSpot虚拟机对象的内存布局有所了解。HotSpot虚拟机的对象头(object header)分为两部分,第一部分用于存储对象自身的运行时数据,如哈希码(hash code)、GC分代年龄(Generational GC Age)等。这部分数据的长度在32位和64位的Java虚拟机中分别会占用32个或者64个比特,官方称它为Mark Word。各个状态下的对象头存储内容如下所示:


在代码即将进入到同步块的时候,如果此同步对象没有被锁定(锁标志位为01状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(通常也叫做Displaced Mark Word),此时状态如下所示:

可重入锁是轻量级锁吗?

然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位的最后两个比特将转变为00,表示此时对象处于轻量级锁定状态。这个时候状态如下所示:

如果这个CAS更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那么直接进入同步块继续执行就可以了,否则说明这个锁对象已经被其他线程抢占了,开始进行自旋或者膨胀为重量级锁,锁标志的状态值变为10。变为重量级锁之后,等待锁的线程也就必须要进入阻塞状态。

缺点


  • 如果锁竞争激烈,那么轻量级锁将很快膨胀为重量级锁,那么维持轻量级锁的过程(未获取锁的线程先进行自旋等待(自旋锁),然后膨胀为重量级锁)就成了浪费。


偏向锁


在没有实际竞争的情况下,轻量级锁每次申请、释放都至少需要一次CAS操作,对于一些自始至终使用锁的线程都只有一个的情况下还是有很大的浪费。偏向锁就是一种针对这种情况下的优化,只需要在初始化时进行一次CAS操作(CAS操作也是要耗费资源的,例如Spring Boot在启动时大量的初始化操作由main线程完成,对于一些业务复杂的应用关闭偏向锁(此时会使用轻量级锁)会造成应用启动时间大幅增加)。

偏向锁中的偏就是偏心的偏、偏袒的偏,偏向锁假定将来只有第一个申请锁的线程会使用锁(之后不会再有任何线程再来申请锁),因此,只需要在Mark Word中CAS记录owner(本质上就是更新,只是初始值为空),如果记录成功,则偏向锁获取成功,锁状态变为偏向锁,以后当前线程等于owner就可以0成本获取锁,否则,说明有其他线程竞争,膨胀为轻量级锁。

缺点


  • 同样的,如果存在其他线程申请锁,那么偏向锁将很快膨胀为轻量级锁,造成资源浪费。

注意:

有人可能会有疑问当对象进入偏向锁状态时,Mark Word大部分空间都用于存储有锁的线程ID了,那这部分空间占用了原来存储对象哈希码值的位置,如果这个时候调用Object::hashCode()方法会怎么办?在Java语言里面一个对象如果计算过哈希码,就应该一直保持不变(强烈推荐但不强制,因为用户可以重载hashCode()方法按照自己意愿来返回哈希码),否则很多依赖对象哈希码API都可能存在出错风险。而作为绝大多数对象哈希码来源的Object::hashCode()方法,返回的是对象的一致性哈希码(Identity Hash Code),这个值是能强制保证不变的,他通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。因此,当一个对象已经计算过一致性哈希码之后,他就再也无法进入偏向锁状态了,当一个对象当前正处于偏向锁状态,有收到需要计算其一致性哈希码请求时(来自于Object::hashCode()或者System::identityHashCode(Object)方法的调用,但是如果重写了Object的hashCode方法,计算哈希值并不会产生这里说的请求),它的偏向状态就会立即被撤销,并且锁会膨胀为重量级锁。在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的ObjectMonitor类里有字段可以记录非加锁状态下(标志位为01)的Mark Word,自然可以存储原来的哈希码(以上文字来自于《深入Java虚拟机》,此次有所疑问的是,轻量级锁也会使用Lock Record来存储Mark Word,也是可以满足获取哈希码的请求的,那为什么还要膨胀为重量级锁呢?此次还需要阅读OpenJDK源码验证)。


锁分配和膨胀过程


前面简单讲述了内置锁在使用过程中的一些基本问题和解决方案,详细的锁分配和膨胀过程如下所示:

总结


通过上述对锁优化技术的回顾,我们知道所谓的自旋锁、偏向锁等等,都是实现锁的一些算法。就HotSpot VM来说,偏向锁、轻量级锁、重量级锁等等只是Java内置监视器锁的可能状态。假设,一个Java对象被synchronize关键字用于处理线程同步,在不同时刻这个对象的监视器锁可能有不同的状态:
  • 在第一个线程要求获取对象监视器锁时,会被处于偏向锁状态。

  • 过了一会,第二个线程来请求锁时,偏向锁会被撤销,监视器锁会膨胀为轻量级锁。

  • 最后当有线程在自旋了指定次数后仍然未获取锁,该对象监视器锁就会膨胀为重量级锁。


而可重入锁使用有别于Java内置监视器锁模型来实现和互斥量(mutex)相同的语义。也就是说属于偏向锁、轻量级锁以及重量级锁并没有应用在可重入锁中。可重入锁和轻量级锁并没有什么可比较的。


参考:

《深入Java虚拟机》

Java Synchronize机制(https://blog.dreamtobe.cn/2015/11/13/java_synchronized/)