概述
说起多线程同步,一般的方案就是加锁,而在 java 中,提到加锁就想起 juc 包提供的 Lock 接口实现类与默认的关键字 synchronized 。我们常听到,juc 下的锁大多基于 AQS,而 AQS 的锁机制基于 CAS,相比起 CAS 使用的自旋锁,Synchronized 是一种重量级的锁实现。
实际上,在 JDK6 之后,synchronized 逐渐引入了锁升级机制,它将会有一个从轻量级到重量级的逐步升级的过程。本文将简单的介绍 synchronized 的底层实现原理,并且介绍 synchronized 的锁升级机制。
一、synchronized 的底层实现
synchronized 意为同步,它可以用于修饰静态方法,实例方法,或者一段代码块。
它是一种可重入的对象锁。当修饰静态方法时,锁对象为类;当修饰实例方法时,锁对象为实例;当修饰代码块时,锁可以是任何非 null 的对象。
由于其底层的实现机制,synchronized 的锁又称为监视器锁。
1.同步代码块
当我们反编译一个含有被 synchronized 修饰的代码块的文件时,我们可以看到类似如下指令:
这里的 monitorenter 与 monitorexit 即是线程获取 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 修饰的方法,在反编译以后我们可以看到如下指令:
在 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字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
其中,对象头又分为三部分:MarkWord,类型指针,数组长度(如果是数组的话)。
MarkWord 是一个比较重要的部分,它存储了对象运行时的大部分数据,如:hashcode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。
根据机器的不同,MarkWord 可能有 64 位或者 32 位,MarkWord 会随着对象状态的改变而改变,一般来说,结构是这样的:
值得注意的是:
对象头的最后两位存储了锁的标志位,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 | ObjectMonitor() { |
ObjectMonitor 中有两个队列,_WaitSet
和 _EntryList
,用来保存 ObjectWaiter 对象列表( 每个等待锁的线程都会被封装成 ObjectWaiter 对象 ),_owner 指向持有 ObjectMonitor 对象的线程,当多个线程同时访问一段同步代码时:
- 首先会进入
_EntryList
集合,当线程获取到对象的 Monitor 后,进入 _Owner 区域并把 monitor 中的 owner 变量设置为当前线程,同时 monitor中的计数器 count 加1;- 若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减1,同时该线程进入
WaitSet
集合中等待被唤醒;- 若当前线程执行完毕,也将释放 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 即可,处理流程如下:
- 检测 MarkWord 是否为偏向状态,即是否为偏向锁1,锁标识位为01;
- 若为偏向状态,则检查线程 ID 是否为当前线程 ID,是则执行代码块;
- 如果测试线程 ID 不为当前线程 ID,则通过 CAS 操作竞争锁,竞争成功,则将 Mark Word 的线程 ID 替换为当前线程 ID,否则说明存在锁竞争,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
偏向锁的释放采用了 一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:
- 暂停拥有偏向锁的线程;
- 判断锁对象是否还处于被锁定状态,否,则恢复到无锁状态(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 还会引入了自适应自旋锁,锁消除,锁粗化等锁优化机制。