偏向锁的进化和废弃

锁的演变

在 JDK1.5 之前,面对 Java 并发问题, synchronized 是一招鲜的解决方案:
普通同步方法,锁上当前实例对象
静态同步方法,锁上当前类 Class 对象
同步块,锁上括号里面配置的对象
拿同步块来举例:

public void test(){
  synchronized (object) {
    i++;
  }
}

经过javap -v编译后的指令如下:

偏向锁的进化和废弃_第1张图片
monitorenter指令是在编译后插入到同步代码块的开始位置;monitorexit是插入到方法结束和异常的位置(实际隐藏了try-finally),每个对象都有一个 monitor 与之关联,当一个线程执行到 monitorenter 指令时,就会获得对象所对应的monitor的所有权,也就获得到了对象的锁。
当另外一个线程执行到同步块的时候,由于它没有对应monitor的所有权,就会被阻塞,此时控制权只能交给操作系统,也就会从user mode切换到kernel mode, 由操作系统来负责线程间的调度和线程的状态变更, 需要频繁的在这两个模式下切换(上下文转换)。这种有点竞争就找内核的行为很不好,会引起很大的开销,所以大家都叫它重量级锁,自然效率也很低。
来到 JDK1.6,要怎样优化才能让锁变的轻量级一些?答案就是:
轻量级锁:CPU CAS
如果 CPU 通过简单的 CAS 能处理加锁/释放锁,这样就不会有上下文的切换,较重量级锁而言自然就轻了很多。但是当竞争很激烈,CAS 尝试再多也是浪费 CPU,权衡一下,不如升级成重量级锁,阻塞线程排队竞争,也就有了轻量级锁升级成重量级锁的过程
偏向锁的进化和废弃_第2张图片
HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,同一个线程反复获取锁,如果还按照轻量级锁的方式获取锁(CAS),也是有一定代价的,如何让这个代价更小一些呢?
偏向锁
偏向锁实际就是锁对象潜意识「偏心」同一个线程来访问,让锁对象记住线程 ID,当线程再次获取锁时,亮出身份,如果同一个 ID 直接就获取锁就好了,是一种load-and-test的过程,相较 CAS 自然又轻量级了一些
可是多线程环境,也不可能只是同一个线程一直获取这个锁,其他线程也是要干活的,如果出现多个线程竞争的情况,也就有了偏向锁升级的过程
偏向锁的进化和废弃_第3张图片
都是同一个锁对象,却有多种锁状态,其目的显而易见:
占用的资源越少,程序执行的速度越快
偏向锁,轻量锁,它俩都不会调用系统互斥量(Mutex Lock),只是为了提升性能,多出的两种锁的状态,这样可以在不同场景下采取最合适的策略,所以可以总结性的说:
偏向锁:无竞争的情况下,只有一个线程进入临界区,采用偏向锁
轻量级锁:多个线程可以交替进入临界区,采用轻量级锁
重量级锁:多线程同时进入临界区,交给操作系统互斥量来处理

认识 Java 对象头

按照常规理解,识别线程 ID 需要一组 mapping 映射关系来搞定,如果单独维护这个 mapping 关系又要考虑线程安全的问题。奥卡姆剃刀原理,Java 万物皆是对象,对象皆可用作锁,与其单独维护一个 mapping 关系,不如中心化将锁的信息维护在 Java 对象本身上
Java 对象头最多由三部分构成:
MarkWord
ClassMetadata Address
Array Length (如果对象是数组才会有这部分)
其中Markword是保存锁状态的关键,对象锁状态可以从偏向锁升级到轻量级锁,再升级到重量级锁,加上初始的无锁状态,可以理解为有 4 种状态。想在一个对象中表示这么多信息自然就要用位存储,在 64 位操作系统中,是这样存储的(注意颜色标记)。
偏向锁的进化和废弃_第4张图片

认识偏向锁

单纯的看上图,还是显得十分抽象,作为程序员的我们最喜欢用代码说话,贴心的 openjdk 官网提供了可以查看对象内存布局的工具 JOL (java object layout)

<dependency>
  <groupId>org.openjdk.jol</groupId>
  <artifactId>jol-core</artifactId>
  <version>0.14</version>
</dependency>

场景1

 public static void main(String[] args) {
  Object o = new Object();
  log.info("未进入同步块,MarkWord 为:");
  log.info(ClassLayout.parseInstance(o).toPrintable());
  synchronized (o){
   log.info(("进入同步块,MarkWord 为:"));
   log.info(ClassLayout.parseInstance(o).toPrintable());
  }
 }

来看输出结果:
偏向锁的进化和废弃_第5张图片
上面我们用到的 JOL 版本为0.14, 带领大家快速了解一下位具体值,接下来我们就要用0.16版本查看输出结果,因为这个版本给了我们更友好的说明,同样的代码,来看输出结果:
偏向锁的进化和废弃_第6张图片
看到这个结果,你应该是有疑问的,JDK 1.6 之后默认是开启偏向锁的,为什么初始化的代码是无锁状态,进入同步块产生竞争就绕过偏向锁直接变成轻量级锁了呢?

虽然默认开启了偏向锁,但是开启有延迟,大概 4s。原因是 JVM 内部的代码有很多地方用到了synchronized,如果直接开启偏向,产生竞争就要有锁升级,会带来额外的性能损耗,所以就有了延迟策略

我们可以通过参数-XX:BiasedLockingStartupDelay=0将延迟改为0,但是不建议这么做。我们可以通过一张图来理解一下目前的情况
偏向锁的进化和废弃_第7张图片
场景2
那我们就代码延迟 5 秒来创建对象,来看看偏向是否生效

 public static void main(String[] args) throws InterruptedException {
  // 睡眠 5s
  Thread.sleep(5000);
  Object o = new Object();
  log.info("未进入同步块,MarkWord 为:");
  log.info(ClassLayout.parseInstance(o).toPrintable());
  synchronized (o){
   log.info(("进入同步块,MarkWord 为:"));
   log.info(ClassLayout.parseInstance(o).toPrintable());
  }
 }

重新查看运行结果:
偏向锁的进化和废弃_第8张图片
这样的结果是符合我们预期的,但是结果中的biasable状态,在 MarkWord 表格中并不存在,其实这是一种匿名偏向状态,是对象初始化中,JVM 帮我们做的
这样当有线程进入同步块:

可偏向状态:直接就 CAS 替换 ThreadID,如果成功,就可以获取偏向锁了
不可偏向状态:就会变成轻量级锁

那问题又来了,现在锁对象有具体偏向的线程,如果新的线程过来执行同步块会偏向新的线程吗?
场景3

public static void main(String[] args) throws InterruptedException {
  // 睡眠 5s
  Thread.sleep(5000);
  Object o = new Object();
  log.info("未进入同步块,MarkWord 为:");
  log.info(ClassLayout.parseInstance(o).toPrintable());
  synchronized (o){
   log.info(("进入同步块,MarkWord 为:"));
   log.info(ClassLayout.parseInstance(o).toPrintable());
  }

  Thread t2 = new Thread(() -> {
   synchronized (o) {
    log.info("新线程获取锁,MarkWord为:");
    log.info(ClassLayout.parseInstance(o).toPrintable());
   }
  });

  t2.start();
  t2.join();
  log.info("主线程再次查看锁对象,MarkWord为:");
  log.info(ClassLayout.parseInstance(o).toPrintable());

  synchronized (o){
   log.info(("主线程再次进入同步块,MarkWord 为:"));
   log.info(ClassLayout.parseInstance(o).toPrintable());
  }
 }

来看运行结果,奇怪的事情发生了:
偏向锁的进化和废弃_第9张图片

  • 标记1: 初始可偏向状态
  • 标记2: 偏向主线程后,主线程退出同步代码块
  • 标记3: 新线程进入同步代码块,升级成了轻量级锁
  • 标记4: 新线程轻量级锁退出同步代码块,主线程查看,变为不可偏向状态
  • 标记5: 由于对象不可偏向,同场景1主线程再次进入同步块,自然就会用轻量级锁
    至此,场景一二三可以总结为一张图:
    偏向锁的进化和废弃_第10张图片

偏向撤销

在真正讲解偏向撤销之前,需要和大家明确一个概念——偏向锁撤销和偏向锁释放是两码事

  • 撤销:笼统的说就是多个线程竞争导致不能再使用偏向模式的时候,主要是告知这个锁对象不能再用偏向模式
  • 释放:和你的常规理解一样,对应的就是synchronized 方法的退出或 synchronized 块的结束

何为偏向撤销?

从偏向状态撤回原有的状态,也就是将 MarkWord 的第 3 位(是否偏向撤销)的值,从 1 变回 0,
在获取偏向锁的过程中,发现cas失败也就是存在线程竞争时,直接把被偏向的锁对象升级到被加了轻量级锁的状态

如果只是一个线程获取锁,再加上「偏心」的机制,是没有理由撤销偏向的,所以偏向的撤销只能发生在有竞争的情况下

想要撤销偏向锁,还不能对持有偏向锁的线程有影响,所以就要等待持有偏向锁的线程到达一个safepoint 安全点(这里的安全点是 JVM 为了保证在垃圾回收的过程中引用关系不会发生变化设置的一种安全状态,在这个状态上会暂停所有线程工作), 在这个安全点会挂起获得偏向锁的线程

在这个安全点,线程可能还是处在不同状态的

  • 线程不存活或者活着的线程但退出了同步块,很简单,直接撤销偏向就好了
  • 活着的线程但仍在同步块之内,那就要升级成轻量级锁

偏向锁是特定场景下提升程序效率的方案,可并不代表程序员写的程序都满足这些特定场景,比如这些场景(在开启偏向锁的前提下):

  • 一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种case下,会导致大量的偏向锁撤销操作
  • 明知有多线程竞争(生产者/消费者队列),还要使用偏向锁,也会导致各种撤销
    很显然,这两种场景肯定会导致偏向撤销的,一个偏向撤销的成本无所谓,大量偏向撤销的成本是不能忽视的。那怎么办?既不想禁用偏向锁,还不想忍受大量撤销偏向增加的成本,这种方案就是设计一个有阶梯的底线

批量重偏向(bulk rebias)

这是第一种场景的快速解决方案,以 class 为单位,为每个 class 维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时:

BiasedLockingBulkRebiasThreshold = 20

JVM 就认为该class的偏向锁有问题,因此会进行批量重偏向, 它的实现方式就用到了epoch

Epoch,如其含义「纪元」一样,就是一个时间戳。每个 class 对象会有一个对应的epoch字段,每个处于偏向锁状态对象的mark word中也有该字段,其初始值为创建该对象时 class 中的epoch的值(此时二者是相等的)。每次发生批量重偏向时,就将该值加1,同时遍历JVM中所有线程的栈

  • 找到该 class 所有正处于加锁状态的偏向锁对象,将其epoch字段改为新值
  • class 中不处于加锁状态的偏向锁对象(没被任何线程持有,但之前是被线程持有过的,这种锁对象的 markword 肯定也是有偏向的),保持epoch字段值不变

这样下次获得锁时,发现当前对象的epoch值和class的epoch不同,本着今朝不问前朝事的原则(上一个纪元),那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过 CAS 操作将其mark word的线程 ID 改成当前线程 ID,这也算是一定程度的优化,毕竟没升级锁;

如果epoch都一样,说明没有发生过批量重偏向, 如果markword有线程ID,还有其他锁来竞争,那锁自然是要升级的

批量重偏向是第一阶梯底线,还有第二阶梯底线

批量撤销(bulk revoke)

当达到重偏向阈值后,假设该 class 计数器继续增长,当其达到批量撤销的阈值后(默认40)时

BiasedLockingBulkRevokeThreshold = 40

JVM就认为该 class 的使用场景存在多线程竞争,会标记该 class 为不可偏向。之后对于该 class 的锁,直接走轻量级锁的逻辑

这就是第二阶梯底线,但是在第一阶梯到第二阶梯的过渡过程中,也就是在彻底禁用偏向锁之前,还给一次改过自新的机会,那就是另外一个计时器:

BiasedLockingDecayTime = 25000
  • 如果在距离上次批量重偏向发生的 25 秒之内,并且累计撤销计数达到40,就会发生批量撤销(偏向锁彻底 game over)
  • 如果在距离上次批量重偏向发生超过 25 秒之外,那么就会重置在[20, 40)内的计数, 再给次机会

至此,整个偏向锁的工作流程可以用一张图表示:
偏向锁的进化和废弃_第11张图片

HashCode 哪去了

上面场景一,无锁状态,对象头中没有 hashcode;偏向锁状态,对象头还是没有 hashcode,那我们的 hashcode 哪去了?

首先要知道,hashcode 不是创建对象就帮我们写到对象头中的,而是要经过第一次调用Object::hashCode()或者System::identityHashCode(Object)才会存储在对象头中的。第一次生成的 hashcode后,该值应该是一直保持不变的,但偏向锁又是来回更改锁对象的 markword,必定会对 hashcode 的生成有影响,那怎么办呢?,我们来用代码验证:
场景一

 public static void main(String[] args) throws InterruptedException {
  // 睡眠 5s
  Thread.sleep(5000);

  Object o = new Object();
  log.info("未生成 hashcode,MarkWord 为:");
  log.info(ClassLayout.parseInstance(o).toPrintable());

  o.hashCode();
  log.info("已生成 hashcode,MarkWord 为:");
  log.info(ClassLayout.parseInstance(o).toPrintable());

  synchronized (o){
   log.info(("进入同步块,MarkWord 为:"));
   log.info(ClassLayout.parseInstance(o).toPrintable());
  }
 }

来看运行结果
偏向锁的进化和废弃_第12张图片

结论就是:即便初始化为可偏向状态的对象,一旦调用Object::hashCode()或者System::identityHashCode(Object),进入同步块就会直接使用轻量级锁

场景二

假如已偏向某一个线程,然后生成 hashcode,然后同一个线程又进入同步块,会发生什么呢?来看代码:

 public static void main(String[] args) throws InterruptedException {
  // 睡眠 5s
  Thread.sleep(5000);

  Object o = new Object();
  log.info("未生成 hashcode,MarkWord 为:");
  log.info(ClassLayout.parseInstance(o).toPrintable());

  synchronized (o){
   log.info(("进入同步块,MarkWord 为:"));
   log.info(ClassLayout.parseInstance(o).toPrintable());
  }

  o.hashCode();
  log.info("生成 hashcode");
  synchronized (o){
   log.info(("同一线程再次进入同步块,MarkWord 为:"));
   log.info(ClassLayout.parseInstance(o).toPrintable());
  }
 }

查看运行结果:
偏向锁的进化和废弃_第13张图片

结论就是:同场景一,会直接使用轻量级锁

场景三
那假如对象处于已偏向状态,在同步块中调用了那两个方法会发生什么呢?继续代码验证:

 public static void main(String[] args) throws InterruptedException {
  // 睡眠 5s
  Thread.sleep(5000);

  Object o = new Object();
  log.info("未生成 hashcode,MarkWord 为:");
  log.info(ClassLayout.parseInstance(o).toPrintable());

  synchronized (o){
   log.info(("进入同步块,MarkWord 为:"));
   log.info(ClassLayout.parseInstance(o).toPrintable());
   o.hashCode();
   log.info("已偏向状态下,生成 hashcode,MarkWord 为:");
   log.info(ClassLayout.parseInstance(o).toPrintable());
  }
 }

来看运行结果:
偏向锁的进化和废弃_第14张图片

结论就是:如果对象处在已偏向状态,生成 hashcode 后,就会直接升级成重量级锁

最后用书中的一段话来描述 锁和hashcode 之前的关系:
偏向锁的进化和废弃_第15张图片
调用 Object.wait() 方法会发生什么?

Object 除了提供了上述 hashcode 方法,还有wait()方法,这也是我们在同步块中常用的,那这会对锁产生哪些影响呢?来看代码:

 public static void main(String[] args) throws InterruptedException {
  // 睡眠 5s
  Thread.sleep(5000);

  Object o = new Object();
  log.info("未生成 hashcode,MarkWord 为:");
  log.info(ClassLayout.parseInstance(o).toPrintable());

  synchronized (o) {
   log.info(("进入同步块,MarkWord 为:"));
   log.info(ClassLayout.parseInstance(o).toPrintable());

   log.info("wait 2s");
   o.wait(2000);

   log.info(("调用 wait 后,MarkWord 为:"));
   log.info(ClassLayout.parseInstance(o).toPrintable());
  }
 }

查看运行结果:
偏向锁的进化和废弃_第16张图片

结论就是,wait 方法是互斥量(重量级锁)独有的,一旦调用该方法,就会升级成重量级锁

最后再继续丰富一下锁对象变化图:偏向锁的进化和废弃_第17张图片

废弃偏向锁

为啥要废弃偏向锁,因为维护成本有些高了,来看 Open JDK 官方声明,JEP 374: Deprecate and Disable Biased Locking,相信你看上面的文字说明也深有体会,为了一个现在少有的场景付出了巨大的代码实现
偏向锁的进化和废弃_第18张图片
这个说明的更新时间距离现在很近,在 JDK15 版本就已经开始了,一句话解释就是维护成本太高,最终JDK 15 之前,偏向锁默认是 enabled,从 15 开始,默认就是 disabled,除非显示的通过UseBiasedLocking 开启,偏向锁给 JVM 增加了巨大的复杂性,只有少数非常有经验的程序员才能理解整个过程,维护成本很高,大大阻碍了开发新特性的进程。

轻量级和重量级锁,hashcode 存在了什么位置?
如果进入各种锁状态,那么会缓存在其他地方,一般是获取锁的线程里面存储,恢复无锁(即释放锁)会改回原有的哈希值。
轻量级-》lockRecord
重量级锁-》objectMonitor

你可能感兴趣的:(多线程与高并发,java,偏向锁)