线程同步-synchronized-优化策略

优化策略

概述

  • 前言:
    • [Java 线程采用的是内核级线程实现的]{.aqua}
    • [Synchronized 每次造成的线程阻塞都会让线程执行上下文切换,从而导致 OS 从用户态陷入内核态]{.aqua}
    • [OS 从用户态陷入内核态的开销是非常大的,为了避免因频繁的阻塞造成的上下文切换带来的开销]{.aqua}
    • [JDK 官方在 JDK 6 之后推出了相应的优化策略]{.aqua}
  • 细节:下列所有优化策略对程序员来说都是透明的,全部都是由虚拟机完成的,你只需要知道它的原理
  • 必备知识:你需要了解对象的内存布局或者对象的组成才能够明白下面的知识

:::primary

参考笔记:堆空间 - 对象创建

:::

轻量级锁

  • 定义:[不采用传统的 管程 给对象加锁,而是采用更加轻量的 锁记录空间 来给对象加锁]{.red}

  • 目的:[在 多线程不会相互产生竞争 的情况下减少传统的重量级锁带来的性能消耗]{.red}

    • 没有竞争不代表不需要加锁,线程的调度完全是由操作系统完成的,线程是否会错开访问完全是未知的
    • 所以虚拟机会在多线程不产生的竞争的情况下使用轻量级锁来减少开销
  • 锁记录空间:

    • 存放位置:[每个锁记录都存放在线程各自的虚拟机栈空间中]{.red}
    • 组成:[锁记录 + 锁对象的引用地址]{.red}
      • [锁记录用于存放 Mark Word 的内容,引用地址用于指向锁住的对象]{.pink}
      • [锁记录空间的地址并没有存放在锁记录中]{.pink}
    锁记录空间
  • 加锁过程:

    1. [虚拟机会在线程的虚拟机栈空间中建立锁记录空间,用于记录锁对象的相关信息]{.pink}

    2. 线程尝试获取对象锁:

      • [线程首先将 Mark Word 内容复制到 Displaced Mark Word 中]{.pink}
      • [线程接着尝试使用 CAS 将 Mark Word 的内容更新为 锁记录地址,并将 锁状态更新为轻量级锁(00)]{.pink} * 如果锁对象的锁状态是未上锁状态(00),那线程就会成功获取对象锁 * 如果锁对象的锁状态已经是轻量级锁状态(01),那线程获取就会获取失败
      轻量级-2
    3. 线程成功获取对象锁:[线程将锁定对象的地址指向当前需要获取的对象]{.red}

    4. 线程获取对象失败:[检查 Mark Word 保存的地址是否指向当前线程的锁记录空间]{.red}

      • 如果指向的是当前线程的锁记录空间,那么当前线程已经拥有该轻量级锁了,就会执行可重入操作

        • [线程会在栈空间中新增一条锁记录]{.pink}

        • [该锁记录的不保存 Mark Word 的副本而是置为空]{.pink}

        +++danger 为什么要将重入记录的 Displaced Mark Word 置为空?

        +++

        轻量级-3
      • [如果指向的不是当前线程的锁记录空间,那么就存在其他线程获取到该轻量级锁,轻量级锁会发生锁膨胀]{.pink}

        轻量级-4
    5. 线程释放锁:

      • [如果锁记录的锁记录地址为空,那么这就是重入记录,直接将锁记录数量减少一即可]{.pink}
      • [如果锁记录的锁记录地址不为空,那么线程会采用 CAS 将 Mark Word 更新为原来的内容]{.pink} * 如果 CAS 操作能够成功,那么线程就可以正常释放锁 * [如果 CAS 操作不成功,那么就认为发生了锁膨胀,需要采用重量级锁的解锁流程]{.pink}
  • 锁膨胀

    • 定义:[轻量锁转变为重量锁]{.red}

    • 原因:存在某个线程已经持有对象的轻量级锁时,其余线程试图获取该对象的轻量级锁,产生线程竞争

    • 膨胀过程:

      1. [虚拟机创建管程对象,Mark Word 指向管程对象]{.pink}

      2. [管程对象将其拥有者设置为持有轻量级锁的线程]{.pink}

      3. [持有轻量锁的线程在释放锁时采用 CAS 更新 Mark Word 会失败,因为 Mark Word 不是期望的锁记录地址]{.pink}

      4. [此时线程就会进入重量级锁的释放流程]{.pink}

      <img src="https://cdn.jsdelivr.net/gh/fuyusakaiori/image@master/学习图示/锁膨胀.myitqy1y69s.png" alt="锁膨胀" style="zoom:80%;" />
      

偏向锁

  • 历史:
    • JDK 6 中引入的新的锁优化措施,默认开启偏向锁机制
    • [JDK 15 中已经默认不开启偏向锁机制 (+XX:UseBiasedLocking 开启偏向锁机制)]{.blue}

偏向

  • 定义:

    • [如果在线程获得锁之后 长期没有其余线程来尝试获取锁对象,那么该锁将会“偏向”当前线程]{.red}
    • [线程在此之后 无论重入还是普通调用 锁对象都不需要再执行任何同步措施]{.red}
  • 目的:

    • [在轻量级锁机制中每次线程执行锁重入都需要使用 CAS 去更新 Mark Word,每次更新都是存在消耗的]{.red}

    • [在偏向锁机制中只要锁对象偏向线程后,该线程就可以避免每次调用或重入锁对象造成的 CAS 开销]{.red}

  • 偏向过程:

    1. 检查锁对象是否为可偏向状态

      • [如果锁对象 Mark Word 中已经存储了哈希值,那么就不可以再开启偏向模式了]{.pink}

      • [如果虚拟机参数中已经配置禁止开启偏向锁机制的参数,那么也不可以再开启偏向模式]{.pink}

        :::info

        至于为什么在存储哈希值后就不可以开启偏向模式了,在偏向撤销时讲述

        :::

    2. [锁对象 第一次被线程获取 时会开启偏向模式,Mark Word 中偏向模式变为 1,锁记录状态依旧为 01]{.pink}

    3. 线程接着尝试使用 CAS 更新对象头中存储的内容

    • [如果对象头中哈希值仍为默认值 0,那 CAS 就可以将 哈希值 更新为 偏向线程的 ID + 偏向的时间戳]{.pink}
    • [如果对象头中的哈希值不是默认值 0,那么 虚拟机就会将对象重新置为未偏向模式]{.pink}
    1. [线程接下来的重入或者普通调用过程就不需要执行任何同步措施,直接访问即可]{.pink}
  • 细节:[偏向锁机制是默认开启的,但是偏向锁默认是延迟开启的]{.red}

    • 前者意味着所有对象在未上锁的状态下都是开启了偏向模式的
    • 后者意味着你打印对象内存布局时会发现对象的依然不处于偏向模式
  • 测试偏向锁延迟开启机制

    1. 导入 JOL 工具包用于查看对象的内存布局,也就是对象的组成内容

      <!--注意: 这个工具类有好几个版本, 每个版本打印出来的格式不太一样-->
      <dependency>
      <groupId>org.openjdk.jol</groupId>
      <artifactId>jol-core</artifactId>
      <version>0.16</version>
      </dependency>
    2. 编写测试代码用于测试偏向锁的存在

      @Slf4j(topic = "c.bias")
      public class ThreadBias
      {
      // 内部类: 里面什么都没有都可以
      private static class Bias{
      private int number;
      public Bias(int number){
      this.number = number;
      }
      }

      public static void main(String[] args) throws InterruptedException
      {
      Bias bias = new Bias();
      // 打印对象的内存布局
      System.out.println(ClassLayout.parseInstance(bias).toPrintable());
      }
      }
    3. 测试结果-1:意料之中没有开启偏向锁

      偏向锁-1
    4. 两种解决方式

      • [线程睡眠一段时间之后再打印对象的内存布局]{.blue}
      • [改变偏向锁延迟的时间:-XX:BiasedLockingStartupDelay=0]{.blue}
    5. 测试结果-2:

      偏向锁-2
  • 测试偏向锁机制:[前提是确保偏向锁能够生效,也就是没有延迟,否则是看不到偏向线程 ID 的]{.red}

    1. 编写测试代码

      @Slf4j(topic = "c.bias")
      public class ThreadBias
      {
      // 内部类不再重复
      public static void main(String[] args) throws InterruptedException
      {
      Bias bias = new Bias(10);
      // 查看未上锁的对象的内存布局
      System.out.println(ClassLayout.parseInstance(bias).toPrintable());
      // 给对象上锁
      synchronized (bias){
      // 查看处于上锁的对象内存布局
      log.debug(ClassLayout.parseInstance(bias).toPrintable());
      }
      // 查看释放锁之后的对象内存布局
      log.debug(ClassLayout.parseInstance(bias).toPrintable());
      }
      }
    2. 测试结果:对象在被主线程上锁之后就将哈希值更新为偏向线程 ID + 偏向时间戳了

      偏向锁-3

撤销偏向

  • 原因:[存在其他线程尝试获取锁对象的时候,偏向锁就会被立刻撤销转变为轻量级锁]{.red}

    • 其他线程尝试获取锁对象可能是拥有线程没有使用锁对象的时候,也有可能恰好拥有线程正在使用锁对象
    • 这两种情况的不同会导致虚拟机在撤销偏向时给定对象的锁状态的不同,前者更新为未上锁,后者就是轻量级锁
  • 撤销方式:

    • [其余线程尝试获取锁对象,无论对象是否被拥有线程使用]{.red}

      • 细节:[这种撤销方式会导致偏向锁升级为轻量级锁]{.pink}
    • [拥有线程调用计算哈希值的方法]{.red}

      • 细节:

        • [这种撤销方式会导致偏向锁直接升级为重量级锁]{.pink}
        • [这个方法会自动禁用偏向锁,并且再也无法进入偏向锁状态]{.pink}

        +++danger 为什么调用计算哈希值的方法会自动禁用偏向锁呢?

        ① 未上锁状态下的哈希值默认为 0,可以被偏向线程 ID + 偏向时间戳所替换

        ② 但是如果对象调用了计算哈希值的方法,为了保证哈希值一致性就必须将其存储在对象头中

        ③ 此时对象头就没有存储偏向线程 ID + 偏向时间戳的空间了,也就意味着无法进入偏向锁状态了

        +++

  • 撤销过程:

    1. [Mark Word 记录的偏向模式标志位立刻变为 0]{.pink}
    2. [如果锁对象正在被原拥有线程使用,那么等待使用结束之后就会先将锁状态标志位置为 01]{.pink}
      • 偏向锁状态 -> 未上锁状态 -> 轻量级锁状态 -> 未上锁状态
    3. [如果锁对象没有被原拥有线程使用,那么其他线程可以立刻使用锁对象,并且将锁状态标志位置为 00]{.pink}
      • 偏向锁状态 -> 轻量级锁状态 -> 未上锁状态
  • 测试其余线程访问造成的偏向撤销

    1. 编写测试代码
    2. 测试结果
  • 测试哈希值撤销偏向锁

    1. 编写测试代码

      @Slf4j(topic = "c.bias")
      public class ThreadBias
      {
      public static void main(String[] args) throws InterruptedException
      {
      Bias bias = new Bias(10);
      // 计算对象的哈希值
      bias.hashCode();
      // 查看未上锁的对象的内存布局
      log.debug(ClassLayout.parseInstance(bias).toPrintable());
      synchronized (bias){
      // 查看处于上锁的对象内存布局
      log.debug(ClassLayout.parseInstance(bias).toPrintable());
      }
      // 查看释放锁之后的对象内存布局
      log.debug(ClassLayout.parseInstance(bias).toPrintable());
      }
      }
    2. 测试结果

      偏向锁-4

批量重偏向

  • 定义:[偏向锁的撤销次数达到阈值之后,虚拟机会考虑重新将之后的对象重新偏向给其余的线程]{.red}

    • [撤销次数达到阈值之前撤销的对象都从偏向锁升级为轻量级]{.pink}
    • [撤销次数达到阈值之后的对象都是直接偏向另一个线程而不会经历撤销的过程]{.pink}
  • 批量重偏向次数:

    • 默认阈值:[虚拟机默认批量重偏向的阈值是达到 20 次]{.red}
    • 设置阈值:[-XX:BiasedLockingBulkRebiasThreshold=count]{.blue}
    • 统计方式:[撤销次数不是统计 每个对象 发生了多少次撤销而是统计 整个进程 中发生了多少次撤销]{.red}
  • 测试批量重偏向机制

    1. 编写测试代码

      • 主线程延迟启动确保能够开启偏向锁

      • 第一个线程开始给对象添加偏向锁,然后将其添加到集合中,以便之后其他线程使用

      • 第二个线程获取集合中的对象,就会造成偏向锁被撤销,在达到阈值之后开始批量重偏向

      @Slf4j(topic = "c.rebias")
      public class ThreadReBias
      {
      private static class Inner{

      }
      private static Thread t1;
      private static Thread t2;
      public static void main(String[] args) throws InterruptedException
      {
      // 确保偏向锁开启
      TimeUnit.SECONDS.sleep(4);
      // 存放对象的集合
      List<Inner> list = new ArrayList<>();
      // 线程开始给对象加锁
      t1 = new Thread(() -> {
      // 循环添加对象, 也就是添加偏向锁的过程
      for (int i = 1; i <= 40; i++) {
      final int temp = i;
      Inner inner = new Inner();
      synchronized (inner) {
      list.add(inner);
      // 打印内存布局
      log.debug(temp + "\t" + ClassLayout.parseInstance(inner).toPrintable());
      }
      }
      // 尽可能使用同步工具确保第二个线程不会先于第一个线程执行, 最好不要使用睡眠方法
      LockSupport.unpark(t2);
      }, "t1");
      t1.start();

      t2 = new Thread(() -> {
      // 等待一个线程执行结束
      LockSupport.park();
      log.debug("==============================开始撤销===================================");
      // 循环添加对象, 也就是添加偏向锁的过程
      for (int i = 1; i <= 40; i++) {
      final int temp = i;
      Inner inner = list.get(i - 1);
      synchronized (inner) {
      // 只打印加锁过程中的对象内存布局也是可以看出来的
      log.debug(temp + "\t" + ClassLayout.parseInstance(inner).toPrintable());
      }
      }
      }, "t2");
      t2.start();
      }
      }
    2. 测试结果

      • [锁对象偏向线程-1]{.grey}

        偏向锁-5
      • [虚拟机撤销偏向锁升级为轻量级锁]{.grey}

        偏向锁-6
      • [锁对象重偏向线程-2:]{.grey}

        • [小细节:]{.grey}
          • [实际撤销次数在达到 19 次的时候就会在下个对象身上立刻触发批量重偏向机制]{.grey}
          • [也就说批量重偏向的对象数量是 21 个,撤销的次数仅有 19 次]{.grey}
        偏向锁-7

批量撤销

  • 定义:

    • [偏向锁的撤销达到更大的阈值之后,虚拟机会认为根本就不应该重偏向]{.red}
    • [就会直接禁止 该类的实例 使用偏向锁(不是完全禁用偏向模式)]{.red}
  • 批量撤销阈值:

    • 默认值:[虚拟机默认批量撤销的阈值时 40 次]{.red}
    • 设置阈值:[-XX:BiasedLockingBulkRevokeThreshold=count]{.blue}
    • 细节:[在规定的时间范围内撤销次数没有达到阈值次时,虚拟机将会把撤销次数清零,重新统计]{.red}
      • [-XX:BiasedLockingDecayTime=time:设置间隔时间]{.blue}
  • 测试批量撤销机制

    1. 编写测试代码:

      • 主线程延迟启动确保能够开启偏向锁

      • 第一个线程开始给对象添加偏向锁,然后将其添加到集合中,以便之后其他线程使用

      • 第二个线程获取集合中的对象,就会造成偏向锁被撤销,在达到阈值之后开始批量重偏向

      • 第三个线程再次获取集合中的对象,对于前面已经被撤销偏向锁的对象就会直接使用,不会再次撤销

      • 而对于重偏向到第二个线程的对象就会再次开始撤销

      @Slf4j(topic = "c.rebias")
      public class ThreadReBias
      {
      private static class Inner{

      }
      private static Thread t1;
      private static Thread t2;
      private static Thread t3;
      public static void main(String[] args) throws InterruptedException
      {
      // 确保偏向锁开启
      TimeUnit.SECONDS.sleep(4);
      // 存放对象的集合
      List<Inner> list = new ArrayList<>();
      // 线程开始给对象加锁
      t1 = new Thread(() -> {
      // 循环添加对象, 也就是添加偏向锁的过程
      for (int i = 1; i <= 40; i++) {
      final int temp = i;
      Inner inner = new Inner();
      synchronized (inner) {
      list.add(inner);
      // 打印内存布局
      log.debug(temp + "\t" + ClassLayout.parseInstance(inner).toPrintable());
      }
      }
      LockSupport.unpark(t2);
      }, "t1");
      t1.start();

      t2 = new Thread(() -> {
      LockSupport.park();
      log.debug("===============开始撤销===============");
      // 循环添加对象, 也就是添加偏向锁的过程
      for (int i = 1; i <= 40; i++) {
      final int temp = i;
      Inner inner = list.get(i - 1);
      synchronized (inner) {
      // 打印内存布局
      log.debug(temp + "\t" + ClassLayout.parseInstance(inner).toPrintable());
      }
      }
      LockSupport.unpark(t3);
      }, "t2");
      t2.start();

      t3 = new Thread(()->{
      LockSupport.park();
      // 注意: 这里读者可以自行更改为39次和40次进行对比
      for (int i = 1; i < 40; i++) {
      if (i == 20)
      log.debug("===============开始撤销===============");
      final int temp = i;
      Inner inner = list.get(i - 1);
      log.debug(temp + "\t" + ClassLayout.parseInstance(inner).toPrintable());
      synchronized (inner){
      // 打印内存布局
      log.debug(temp + "\t" + ClassLayout.parseInstance(inner).toPrintable());
      }
      }
      }, "t3");
      t3.start();

      t3.join();
      log.debug(ClassLayout.parseInstance(new Inner()).toPrintable());
      log.debug(ClassLayout.parseInstance(new Inner()).toPrintable());

      }
      }
    2. 测试结果:前两个线程执行的逻辑和此前完全一样,因此从第三个线程开始分析

      • [第三个线程开始从集合中取出对象,发现前 19 个线程已经被撤销偏向锁了,所以不需要再次撤销]{.grey}

        偏向锁-9
      • [第三个线程在取出第 20 个元素的时候就会发现此后的对象都重偏向到第二个线程,所以又会开始撤销]{.grey}

        • [其实撤销次数达到 39 次的时候,之后该类的实例就已经无法进入偏向模式了]{.grey}
        • [此前第二个线程已经撤销了 19 次对象的偏向锁,那第三个线程只要撤销 20 次就可以触发批量撤销]{.grey}
        偏向锁-10
      • [主线程创建对象并打印内存布局,该类的实例对象再也没有偏向模式]{.grey}

        偏向锁-8

自旋锁

  • 历史:
    • JDK 4 中就已经引入自旋锁机制,默认关闭自旋锁机制
    • JDK 6 中为自旋锁添加自适应机制,默认开启自旋锁机制
    • [-XX:+UseSpinning 开启自旋锁机制 / -XX:UseSpinning 关闭自旋锁机制]{.blue}
  • 定义:[线程尝试获取对象锁时发现已经被其他线程获取,此时 不进入阻塞队列等待而是不停地询问锁是否被释放]{.red}
    • 如果线程在不停询问的期间发现持有锁的线程释放了锁,那么就可以避免阻塞直接获取锁
    • 如果线程直到询问期结束依然没有获取到锁,那么依然会进入阻塞队列中等待获取锁
  • 原因:
    • 重量级锁的机制会使得无法获取锁的线程进入阻塞队列中等待
    • [线程进入阻塞队列等待就必须执行上下文切换,内核线程上下文切换必须从用户态切换到内核态,开销很大]{.aqua}
  • 细节:
    • [自旋锁机制只能够在线程可以并行的情况下采用,也就是只能够在多核处理下采用]{.red}
      • 如果是单核处理器,那么无论如何都会发生上下文切换,换上来的线程采用的自旋是毫无意义的,因为持有锁的线程已经被切换下去了,压根不可能在自旋期间获得锁
      • 只有线程在两个处理器上,其中一个持有锁的线程在执行,另一个线程在自旋,这才是有意义的
    • [自旋锁本质是忙等待,如果忙等待时间短那效果就会非常好,如果忙等待时间长那就会白白浪费处理器性能]{.red}
      • [自旋的次数(询问的次数)默认值为十次:-XX:PreBlockSpin 可以自定义自旋的次数]{.pink}
      • [JDK 6 之后的自旋锁能够根据即时编译监控收集的信息自动调整自旋的次数,即自适应策略]{.pink}
    • [自旋锁、轻量级锁、偏向锁这几个策略显然是无法一起使用的]{.pink}
      • 自旋锁的前提就已经是存在线程之间相互竞争锁了
      • 轻量级锁、偏向锁这两个策略都是基于没有线程竞争的优化策略

锁消除

  • 前言:Java 虚拟机中采用的同步消除优化策略也就是锁消除策略

  • 定义:[如果虚拟机发现对不存在多线程竞争的资源进行了同步,那么虚拟机会直接消除同步代码块提高性能]{.red}

    // 下列情况就是可以同步消除的情况
    public synchronized void calculate(){
    // 显然内部不存在使用共享变量或者引用对象逃逸的情况
    // 也就是不存在线程安全的情况, 虚拟机(即时编译器)会对其进行同步消除
    int first = 10;
    int second = 20;
    System.out.println(first + second);
    }
  • 细节:[同步消除的目的不是为了消除你编写的同步代码而是为了 消除调用的方法中存在的同步代码 ]{.red}

    • 演示案例:字符串拼接(《深入理解虚拟机》上的例子)

      • [StringBuffer 是线程安全的类,而同步消除就会在拼接操作执行的时候消除这种不必要的同步]{.pink}
      • 除此之外还有非常多的方法自身就涉及到了同步代码,即时编译器都会对他们进行消除
      public String concatString(String first, String second){
      return first + second;
      }
      // JDK 5 以前的方式, 字符串拼接操作会转换成 StringBuffer 进行拼接
      // JDK 6 之后会采用 StringBuilder 进行拼接
      public String concatString(String first, String second){
      StringBuffer sb = new StringBuffe();
      sb.append(first);
      sb.append(second);
      return sb.toString();
      }
  • 锁消除证明测试

    1. 引入 JMH 工具包

      <!-- 基准测试使用的包 -->
      <dependency>
      <groupId>org.openjdk.jmh</groupId>
      <artifactId>jmh-core</artifactId>
      <version>1.19</version>
      </dependency>
      <dependency>
      <groupId>org.openjdk.jmh</groupId>
      <artifactId>jmh-generator-annprocess</artifactId>
      <version>1.19</version>
      <scope>provided</scope>
      </dependency>
    2. 编写测试代码

      // 测试同步消除
      @Fork(1)
      @BenchmarkMode(Mode.AverageTime)
      @Warmup(iterations = 3)
      @Measurement(iterations = 5)
      @OutputTimeUnit(TimeUnit.NANOSECONDS)
      public class ThreadSynElimination
      {
      static int number = 0;
      @Benchmark
      public void noSynchronizedMethod() throws Exception{
      number++;
      }

      public void synchronizedMethod() throws Exception{
      synchronized (this){
      number++;
      }
      }
      }
    3. 将项目模块打包后执行

锁粗化

Author: Fuyusakaiori
Link: http://example.com/2021/10/28/juc/多线程基础/线程同步-synchronized-优化策略/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.