大家好,又见面了,我是你们的朋友全栈君。
说起多线程同步,一般的方案就是加锁,而在 java 中,提到加锁就想起 juc 包提供的 Lock 接口实现类与默认的关键字 synchronized 。我们常听到,juc 下的锁大多基于 AQS,而 AQS 的锁机制基于 CAS,相比起 CAS 使用的自旋锁,Synchronized 是一种重量级的锁实现。
实际上,在 JDK6 之后,synchronized 逐渐引入了锁升级机制,它将会有一个从轻量级到重量级的逐步升级的过程。本文将简单的介绍 synchronized 的底层实现原理,并且介绍 synchronized 的锁升级机制。
synchronized 意为同步,它可以用于修饰静态方法,实例方法,或者一段代码块。
它是一种可重入的对象锁。当修饰静态方法时,锁对象为类;当修饰实例方法时,锁对象为实例;当修饰代码块时,锁可以是任何非 null 的对象。
由于其底层的实现机制,synchronized 的锁又称为监视器锁。
当我们反编译一个含有被 synchronized 修饰的代码块的文件时,我们可以看到类似如下指令:
这里的 monitorenter 与 monitorexit 即是线程获取 synchronized 锁的过程。
当线程试图获取对象锁的时候,根据 monitorenter 指令:
当线程执行完以后,根据 monitorexit 指令:
而对于被 synchronized 修饰的方法,在反编译以后我们可以看到如下指令:
在 flags 处多了 ACC_SYNCHRONIZED
标识符,如果方法拥有改标识符,则线程需要在访问前获取 monitor,在执行后释放 monitor,这个过程同上文提到的代码块的同步。
相对代码块的同步,方法的同步隐式调用了 monitor,实际上二者本质并无差别,最终都要通过 JVM 调用操作系统互斥原语 mutex 实现。
synchronized 是对象锁,在 JDK6 引入锁升级机制后,synchronized 的锁实际上分为了偏向锁、轻量级锁和重量级锁三种,这三者都依赖于对象头中 MarkWord 的数据的改变。
在 java 中,一个对象被分为三部分:
其中,对象头又分为三部分:MarkWord,类型指针,数组长度(如果是数组的话)。
MarkWord 是一个比较重要的部分,它存储了对象运行时的大部分数据,如:hashcode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。
根据机器的不同,MarkWord 可能有 64 位或者 32 位,MarkWord 会随着对象状态的改变而改变,一般来说,结构是这样的:
值得注意的是:
对象头的最后两位存储了锁的标志位,01是初始状态,未加锁,其对象头里存储的是对象本身的哈希码,随着锁级别的不同,对象头里会存储不同的内容。
锁标志位如下:
LockWord存储内容 | 锁标志位 | 状态 |
---|---|---|
对象哈希值,GC 分代年龄 | 01 | 未锁定 |
指向 LockRecord 的指针 | 00 | 轻量级锁锁定 |
指向 Monitor 的指针 | 10 | 重量级锁锁定 |
空 | 11 | GC 标记 |
偏向线程 id,偏向时间戳,GC 分代年龄 | 01 | 可偏向 |
synchronized 的对象锁是基于监视器对象 Monitor 实现的,而根据上文,我们知道锁信息存储于对象自己的 MarkWord 中,那么 Monitor 和 对象又是什么关系呢?
实际上,在对象在创建之初就会在 MarkWord 中关联一个 Monitor 对象 ,当锁升级到重量级锁时,标志位就会变为指向 Monitor 对象的指针。
Monitor 对象在 JVM 中基于 ObjectMonitor 实现,代码如下:
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 对象的线程,当多个线程同时访问一段同步代码时:
_EntryList
集合,当线程获取到对象的 Monitor 后,进入 _Owner 区域并把 monitor 中的 owner 变量设置为当前线程,同时 monitor中的计数器 count 加1;WaitSet
集合中等待被唤醒;这也解释了为什么 notify()
、notifyAll()
和wait()
方法会要求在同步块中使用,因为这三个方法都需要获取 Monitor 对象,而要获取 Monitor,就必须使用 monitorenter指令。
根据锁标志位,我们了解到 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表示要唤醒一个继任线程来竞争锁。 |
假如当有多个线程争夺偏向锁,或未开启偏向锁的前提下由无锁进入加锁状态的时候,锁会先升级为轻量级锁:
其实这个有个疑问,为什么获得锁成功了而CAS失败了?
这里其实要牵扯到CAS的具体过程:先比较某个值是不是预测的值,是的话就动用原子操作交换(或赋值),否则不操作直接返回失败。在用CAS的时候期待的值是其原本的MarkWord。发生“重入”的时候会发现其值不是期待的原本的MarkWord,而是一个指针,所以当然就返回失败,但是如果这个指针指向这个线程,那么说明其实已经获得了锁,不过是再进入一次。
当我们使用 synchronized 加锁了,但是实际可能存在并没有多个线程去竞争的情况,这种情况下加锁和释放锁会消耗无意义的资源。为此,就有了偏向锁,
所以,当一个线程访问同步块并获取锁时,会在 MarkWord 和栈帧中的 LockRecord 里存储锁偏向的线程ID,以后该线程进入和退出同步块时不需要花费 CAS 操作来争夺锁资源,只需要检查是否为偏向锁、锁标识为以及 ThreadID是否为当前线程的 ID 即可,处理流程如下:
偏向锁的释放采用了 一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:
在 JDK5 之前,synchronized 无论如何都会直接加 Monitor 锁,实际上针对无锁情况或者锁竞争不激烈的情况,这样会比较消耗性能,因此,在 JDK6 引入了锁升级的概念,即:无锁状态-》偏向锁状态-》轻量级锁状态-》重量级锁状态的锁升级的过程。
在 JVM 中,锁升级是不可逆的,即一旦锁被升级为下一个级别的锁,就无法再降级。
首先默认的无锁状态,当我们加锁以后,可能并没有多个线程去竞争锁,此时我们可以默认为只有一个线程要获取锁,即偏向锁,当锁转为偏向锁以后,被偏向的线程在获取锁的时候就不需要竞争,可以直接执行。
当确实存在少量线程竞争锁的情况时,偏向锁显然不能再继续使用了,但是如果直接调用重量级锁在轻量锁竞争的情况下并不划算,因为竞争压力不大,所以往往需要频繁的阻塞和唤醒线程,这个过程需要调用操作系统的函数去切换 CPU 状态从用户态转为核心态。因此,可以直接令等待的线程自旋,避免频繁的阻塞唤醒。
当竞争加大时,线程往往要等待比较长的时间才能获得锁,此时在等待期间保持自旋会白白占用 CPU 时间,此时就需要升级为重量级锁,即 Monitor 锁,JVM 通过指令调用操作系统函数阻塞和唤醒线程。
我们了解了重量级锁,轻量级锁,偏向锁的实现机制,实际上,除了锁升级的过程,synchronized 还增加了其他针对锁的优化操作。
自旋锁依赖于 CAS,我们可以手动的设置 JVM 的自旋锁自旋次数,但是往往很难确定适当的自旋次数,如果自旋次数太少,那么可能会引起不必要的锁升级,而自旋次数太长,又会影响性能。在 JDK6 中,引入了自适应自旋锁的机制,对于同一把锁,当线程通过自旋获取锁成功了,那么下一次自旋次数就会增加,而相反,如果自旋锁获取失败了,那么下一次在获取锁的时候就会减少自旋次数。
在一些方法中,有些加锁的代码实际上是永远不会出现锁竞争的,比如 Vector 和 Hashtable 等类的方法都使用 synchronized 修饰,但是实际上在单线程程序中调用方法,JVM 会检查是否存在可能的锁竞争,如果不存在,会自动消除代码中的加锁操作。
我们常说,锁的粒度往往越细越好,但是一些不恰当的范围可能反而引起更频繁的加锁解锁操作,比如在迭代中加锁,JVM 会检测同一个对象是否在同一段代码中被频繁加锁解锁,从而主动扩大锁范围,避免这种情况的发生。
synchronized 在 JDK6 以后,根据锁升级机制分为四种状态:无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态。这三种锁都与锁对象的对象头中的 MarkWord 有关,不同的锁状态会在 MarkWord 有对应的不同的锁标志位。
偏向锁的锁标志位为 01,通过将 MarkWord 中的线程 ID 改为偏向线程 ID 实现。
轻量级锁基于自旋锁,通过拷贝 MarkWord 到线程私有的 LockRecord 中,并且 CAS 改变对象的 LockWord 为指向线程 LockRecord 的指针来实现。
重量级锁即原本的监视器锁,基于 JVM 的 Monitor 对象实现,通过将对象的 LockWord 指向对应的 ObjectMonitor 对象,并且通过 ObjectMonitor 中的阻塞队列,等待队列以及当前持有锁的线程指针等参数来实现。
除了锁升级以外,JVM 还会引入了自适应自旋锁,锁消除,锁粗化等锁优化机制。
发布者:全栈程序员栈长,转载请注明出处:https://javaforall.cn/170771.html原文链接:https://javaforall.cn