synchronized底层原理探究

概述

说起多线程同步,一般的方案就是加锁,而在 java 中,提到加锁就想起 juc 包提供的 Lock 接口实现类与默认的关键字 synchronized 。我们常听到,juc 下的锁大多基于 AQS,而 AQS 的锁机制基于 CAS,相比起 CAS 使用的自旋锁,Synchronized 是一种重量级的锁实现。

实际上,在 JDK6 之后,synchronized 逐渐引入了锁升级机制,它将会有一个从轻量级到重量级的逐步升级的过程。本文将简单的介绍 synchronized 的底层实现原理,并且介绍 synchronized 的锁升级机制。

一、synchronized 的底层实现

synchronized 意为同步,它可以用于修饰静态方法,实例方法,或者一段代码块。

它是一种可重入的对象锁。当修饰静态方法时,锁对象为类;当修饰实例方法时,锁对象为实例;当修饰代码块时,锁可以是任何非 null 的对象。

由于其底层的实现机制,synchronized 的锁又称为监视器锁。

1.同步代码块

当我们反编译一个含有被 synchronized 修饰的代码块的文件时,我们可以看到类似如下指令:

image-20210210174821495

这里的 monitorentermonitorexit 即是线程获取 synchronized 锁的过程。

当线程试图获取对象锁的时候,根据 monitorenter 指令:

  • 如果 Monitor 的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为 Monitor 的所有者;
  • 如果线程已经占有该 monitor,只是重新进入,则进入 Monitor 的进入数加1(可重入);
  • 如果其他线程已经占用了 monitor,则该线程进入阻塞状态,直到 Monitor 的进入数为0,再重新尝试获取 Monitor 的所有权;

当线程执行完以后,根据 monitorexit 指令:

  • 当执行 monitorexit 指令后,Monitor 的进入数 -1;
  • 如果 - 1 后 Monitor 进入数为 0,则该线程不再拥有这个锁,退出 monitor;
  • 如果 - 1 后 Monitor 进入数仍不为 0,则线程继续持有这个锁,重复上述过程直到使用完毕。

2.同步方法

而对于被 synchronized 修饰的方法,在反编译以后我们可以看到如下指令:

image-20210210175830989

在 flags 处多了 ACC_SYNCHRONIZED标识符,如果方法拥有改标识符,则线程需要在访问前获取 monitor,在执行后释放 monitor,这个过程同上文提到的代码块的同步。

相对代码块的同步,方法的同步隐式调用了 monitor,实际上二者本质并无差别,最终都要通过 JVM 调用操作系统互斥原语 mutex 实现。

二、synchronized 锁的实现原理

synchronized 是对象锁,在 JDK6 引入锁升级机制后,synchronized 的锁实际上分为了偏向锁、轻量级锁和重量级锁三种,这三者都依赖于对象头中 MarkWord 的数据的改变。

1.对象头

在 java 中,一个对象被分为三部分:

  • 实例数据:存放类的属性数据信息,包括父类的属性信息;

  • 对象头:用于存放哈希值或者锁等信息。

    Java 对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32 bit,在64位虚拟机中,1个机器码是8个字节,也就是64 bit);

    但是如果对象是数组类型,则需要3个机器码,因为 JVM虚拟机可以通过 java 对象的元数据信息确定 Java 对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

  • 对齐填充:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;

image-20210210190106478

其中,对象头又分为三部分:MarkWord,类型指针,数组长度(如果是数组的话)。

MarkWord 是一个比较重要的部分,它存储了对象运行时的大部分数据,如:hashcode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等

根据机器的不同,MarkWord 可能有 64 位或者 32 位,MarkWord 会随着对象状态的改变而改变,一般来说,结构是这样的:

img

值得注意的是:

对象头的最后两位存储了锁的标志位,01是初始状态,未加锁,其对象头里存储的是对象本身的哈希码,随着锁级别的不同,对象头里会存储不同的内容。

  • 偏向锁存储的是当前占用此对象的线程ID;
  • 轻量级锁存储的是指向线程栈中锁记录的指针。

锁标志位如下:

LockWord存储内容 锁标志位 状态
对象哈希值,GC 分代年龄 01 未锁定
指向 LockRecord 的指针 00 轻量级锁锁定
指向 Monitor 的指针 10 重量级锁锁定
11 GC 标记
偏向线程 id,偏向时间戳,GC 分代年龄 01 可偏向

2.重量级锁与监视器

synchronized 的对象锁是基于监视器对象 Monitor 实现的,而根据上文,我们知道锁信息存储于对象自己的 MarkWord 中,那么 Monitor 和 对象又是什么关系呢?

实际上,在对象在创建之初就会在 MarkWord 中关联一个 Monitor 对象 ,当锁升级到重量级锁时,标志位就会变为指向 Monitor 对象的指针。

Monitor 对象在 JVM 中基于 ObjectMonitor 实现,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ObjectMonitor() {
_header = NULL;
_count = 0; // 持有锁次数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL; // 当前持有锁的线程
_WaitSet = NULL; // 等待队列,处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 阻塞队列,处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}

ObjectMonitor 中有两个队列,_WaitSet_EntryList,用来保存 ObjectWaiter 对象列表( 每个等待锁的线程都会被封装成 ObjectWaiter 对象 ),_owner 指向持有 ObjectMonitor 对象的线程,当多个线程同时访问一段同步代码时:

  1. 首先会进入 _EntryList 集合,当线程获取到对象的 Monitor 后,进入 _Owner 区域并把 monitor 中的 owner 变量设置为当前线程,同时 monitor中的计数器 count 加1;
  2. 若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减1,同时该线程进入 WaitSet集合中等待被唤醒;
  3. 若当前线程执行完毕,也将释放 Monitor 并复位 count 的值,以便其他线程进入获取 Monitor;

这也解释了为什么 notify()notifyAll()wait() 方法会要求在同步块中使用,因为这三个方法都需要获取 Monitor 对象,而要获取 Monitor,就必须使用 monitorenter指令

3.轻量级锁与锁记录

根据锁标志位,我们了解到 10 表示为指向 Monitor 对象的指针,是重量级锁,而 00 是指向 LockRecord 的指针,是轻量级锁。那么,这个 LockRecord 又是什么呢?

在线程的栈中,存在名为 LockRecord (锁记录)的空间,这个是线程私有的,对应的还存在一个线程共享的全局列表。当一个线程去获取锁的时候,会将 Mark Word 中的锁信息拷贝到 LockRecord 列表中,并且修改 MarkWord 的锁标志位为指向对应 LockRecord 的指针

其中,Lock Record 中还保存了以下信息:

Lock Record 描述
Owner 初始时为NULL表示当前没有任何线程拥有该 monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为 null;
EntryQ 关联一个系统互斥锁(semaphore),阻塞所有试图锁住 monitor record 失败的线程;
RcThis 表示 blocked 或 waiting 在该 monitor record 上的所有线程的个数;
Nest 用来实现重入锁的计数;
HashCode 保存从对象头拷贝过来的 hashcode 值(可能还包含GC age)。
Candidate 用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。

假如当有多个线程争夺偏向锁,或未开启偏向锁的前提下由无锁进入加锁状态的时候,锁会先升级为轻量级锁:

  • 虚拟机现在线程栈帧中建立 LockRecord 的空间,用于存储锁对象 MarkWord 的拷贝;
  • 拷贝完毕后,使用 CAS 操作尝试将 MarkWord 中的 LockWord 字段改为指向当前线程的 LockRecord 的指针,并且将 MarkWord 中的锁标志位改为 00;
  • 如果更新失败,就先检查 LockWord 是否已经指向当前线程的 LockRecord 了,如果是说明已经获取到锁了,直接重入,否则说明还在竞争锁,此时进入自旋等待;

其实这个有个疑问,为什么获得锁成功了而CAS失败了?

这里其实要牵扯到CAS的具体过程:先比较某个值是不是预测的值,是的话就动用原子操作交换(或赋值),否则不操作直接返回失败。在用CAS的时候期待的值是其原本的MarkWord。发生“重入”的时候会发现其值不是期待的原本的MarkWord,而是一个指针,所以当然就返回失败,但是如果这个指针指向这个线程,那么说明其实已经获得了锁,不过是再进入一次。

4.偏向锁

当我们使用 synchronized 加锁了,但是实际可能存在并没有多个线程去竞争的情况,这种情况下加锁和释放锁会消耗无意义的资源。为此,就有了偏向锁,

所以,当一个线程访问同步块并获取锁时,会在 MarkWord 和栈帧中的 LockRecord 里存储锁偏向的线程ID,以后该线程进入和退出同步块时不需要花费 CAS 操作来争夺锁资源,只需要检查是否为偏向锁、锁标识为以及 ThreadID是否为当前线程的 ID 即可,处理流程如下:

  1. 检测 MarkWord 是否为偏向状态,即是否为偏向锁1,锁标识位为01;
  2. 若为偏向状态,则检查线程 ID 是否为当前线程 ID,是则执行代码块;
  3. 如果测试线程 ID 不为当前线程 ID,则通过 CAS 操作竞争锁,竞争成功,则将 Mark Word 的线程 ID 替换为当前线程 ID,否则说明存在锁竞争,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;

偏向锁的释放采用了 一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:

  1. 暂停拥有偏向锁的线程;
  2. 判断锁对象是否还处于被锁定状态,否,则恢复到无锁状态(01),以允许其余线程竞争。是,则挂起持有锁的当前线程,并将指向当前线程的锁记录地址的指针放入对象头 Mark Word,升级为轻量级锁状态(00),然后恢复持有锁的当前线程,进入轻量级锁的竞争模式;

三、锁升级

在 JDK5 之前,synchronized 无论如何都会直接加 Monitor 锁,实际上针对无锁情况或者锁竞争不激烈的情况,这样会比较消耗性能,因此,在 JDK6 引入了锁升级的概念,即:无锁状态-》偏向锁状态-》轻量级锁状态-》重量级锁状态的锁升级的过程。

在 JVM 中,锁升级是不可逆的,即一旦锁被升级为下一个级别的锁,就无法再降级。

首先默认的无锁状态,当我们加锁以后,可能并没有多个线程去竞争锁,此时我们可以默认为只有一个线程要获取锁,即偏向锁,当锁转为偏向锁以后,被偏向的线程在获取锁的时候就不需要竞争,可以直接执行。

当确实存在少量线程竞争锁的情况时,偏向锁显然不能再继续使用了,但是如果直接调用重量级锁在轻量锁竞争的情况下并不划算,因为竞争压力不大,所以往往需要频繁的阻塞和唤醒线程,这个过程需要调用操作系统的函数去切换 CPU 状态从用户态转为核心态。因此,可以直接令等待的线程自旋,避免频繁的阻塞唤醒。

当竞争加大时,线程往往要等待比较长的时间才能获得锁,此时在等待期间保持自旋会白白占用 CPU 时间,此时就需要升级为重量级锁,即 Monitor 锁,JVM 通过指令调用操作系统函数阻塞和唤醒线程。

四、锁优化

我们了解了重量级锁,轻量级锁,偏向锁的实现机制,实际上,除了锁升级的过程,synchronized 还增加了其他针对锁的优化操作。

1.自适应自旋锁

自旋锁依赖于 CAS,我们可以手动的设置 JVM 的自旋锁自旋次数,但是往往很难确定适当的自旋次数,如果自旋次数太少,那么可能会引起不必要的锁升级,而自旋次数太长,又会影响性能。在 JDK6 中,引入了自适应自旋锁的机制,对于同一把锁,当线程通过自旋获取锁成功了,那么下一次自旋次数就会增加,而相反,如果自旋锁获取失败了,那么下一次在获取锁的时候就会减少自旋次数。

2.锁消除

在一些方法中,有些加锁的代码实际上是永远不会出现锁竞争的,比如 Vector 和 Hashtable 等类的方法都使用 synchronized 修饰,但是实际上在单线程程序中调用方法,JVM 会检查是否存在可能的锁竞争,如果不存在,会自动消除代码中的加锁操作。

3.锁粗化

我们常说,锁的粒度往往越细越好,但是一些不恰当的范围可能反而引起更频繁的加锁解锁操作,比如在迭代中加锁,JVM 会检测同一个对象是否在同一段代码中被频繁加锁解锁,从而主动扩大锁范围,避免这种情况的发生。

总结

synchronized 在 JDK6 以后,根据锁升级机制分为四种状态:无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态。这三种锁都与锁对象的对象头中的 MarkWord 有关,不同的锁状态会在 MarkWord 有对应的不同的锁标志位。

偏向锁的锁标志位为 01,通过将 MarkWord 中的线程 ID 改为偏向线程 ID 实现。

轻量级锁基于自旋锁,通过拷贝 MarkWord 到线程私有的 LockRecord 中,并且 CAS 改变对象的 LockWord 为指向线程 LockRecord 的指针来实现。

重量级锁即原本的监视器锁,基于 JVM 的 Monitor 对象实现,通过将对象的 LockWord 指向对应的 ObjectMonitor 对象,并且通过 ObjectMonitor 中的阻塞队列,等待队列以及当前持有锁的线程指针等参数来实现。

除了锁升级以外,JVM 还会引入了自适应自旋锁,锁消除,锁粗化等锁优化机制。

0%