写点什么

虚拟机如何实现 synchronized

用户头像
Geek_571bdf
关注
发布于: 2021 年 05 月 28 日

知识点

1. 重量级锁的开销与自适应锁自旋;自适应锁自旋的副作用。

2. 轻量级锁的设计初衷;说说轻量级锁的加锁与解锁过程;

3. 偏向锁的设计初衷;偏向锁的初次上锁;偏向锁的后续上锁;偏向锁的撤销;取消偏向锁


1. 重量级锁的开销与自适应锁自旋。

重量级锁是 Java 虚拟机中最为基础的锁实现。在这种状态下,Java 虚拟机会阻塞加锁失败的线程,并且在目标锁被释放的时候,唤醒这些线程。

 

Java 线程的阻塞以及唤醒,都是依靠操作系统来完成的。举例来说,对于符合 posix 接口的操作系统,上述操作是通过 pthread 的互斥锁(mutex)来实现的。这些操作将涉及系统调用,需要从操作系统的用户态切换至内核态,其开销非常之大。// 使用重量级锁的开销很大。

 

为了尽量避免昂贵的线程阻塞、唤醒操作,虚拟机会在线程进入阻塞状态之前,以及被唤醒后竞争不到锁的情况下,进入自旋状态,在处理器上空跑并且轮询 锁是否被释放。如果此时锁恰好被释放了,那么当前线程便无须进入阻塞状态,而是直接获得这把锁。

与线程阻塞相比,自旋状态可能会浪费大量的处理器资源。因为当前线程仍处于运行状况,只不过跑的是无用指令。它期望在运行无用指令的过程中,锁能够被释放出来。

 

如果在短时间内锁肯定被释放出来,显然进入自旋状态而非阻塞更合适;但如果等待时间过长,将浪费大量的处理器资源,因此进入阻塞陷入更合适。

虚拟机无法知道锁还要多长时间才会释放,Java 虚拟机给出的方案是自适应自旋,即,根据以往自旋等待时是否能够获得锁,来动态调整自旋的时间(循环数目)。

 

如果上次自旋等到了锁,那么这次自旋的时间就长一些;反之,这次自旋的时间就短一些。

 

自旋状态还带来另外一个副作用:不公平的锁机制。处于阻塞状态的线程,并没有办法立刻竞争被释放的锁。而处于自旋状态的线程,则很有可能优先获得这把锁。

 

2. 虚拟机是如何区分轻量级锁和重量级锁的?

在对象内存布局中,我们知道,对象头由标记字段和类型指针组成,其中,标记字段的最后两位便被用来表示该对象的锁状态。其中,00 代表轻量级锁,01 代表无锁(或偏向锁),10 代表重量级锁,11 则跟垃圾回收算法的标记有关。

(下面)假设当前锁对象的标记字段为 x…x yz,

 

3. 轻量级锁的设计初衷。

需要注意的是,轻量级锁并非用来代替重量级锁的,它的设计初衷是:在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

 

轻量级锁能提升程序同步性能的依据是 “对于绝大部分的锁,在整个同步周期内都是不存在竞争的” 这一经验法则 //多个线程在不同的时间段请求同一把锁,即没有锁竞争。

如果没有竞争,轻量级锁便通过 CAS 操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了 CAS 操作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

 

4. 轻量级锁的加锁过程。

轻量级锁的加锁过程:首先,虚拟机会先判断是否为 x…x10(重量级锁)à 否:在当前线程的栈帧中划出一段空间,作为该锁的锁记录。并将当前锁对象的标记字段复制到该锁记录上。此时锁记录(栈上的值)有两种可能:①x…x01;②x…x00

 

接下来,虚拟机会比较当前锁对象的标记字段是否为 x…x01。

  • 是:则使用 CAS 操作,将该 锁对象的标记字段 替换为刚才分配的锁记录的地址。由于内存对齐的缘故,它的最后两位为 00。此时,该线程已成功获得这把锁,可以继续执行了。

  • 否:则有两种情况:

情况一:该线程重复获取同一把锁。此时会压一条值为 0 锁记录入栈,表示该锁被重复获取。

情况二:当前锁对象的标记字段为 x…x00。表示有其它线程持有该轻量级锁。此时,虚拟机会将该锁膨胀为重量级锁,并阻塞当前线程。

 

5. 轻量级锁的解锁操作。

如果当前锁记录的值为 0,则代表重复进入同一把锁,直接返回即可。

否则,虚拟机会 比较 当前锁对象的标记字段值是否为当前锁记录的地址。

  • 是:则会使用 CAS 操作将 锁对象的标记字段 替换为 锁记录中的值// x…x01。此时表示该线程已经成功释放了这把锁。

  • 否:则意味着这把锁已经被膨胀为重量级锁。此时,虚拟机会进入重量级锁的释放过程,唤醒因竞争该锁而被阻塞了的线程。

 

6. 偏向锁的设计初衷。

如果说轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不去做了。偏向锁基于的假设是:从始至终只有一个线程请求某一把锁。



在线程进行加锁时,如果当前虚拟机启用了偏向锁(启用参数-XX:+UseBiased Locking,自 JDK 6 起 HotSpot 虚拟机的默认启动),那么 Java 虚拟机会通过 CAS 操作,将当前线程的地址记录在锁对象的标记字段之中,并且将标记字段的最后三位设置为 101。// (第一个 1)把偏向模式设置为“1”,表示进入偏向模式。

 

7. 偏向锁的后续加锁。

在接下来的运行中,每当有线程请求这把锁,Java 虚拟机只需判断锁对象标记字段中:

  • 最后三位是否为 101,

  • 是否包含当前线程的地址,

  • 锁对象的 epoch 值是否和锁对象的类的 epoch 值相同。

 

如果都满足,那么当前线程持有该偏向锁,可以直接返回。// 持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对 Mark Word 的更新操作等)。

 

8. 偏向锁的撤销。// 某把锁的撤销,epoch 值不变。

当请求加锁的线程和锁对象标记字段保持的线程地址不匹配时(而且 epoch 值相等,如若不等,那么当前线程可以将该锁重偏向至自己),偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定状态,决定撤销后标志位恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态。对于后者,需要等待持有偏向锁的线程到达安全点,再将偏向锁替换成轻量级锁。

 

9. 某一代偏向锁失效。// 需要更新 epoch 值

如果某一类锁对象的总撤销数超过了一个阈值(对应 Java 虚拟机参数 -XX:BiasedLockingBulkRebiasThreshold,默认为 20),那么 Java 虚拟机会宣布这个类的偏向锁失效。

虚拟机会在每个类中维护一个 epoch 值,可以理解为第几代偏向锁。在设置偏向锁时,虚拟机需要将该 epoch 值复制到锁对象的标记字段中。在宣布某个类的偏向锁失效时,虚拟机实则将该类的 epoch 值加 1,表示之前那一代的偏向锁已经失效。而新设置的偏向锁则需要复制新的 epoch 值。

 

另外,为了保证当前持有偏向锁并且已加锁的线程不至于因此丢锁,虚拟机需要遍历所有线程的 Java 栈,找出该类已加锁的实例,并且将它们标记字段中的 epoch 值加 1。该操作需要所有线程处于安全点状态。

// 偏向锁初次被某一线程获取,那么在该线程周期结束之前,一直被该线程所持有。即,某一线程“永远”持有该偏向锁。如果线程周期结束,则表示该偏向锁对象不被锁定。// 当进行 epoch 值更新时,如果此时之前持有偏向锁的线程周期已经结束,那么该 epoch 值是不会被更新的,因此,当 epoch 值不相等时,则可以直接将该偏向锁偏向自己。因为之前的线程周期已经结束。

 

10. 取消偏向锁。

如果总撤销数超过另一个阈值(对应 Java 虚拟机参数 -XX:BiasedLockingBulkRevokeThreshold,默认值为 40),那么 Java 虚拟机会认为这个类已经不再适合偏向锁。

此时,Java 虚拟机会撤销该类实例的偏向锁,并且在之后的加锁过程中直接为该类实例设置轻量级锁。

用户头像

Geek_571bdf

关注

还未添加个人签名 2019.06.13 加入

还未添加个人简介

评论

发布
暂无评论
虚拟机如何实现synchronized