在多线程场景下,为防止因多个线程去访问一个资源而引出的并发问题(序列化访问临界资源),jdk 提供了两种阻塞式加锁方式 synchronized 和 ReentrentLock 还有非阻塞的 cas + 自旋。此处重点总结下 synchronized个人理解。
synchronized为内置锁(隐式锁)由JVM帮我们实现,不需手动加解锁。是一种对象锁,是可重入的。

首先从加锁方式上进行分析:

  • 方法上
    • jvm会在方法上增加标记( acc_synchronized ) image.png
  • 代码块中
    • jvm会在同步代码块中添加( monitorenter monitorexit ) image.png

然而加锁解锁的过程是需要cpu进行用户态和内核态进行切换的,这是非常消耗性能的一个操作,所以jdk1.6 之后jvm 对锁进行了优化。增加了偏向锁、轻量级锁、重量级锁三种状态来降低性能开销,使之在使用synchronized产生争抢时不会上来就对线程进行park。
在了解各种锁的区别前,我们需先了解java锁体系的一个设计思想管程模型,java所使用的为MESA
image.png
MESA中存在入口等待队列及条件队列,等待队列中存放的是互斥的线程,而条件队列存放的是同步的线程由于阻塞而失去所有权的线程,条件队列必要的一个条件就是有阻塞唤醒。而我们的java实现是通过Object,每一个Object都会有对应的一个Monitor,所以每个对象会有wait()/notify()/notifyAll()
两个队列是以栈结构进行存储的。

Monitor机制在java中的实现

java.lang.Object类定义了wait(),notify(),notifyAll()方法,这些方法的具体实现,依赖于ObjectMonitor实现,这是JVM内部基于C++实现的一套机制。ObjectMonitor其主要数据结构如下(hotspot源码ObjectMonitor.hpp):

  1. ObjectMonitor() {
  2. _header = NULL;
  3. _count = 0; // 记录个数
  4. _waiters = 0,
  5. _recursions = 0; // 锁重入次数
  6. _object = NULL; // 存储锁对象
  7. _owner = NULL; // 标识拥有该monitor的线程(当前获取锁的线程)
  8. _WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet
  9. _WaitSetLock = 0 ;
  10. _Responsible = NULL ;
  11. _succ = NULL ;
  12. _cxq = NULL ; // 多线程竞争锁会先存储到这个单向链表中(FIFO)先进后出
  13. FreeNext = NULL ;
  14. _EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表(竞争锁失败的线程或重新进入被阻塞的线程)
  15. _SpinFreq = 0 ;
  16. _SpinClock = 0 ;
  17. OwnerIsThread = 0 ;
  18. }

image.png
执行逻辑大致为,当线程互斥获取锁失败后会先进入等待队列,持有锁的线程如果调用了wait()则会进入条件队列。当持有锁的线程释放锁后,JVM会先去看条件队列中是否存在等待线程,存在则优先唤醒,如果没有则把等待队列里的线程移入条件队列进行唤醒操作。

在对象头中有一块MarkDown专门存储锁标记,我们可以根据锁标识来区分当前对象的锁状态。
image.png

偏向锁:

不存在竞争,偏向锁有两种状态:可偏向和已偏向。

  • 可偏向状态表示此时对象threadId为空,没有偏向任何线程。
  • 已偏向表示对象头的threadId已经指向加锁线程。

在启动JVM时存在延迟偏向的情况,JVM默认值为4s。即前4s启动时都是无锁状态,后启动为匿名偏向状态(可偏向)

  1. System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());
  2. Thread.sleep(5000);
  3. obj = new Object();
  4. System.out.println(ClassLayout.parseInstance(obj).toPrintable());

image.png
偏向锁延迟启动是因为:JVM在启动时也会有许多同步代码块(加了synchronized的方法),此时会有一些竞争,如果不延迟偏向的话会有很多偏向锁撤销的逻辑造成资源浪费。

偏向锁撤销:

在偏向锁hashCode()或wait()时会产生偏向锁撤销。

  • 可偏向状态调用hashCode会使对象变为无锁状态。
  • 已偏向状态调用hashCode或wait()则会直接升级为重量级锁。

    误区:

    有些地方会有说 无锁、偏向锁、轻量级锁、重量级锁是逐步升级或递减的。这是错误的,偏向锁进行释放后依旧时偏向锁,偏向锁撤销可直接变为其他三种状态。而轻量级锁和重量级锁在释放后,会变为无锁状态。

    轻量级锁:

    线程间存在轻微竞争,即线程交替执行每次CAS都可以获取到锁
    升级为轻量级锁时对象头会指向对应的线程栈空间锁记录的指针,并且把无锁状态的MarkWord复制一份到栈空间。当轻量级锁变为无锁状态时栈中的副本会复制会对象MarkWord,这也是轻量级锁可以存hashCode的原因。

    误区:

    有些文章中写到 轻量级锁会进行自旋或自适应自旋。这是不对的,轻量级锁在争抢锁时只会进行一次CAS,获取锁失败则膨胀为重量级锁。

    重量级锁:

    线程竞争激烈,膨胀过程中会创建一个monitor对象,会进行自选或自适应自旋获取锁。

    进阶:

    批量重偏向、偏向锁撤销:

    此现象产生是一个线程创建了大量对象进行了初始化的同步操作,后来另一个线程也使用这批对象进行了锁操作,造成了大量偏向锁撤销的场景。故JVM对此现象进行了优化。

    原理:

    以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向(threadId 重新指向另一个线程)。
    当达到重偏向阈值(默认20)后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。

    总结:

  1. 偏向锁重偏向和撤销的场景是针对类的,和对象无关。
  2. 偏向锁只可重偏向一次,之后不再会进行重偏向。
  3. 当达到偏向锁撤销的阈值后,JVM会默认这个类有问题,剥夺了该类新实例对象使用偏向锁的权利。

    自旋优化:

    重量级锁时,JVM还可通过自旋来获取锁。当持有锁现场释放时,线程自旋成功。这样可以避免线程阻塞,减少cpu消耗。
  • 自旋会消耗CPU性能,如果是单核CPU自旋就是浪费性能,只有在多核CPU才能发挥优势。
  • jdk1.6时JVM加入自适应自旋,即上一个线程通过自旋获取锁成功。则线程在获取锁时会多自旋几次,认为自旋成功率高,反之则少自旋几次甚至不自旋,比较智能。
  • jdk1.7后不能控制是否开启自旋。

    锁粗化:

    假设一系列的连续操作都会对同一个对象反复加锁及解锁,甚至加锁操作是出现在循环体中的,即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。

    锁消除:

    锁消除即删除不必要的加锁操作。锁消除是Java虚拟机在JIT编译期间,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。