关于jvm:深入理解Java虚拟机是怎么实现synchronized的

3次阅读

共计 5030 个字符,预计需要花费 13 分钟才能阅读完成。

文章收录地址:Java-Bang
专一于零碎架构、高可用、高性能、高并发类技术分享

在 Java 程序中,咱们能够利用 synchronized 关键字来对程序进行加锁。它既能够用来申明一个 synchronized 代码块,也能够间接标记静态方法或者实例办法。

当申明 synchronized 代码块时,编译而成的字节码将蕴含 monitorenter 和 monitorexit 指令。这两种指令均会耗费操作数栈上的一个援用类型的元素(也就是 synchronized 关键字括号里的援用),作为所要加锁解锁的锁对象。

 

 public void foo(Object lock) {synchronized (lock) {lock.hashCode();
    }
  }
  // 下面的 Java 代码将编译为上面的字节码
  public void foo(java.lang.Object);
    Code:
       0: aload_1
       1: dup
       2: astore_2
       3: monitorenter
       4: aload_1
       5: invokevirtual java/lang/Object.hashCode:()I
       8: pop
       9: aload_2
      10: monitorexit
      11: goto          19
      14: astore_3
      15: aload_2
      16: monitorexit
      17: aload_3
      18: athrow
      19: return
    Exception table:
       from    to  target type
           4    11    14   any
          14    17    14   any

我在文稿中贴了一段蕴含 synchronized 代码块的 Java 代码,以及它所编译而成的字节码。你可能会留意到,下面的字节码中蕴含一个 monitorenter 指令以及多个 monitorexit 指令。这是因为 Java 虚拟机须要确保所取得的锁在失常执行门路,以及异样执行门路上都可能被解锁。

你能够依据我在介绍异样解决时介绍过的常识,对照字节码和异样处理表来结构所有可能的执行门路,看看在执行了 monitorenter 
指令之后,是否都有执行 monitorexit 指令。

当用 synchronized 标记办法时,你会看到字节码中办法的拜访标记包含 ACC_SYNCHRONIZED。该标记示意在进入该办法时,Java 虚拟机须要进行 monitorenter 操作。而在退出该办法时,不论是失常返回,还是向调用者抛异样,Java 虚拟机均须要进行 monitorexit 操作。

public synchronized void foo(Object lock) {lock.hashCode();
}

// 下面的 Java 代码将编译为上面的字节码
public synchronized void foo(java.lang.Object);
descriptor: (Ljava/lang/Object;)V
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
  stack=1, locals=2, args_size=2
     0: aload_1
     1: invokevirtual java/lang/Object.hashCode:()I
     4: pop
     5: return

这里 monitorenter 和 monitorexit 操作所对应的锁对象是隐式的 对于实例办法来说,这两个操作对应的锁对象是 this;对于静态方法来说,这两个操作对应的锁对象则是所在类的 Class 实例

对于 monitorenter 和 monitorexit 的作用 ,咱们 能够抽象地了解为每个锁对象领有一个锁计数器和一个指向持有该锁的线程的指针

当执行 monitorenter 时,如果指标锁对象的计数器为 0,那么阐明它没有被其余线程所持有。在这个状况下,Java 虚构机会将该锁对象的持有线程设置为以后线程,并且将其计数器加 1。

在指标锁对象的计数器不为 0 的状况下,如果锁对象的持有线程是以后线程,那么 Java 虚拟机能够将其计数器加 1,否则须要期待,直至持有线程开释该锁。

当执行 monitorexit 时,Java 虚拟机则需将锁对象的计数器减 1。当计数器减为 0 时,那便代表该锁曾经被开释掉了。

之所以采纳这种计数器的形式,是为了容许同一个线程反复获取同一把锁。举个例子,如果一个 Java 类中领有多个 synchronized 办法,那么这些办法之间的互相调用,不论是间接的还是间接的,都会波及对同一把锁的反复加锁操作。因而,咱们须要设计这么一个可重入的个性,来防止编程里的隐式束缚。

说完形象的锁算法,上面咱们便来介绍 HotSpot 虚拟机中具体的锁实现。

重量级锁

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

Java 线程的阻塞以及唤醒,都是依附操作系统来实现的。举例来说,对于合乎 posix 接口的操作系统(如 macOS 和绝大部分的 Linux),上述操作是通过 pthread 的互斥锁(mutex)来实现的。此外,这些操作将波及零碎调用,须要从操作系统的用户态切换至内核态,其开销十分之大。

为了尽量避免低廉的线程阻塞、唤醒操作,Java 虚构机会在线程进入阻塞状态之前,以及被唤醒后竞争不到锁的状况下,进入 自旋 状态,在处理器上空跑并且轮询锁是否被开释。如果此时锁恰好被开释了,那么以后线程便毋庸进入阻塞状态,而是间接取得这把锁。

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

咱们能够用等红绿灯作为例子。Java 线程的阻塞相当于熄火停车,而自旋状态相当于怠速停车。如果红灯的等待时间十分长,那么熄火停车绝对省油一些;如果红灯的等待时间十分短,比如说咱们在 synchronized 代码块里只做了一个整型加法,那么在短时间内锁必定会被释放出来,因而怠速停车更加适合。

然而,对于 Java 虚拟机来说,它并不能看到红灯的剩余时间,也就没方法依据等待时间的长短来抉择自旋还是阻塞。Java 虚拟机给出的计划是 自适应自旋,依据以往自旋期待时是否可能取得锁,来动静调整自旋的工夫(循环数目)。

就咱们的例子来说,如果之前不熄火等到了绿灯,那么这次不熄火的工夫就长一点;如果之前不熄火没等到绿灯,那么这次不熄火的工夫就短一点。

自旋状态 还带来另外一个 副作用 ,那便是 不偏心的锁机制 处于阻塞状态的线程,并没有方法立即竞争被开释的锁 。然而, 处于自旋状态的线程,则很有可能优先取得这把锁

轻量级锁

你可能见到过深夜的十字路口,四个方向都闪黄灯的状况。因为深夜十字路口的车辆来往可能比拟少,如果还设置红绿灯交替,那么很有可能呈现四个方向仅有一辆车在等红灯的状况。

因而,红绿灯可能被设置为闪黄灯的状况,代表车辆能够自在通过,然而司机须要留神察看(集体了解,实际意义请征询交警部门)。

Java 虚拟机也存在着相似的情景:多个线程在不同的时间段申请同一把锁,也就是说没有锁竞争。针对这种情景,Java 虚拟机采纳了轻量级锁,来防止重量级锁的阻塞以及唤醒。

在介绍轻量级锁的原理之前,咱们先来理解一下 Java 虚拟机是怎么辨别轻量级锁和重量级锁的。
(你能够参照 HotSpot Wiki 里这张图浏览。)

在对象内存布局那一篇中我已经介绍了 对象头中的标记字段(mark word)。它的最初两位便被用来示意该对象的锁状态。其中,00 代表轻量级锁,01 代表无锁(或偏差锁),10 代表重量级锁,11 则跟垃圾回收算法的标记无关

当进行加锁操作时,Java 虚构机会判断是否曾经是重量级锁。如果不是,它会在以后线程的以后栈桢中划出一块空间,作为该锁的锁记录,并且将锁对象的标记字段复制到该锁记录中。

而后,Java 虚构机会尝试用 CAS(compare-and-swap)操作替换锁对象的标记字段。这里解释一下,CAS 是一个原子操作,它会比拟指标地址的值是否和期望值相等,如果相等,则替换为一个新的值

假如以后锁对象的标记字段为 X…XYZ,Java 虚构机会比拟该字段是否为 X…X01。如果是,则替换为方才调配的锁记录的地址。因为内存对齐的缘故,它的最初两位为 00。此时,该线程已胜利取得这把锁,能够继续执行了。

如果不是 X…X01,那么有两种可能。第一,该线程反复获取同一把锁。此时,Java 虚构机会将锁记录清零,以代表该锁被反复获取。第二,其余线程持有该锁。此时,Java 虚构机会将这把锁收缩为重量级锁,并且阻塞以后线程。

当进行解锁操作时,如果以后锁记录(你能够将一个线程的所有锁记录设想成一个栈构造,每次加锁压入一条锁记录,解锁弹出一条锁记录,以后锁记录指的便是栈顶的锁记录)的值为 0,则代表反复进入同一把锁,间接返回即可。

否则,Java 虚构机会尝试用 CAS 操作,比拟锁对象的标记字段的值是否为以后锁记录的地址。如果是,则替换为锁记录中的值,也就是锁对象本来的标记字段。此时,该线程曾经胜利开释这把锁。

如果不是,则意味着这把锁曾经被收缩为重量级锁。此时,Java 虚构机会进入重量级锁的开释过程,唤醒因竞争该锁而被阻塞了的线程。

偏差锁

如果说轻量级锁针对的状况很乐观,那么接下来的偏差锁针对的状况则更加乐观:从始至终只有一个线程申请某一把锁。

这就好比你在私家庄园里装了个红绿灯,并且庄园里只有你在开车。偏差锁的做法便是在红绿灯处辨认来车的车牌号。如果匹配到你的车牌号,那么间接亮绿灯。

具体来说,在线程进行加锁时,如果该锁对象反对偏差锁,那么 Java 虚构机会通过 CAS 操作,将以后线程的地址记录在锁对象的标记字段之中,并且将标记字段的最初三位设置为 101。

在接下来的运行过程中,每当有线程申请这把锁,Java 虚拟机只需判断锁对象标记字段中:最初三位是否为 101,是否蕴含以后线程的地址,以及 epoch 值是否和锁对象的类的 epoch 值雷同。如果都满足,那么以后线程持有该偏差锁,能够间接返回。

这里的 epoch 值是一个什么概念呢?

咱们先从偏差锁的撤销讲起。当申请加锁的线程和锁对象标记字段放弃的线程地址不匹配时(而且 epoch 值相等,如若不等,那么以后线程能够将该锁重偏差至本人),Java 虚拟机须要撤销该偏差锁。这个撤销过程十分麻烦,它要求持有偏差锁的线程达到平安点,再将偏差锁替换成轻量级锁。

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

具体的做法便是在每个类中保护一个 epoch 值,你能够了解为第几代偏差锁。当设置偏差锁时,Java 虚拟机须要将该 epoch 值复制到锁对象的标记字段中。

在发表某个类的偏差锁生效时,Java 虚拟机实则将该类的 epoch 值加 1,示意之前那一代的偏差锁曾经生效。而新设置的偏差锁则须要复制新的 epoch 值。

为了保障以后持有偏差锁并且已加锁的线程不至于因而丢锁,Java 虚拟机须要遍历所有线程的 Java 栈,找出该类已加锁的实例,并且将它们标记字段中的 epoch 值加 1。该操作须要所有线程处于平安点状态。

如果总撤销数超过另一个阈值(对应 Java 虚拟机参数 -XX:BiasedLockingBulkRevokeThreshold,默认值为 40),那么 Java 虚构机会认为这个类曾经不再适宜偏差锁。此时,Java 虚构机会撤销该类实例的偏差锁,并且在之后的加锁过程中间接为该类实例设置轻量级锁。

总结与实际

重量级锁会阻塞、唤醒申请加锁的线程。它针对的是多个线程同时竞争同一把锁的状况。Java 虚拟机采取了自适应自旋,来防止线程在面对十分小的 synchronized 代码块时,仍会被阻塞、唤醒的状况。

轻量级锁采纳 CAS 操作,将锁对象的标记字段替换为一个指针,指向以后线程栈上的一块空间,存储着锁对象本来的标记字段。它针对的是多个线程在不同时间段申请同一把锁的状况。

偏差锁只会在第一次申请时采纳 CAS 操作,在锁对象的标记字段中记录下以后线程的地址。在之后的运行过程中,持有该偏差锁的线程的加锁操作将间接返回。它针对的是锁仅会被同一线程持有的状况。

正文完
 0