synchronized 的三种应用方式

修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁。

修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁。

修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码前要获得给定对象的锁。

实现代码如下:

  1. public class SynchronizedDemo {
  2. private Object lock1 = new Object();
  3. private static Object lock2 = new Object();
  4. /**
  5. * 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
  6. */
  7. public synchronized void demo1() {
  8. }
  9. /**
  10. * 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
  11. */
  12. public synchronized static void demo2() {
  13. }
  14. /**
  15. * 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码前要获得给定对象的锁
  16. */
  17. public void demo3() {
  18. // 作用于当前实例加锁
  19. synchronized (this) {
  20. }
  21. //作用于当前类对象加锁
  22. synchronized (SynchronizedDemo.class) {
  23. }
  24. // 作用于lock1实例加锁
  25. synchronized (lock1) {
  26. }
  27. // 作用于当前类对象加锁
  28. synchronized (lock2) {
  29. }
  30. }
  31. }

synchronized 扩后后面的对象是一把锁,在 Java 中任意一个对象都可以成为锁,简单来说,我们把 Object 比喻是一个 key,拥有这个 key 的线程才能执行这个方法,拿到这个 key 以后在执行方法过程中,这个 key 是随身携带的,并且只有一把。如果后续的线程想访问当前方法,因为没有 key 所以不能访问只能在门口等着,等之前的线程把 key 放回去。所以,synchronized 锁定的对象必须是同一个,如果是不同对象,就意味着是不同的房间的钥匙,对于访问者来说是没有任何影响的。

synchronized 锁的原理

字节码指令

通过 javap -v 来查看对应代码的字节码指令,对于同步块的实现使用了 monitorenter monitorexit 指令,前面我们在讲 JMM 的时候,提到过这两个指令,他们隐式的执行了 Lock 和 UnLock 操作,用于提供原子性保证。

monitorenter 指令插入到同步代码块开始的位置,monitorexit 指令插入到同步代码块结束位置,jvm 需要保证每个monitorenter 都有一个 monitorexit 对应。

这两个指令,本质上都是对一个对象的监视器(monitor)进行获取,这个过程是排他的,也就是说同一时刻只能有一个线程获取到由 synchronized 所保护对象的监视器。

线程执行到 monitorenter 指令时,会尝试获取对象所对应的 monitor 所有权,也就是尝试获取对象的锁,而执行monitorexit,就是释放 monitor 的所有权。

查看如下代码的字节码指令:

  1. public class SynchronizedDemo {
  2. public void demo3() {
  3. synchronized (this) {
  4. }
  5. }
  6. public static void main(String[] args) {
  7. }
  8. }

image.png

为什么这里会出现两个 monitorexit 呢?因为一个线程获得了一把锁就必须要释放,否则其他线程就不能获得锁,导致其他线程无限等待。所以当方法运行时出现异常了,也需要提供释放锁的指令(第二个 monitorexit)。

对象头

在 Hotspot 虚拟机中,对象在内存中的布局分为三个区域:对象头、实例数据、对齐填充。Java 对象头是实现 synchronized 锁对象的基础,synchronized 使用的锁对象存储在 Java 对象头里。它是轻量级锁和偏向锁的关键。

Java 对象头的内容如下:

image.png

Mark Word

Mark Work 默认存储对象的 HashCode、分代年龄和锁标记位。32 位 JVM 的 Mark Work 的默认存储结构如下表所示。

image.png

在运行期间,Mark Word 里存储的数据会随着锁标志位的变化而变化。Mark Word 可能变化为存储以下4种数据。如下表所示。
image.png

在64位虚拟机下,Mark Word 是64 bit 大小的,其存储结构如下表所示。

image.png

源码实现

如果想更深入了解对象头在 JVM 源码中的定义,需要关心几个文件,oop.hpp / markOop.hpp。每个 Java Object 在 JVM 内部都有一个 oop / oopDesc 与之对应。先在 oop.hpp 中看 oopDesc 的定义。

image.png

这个 _mark 属性就是上面说到的 Mark Word,在 markOop.hpp 文件中有一些注释说明了 markOop 的内存布局。

image.png

对象监视器

什么是 Monitor?我们可以把它理解为一个同步工具,也可以描述为一种同步机制。所有的 Java 对象是天生的 Monitor,每个 Object 的 markOop.hpp -> monitor() 方法返回 ObjectMonitor 对象。
image.png

  • oop.hpp 中 oopDesc 类是 JVM 对象的顶级基类,所以每个 Object 对象都包含 markOop。
  • markOop.hpp 中 markOopDesc 继承自 oopDesc,并扩展了自己的 monitor() 方法,这个方法返回一个ObjectMonitor 指针对象。
  • objectMonitor.hpp 在 hotspot 虚拟机中,采用 ObjectMonitor 类来实现 monitor。

锁的升级和获取过程

synchronized 是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的 Mutex Lock 来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 synchronized 效率低的原因。

因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为“重量级锁”。JDK中对 synchronized 做的种种优化,其核心都是为了减少这种重量级锁的使用。JDK1.6 以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和“偏向锁”。

锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。

偏向锁

偏向锁适用的场景是同一线程多次执行同步块的情况,如果存在多个线程的情况,就会导致偏向锁膨胀为轻量级锁。

偏向锁加锁

当一个线程访问同步块并获取锁时,将通过 CAS(Compare And Swap) 来尝试将对象头中的 Thread ID 字段设置为自己的线程号,如果设置成功,则获得锁,那么以后线程再次进入和退出同步块时,就不需要使用 CAS 来获取锁,只是简单的测试一个对象头中的 Mark Word 字段中是否存储 着指向当前线程的偏向锁;如果使用 CAS 设置失败时,说明存在锁的竞争,那么将执行偏向锁的撤销操作(revoke bias),将偏向锁升级为轻量级锁。

偏向锁撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。

下图线程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程。

231224406169.png

关闭偏向锁

偏向锁在 Java 6 和 Java 7 里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用 JVM 参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过 JVM 参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

轻量级锁


轻量级锁适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。

轻量级锁加锁

线程在执行同步块之前,JVM 会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中,官方称为 Displaced Mark Word。然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。当竞争线程的自旋次数达到界限值(threshold),轻量级锁将会膨胀为重量级锁。

轻量级锁解锁

轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

下图两个线程同时争夺锁,导致锁膨胀的流程图

231243138199.png

重量级锁

重量级锁通过对象内部的监视器(Monitor)实现,其中 Monitor 的本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

在 hotspot 虚拟机中,通过 ObjectMonitor 类来实现 Monitor。锁的获取过程如下图所示。

image.png

参考

https://www.zhihu.com/question/55075763
https://www.cnblogs.com/paddix/p/5405678.html
synchronized 锁升级机制
Java并发之彻底搞懂偏向锁升级为轻量级锁
JVM锁简介:偏向锁、轻量级锁和重量级锁
死磕系列,估计比较牛,需要死磕的可以看看

这些文章内容之间有可能有冲突,挑着看看吧。

作者:殷建卫 链接:https://www.yuque.com/yinjianwei/vyrvkf/og00ra 来源:殷建卫 - 架构笔记 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。