Lock -- 03 -- synchronized的优化

原文链接:Lock – 03 – synchronized的优化


相关文章:


在 JDK6 中,虚拟机团队在 JVM 层面对 synchronized 做了大量优化,如:自适应自旋锁 (Adaptive Spinning)、锁消除 (Lock Eliminate)、锁粗化 (Lock Coarsening)、轻量级锁 (Lightweight Locking)、偏向锁 (Biased Locking),这些技术都是为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率


一、自旋锁与自适应自旋锁 (Adaptive Spinning)

  • 虚拟机的开发团队注意到,在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。在现在多处理器的环境下,完全可以让另外一个没有获取到锁的线程额外等待一会,但并不放弃 CPU 的执行时间,看看持有锁的线程是否很快就会释放锁,为了让线程等待,我们只需要让线程执行一个忙循环 (自旋),这项技术即所谓的自旋锁

  • 自旋锁在 JDK1.4.2 中就已经引入了,不过默认是关闭的,可以通过参数 -XX:+UseSpinning 来进行开启,在 JDK6 中改为了默认开启

  • 自旋锁并不能代替阻塞,一是因为对处理器的数量有要求,二是自旋虽然避免了线程切换的开销,但是它会一直占用 CPU 时间

    • 如果锁被占用的时间很短,那么自旋的效果就会非常好

    • 如果锁被占用的时间很长,那么自旋的线程只会白白地消耗 CPU 资源,而无法做任何有价值的工作,这就会带来性能的浪费

  • 因此自旋等待的时间必须要要有一定的限度,如果超过了限定的此时仍然没有成功地获得锁,则应当使用传统的方式去挂起线程,默认为 10 次,可以通过参数 -XX:PreBlockSpin 来进行设置

  • 不过无论是默认值还是用户指定的自旋次数,对整个Java虚拟机中所有的锁来说都是相同的。因此在 JDK6 中,对自旋锁进行了优化,引入了自适应的自旋,即即为着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定

    • 如果在同一个锁对象上,自旋等待刚刚成功获得过的锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对较长的时间

    • 如果在同一个锁对象上,自旋很少成功获得过锁,那么在以后要获取这个锁的时候有可能直接省略掉自旋过程,以避免浪费 CPU 资源


二、锁消除 (Lock Eliminate)

  • 锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除

  • 锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,也就无须再进行同步加锁

  • 示例如下

    • 一段没有同步的代码

      public String concatString(String s1, String s2, String s3) {
          return s1 + s2 + s3;
      }
      
      • 如上所示,在 Java 中 String 是一个不可变的类,也就是对 String 对象的任何改变都不会改变原对象,而是返回一个新的 String 对象

      • Javac 编译器会对 String 连接进行自动优化,在 JDK5 之前,字符串加法会转化为 StringBuffer 对象的连续 append() 操作;在 JDK5 及以后的版本中,会转换为 StringBuilder 对象的连续 append() 操作

    • javac 编译后得到的代码

      public String concatString(String s1, String s2, String s3) {    
          StringBuffer sb = new StringBuffer();
          sb.append(s1);
          sb.append(s2);
          sb.append(s3);
          return sb.toString();
      }
      
      • 如上所示,每个 StringBuffer.append() 方法中都有一个同步块,锁为 sb 对象。经逃逸分析后,虚拟机会发现它的动态作用域被限制在了 concatString() 方法内部,也就是 sb 的引用永远不会逃逸到 concatString() 方法之外,其他线程无法访问到它

      • 所以这里虽然有锁,但是可以被安全地消除掉,在解释执行时这里仍然会加锁,但在经过服务端编译器的即时编译之后,这段代码就会忽略掉所有的同步措施而直接执行

    • 示例中提及了锁消除与逃逸分析,那么虚拟机就不可能是 JDK5 之前的版本了,所以实际会转化为线程不安全的 StringBuffer 来完成字符串的拼接,并不会加锁,此处仅起到示例作用

  • 名词解释

    • 逃逸分析

      • 是一种跨函数的全局数据流分析算法

      • 分析对象的动态作用域,为优化措施提供依据

      • 虚拟机编译器可以通过逃逸分析来分析一个新的对象的引用的适用范围,从而决定是否要将这个对象分配到堆上

      • 可以有效地较少 Java 程序中的同步负载和内存堆中的分配压力

    • 解释执行

      • 源程序进入计算机内,解释程序边扫描边解释,逐句输入逐句翻译,计算机一步一步执行,不生成源程序的目标程序

      • 在 Java 中,可以使用参数 -Xint 来设置 JVM 以解释模式来运行

    • 编译执行

      • 源程序进入计算机内,编译程序生成源程序的目标程序,由计算机进行执行

      • 在 Java 中,可以使用参数 -Xcomp 来设置 JVM 以编译模型来运行

    • 即时编译

      • 虚拟机将热点代码编译成与本地平台相关的机器码,并进行各种层次的优化

三、锁粗化 (Lock Coarsening)

  • 在编写同步代码时,我们总是会将同步块的作用范围尽可能的小 – 只在共享数据的实际作用域中才进行同步,这样可以使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能就可能快地拿到锁

  • 但如果一系列的连续操作都是对同一个对象反复加锁解锁,甚至加锁操作出现在循环体之中的,那么即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗

  • 示例如下

    public void  cycle() {    
        for(int i = 0; i < 100; i++) {
            synchronized(this) {
                // ...
            }
        }
    }
    
    • 如上所示,在循环体中存在对同一个对象的加锁操作,虚拟机会对此进行优化,将会把加锁的同步范围扩展 (粗化) 至整个操作序列的外部 (在上述代码中,就是扩展到了第 1 次循环操作之前直至第 100 次循环操作之后),这样只需要加锁一次就可以了

四、轻量级锁 (Lightweight Locking)

  • 轻量级锁是 JDK6 中加入的新型锁机制,所谓 “轻量” 是相对于使用操作系统互斥量来实现的传统锁 (重量级锁) 而言的

  • 轻量级锁并不是用来代替重量级锁的,它涉及的初衷是在没有多线程竞争的情况下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗

  • 要理解轻量级锁 (以及后面提到的偏向锁),我们需要先来了解下对象头,对象头中的 Mark Word 是实现轻量级锁和偏向锁的关键,对象头可分为以下三部分

    • Mark Word

      • 用于存储对象自身的运行时数据,如:哈希码 (HashCode)、GC 分代年龄、锁状态标识、线程持有的锁、偏向线程 ID、偏向时间戳

      在这里插入图片描述

    • 类型指针

      • 指向对象元数据的指针,虚拟机通过这个指针来确定该对象是哪个类的实例
    • 数组长度

      • 当存储的是数组对象时,数组长度必须存在,如果没有数组长度,虚拟机将无法通过数组对象的元数据来确定数组对象的大小
  • 工程流程

    • 当代码进入同步块时,如果此同步对象没有被锁定 (锁标志位为 01),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录 (Lock Record) 的空间,用于存储锁对象目前的 Mark Word 的拷贝 (官方为这份拷贝加了一个Displaced前缀,即 Displaced Mark Word),此时线程堆栈与对象头的状态如下所示

      在这里插入图片描述

    • 拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record 里 owner 的指针指向对象的 Mark Word

      • 这里的两个操作需要额外说明一下

        • 将对象的 Mark Word 更新为指向 Lock Record 的指针

          • 此操作作用在于让其他线程知道该对象已经被锁定 (该对象的 Monitor 对象已经被当前线程所持有)
        • 将 Lock Record 里 owner 的指针指向对象的 Mark Word

          • 此操作作用在于接下来的运行过程,虚拟机识别哪个对象被锁住了
      • 此时 Mark Word 中存储的是指向 Lock Record (锁记录) 的指针,这就与上文 Mark Word 的存储分布图中轻量级锁的内容对应上了 (轻量级锁在 32 位的 Mark Word 中,前 30 字节存储了指向锁记录的指针)

    • 如果更新操作成功了,那么久代表当前线程拥有了这个对象的锁 (Monitor),并且将对象 Mark Word 中的锁标识设置为 00,表示此时该对象处于轻量级锁定状态,此时线程堆栈与对象头的状态如下所示

      在这里插入图片描述

    • 如果更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程栈帧中的 Lock Record,如果是,则说明当前线程已经拥有了这个对象的锁 (Monitor),直接进入同步块中继续执行就可以了;否则就说明了已经有其他线程持有了这个锁对象

    • 如果出现两条以上的线程争用同一个锁的情况,那么轻量级锁将不再有效,必须要膨胀为重量级锁,并且将锁标识设置为 10,此时 Mark Word 中存储的就是指向重量级锁 (互斥量) 的指针,后面等待的线程也必须进入阻塞状态

  • 上面描述的是轻量级锁的加锁过程,其解锁过程也同样是通过 CAS 操作来进行的,如果对象的 Mark Word 仍然指向当前线程栈帧中的 Lock Record,那么就会 CAS 操作将对象的当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来

    • 如果替换成功,就表示整个同步操作顺利完成了

    • 如果替换失败,则说明有其他线程尝试过获取该锁 (已由轻量级锁膨胀为重量级锁,此时 Mark Word 存储的是指向重量级锁 (互斥量) 的指针),就要在释放锁的同时,唤醒被挂起的线程

  • 在没有竞争的情况下,轻量级锁可以通过 CAS 操作来避免使用互斥量的开销;但如果确实存在锁竞争,如果还使用轻量级锁,除了互斥量本身的开销外,还额外发生了 CAS 操作的开销,因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢 (所以在有锁竞争的情况下,轻量级锁须膨胀为重量级锁)


五、偏向锁 (Biased Locking)

  • 偏向锁也是 JDK6 中引入的一项锁优化措施,它的目的是消除数据在无竞争的情况下的同步原语,进一步地提高程序的运行性能

  • 如果说是轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除调了,连 CAS 操作都不去做了

  • 偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他线程所获取,则持有偏向锁的线程将永远不需要再进行同步

  • 工作流程

    • 当锁对象 (Monitor) 第一次被线程获取的时候,虚拟机会将同步对象 Mark Word 中的锁标识设置为 01,并将偏向标识设置为 1,表示进入偏向模式

    • 接着使用 CAS 操作把持有该锁对象 (Monitor) 的线程的 ID 记录在同步对象的 Mark Word 中 (偏向锁在 32 位的 Mark Word 中,前 23 字节存储了线程 ID),如果 CAS操作成功,那么持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都不再进行任何操作 (如:加锁、解锁以及对 Mark Word 的更新操作)

  • 一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束,之后会根据锁对象目前是否处于被锁定的状态来决定是否撤销偏向 (将偏向标识设为 0),撤销后恢复锁标识为未锁定状态 (锁标识为 01) 或轻量级锁定状态 (锁标识为 00),后续的同步操作就按照轻量级锁那样去执行

  • 偏向锁、轻量级锁的状态转化以及同步对象 Mark Word 的关系如下所示

    在这里插入图片描述

  • 在偏向锁中,没有存储对象的哈希码,那么它又去了哪呢?

    • 在 Java 中,作为绝大多数对象哈希码来源的 Object::hashCode() 方法 (对象的 hashCode() 方法可以被覆盖),返回的是对象的一致性哈希码 (Identity Hash Code),这个值是强制不变的,它通过在对象头中存储计算结果来保证第一次计算后,再次调用该方法取到的哈希码值永远不会再发生改变

    • 因此当一个对象已经计算过一次性哈希码后,它将再也无法进入偏向锁状态了;而当一个对象正处于偏向锁状态,又收到需要计算一致性哈希码的请求时,会立刻撤销偏向状态,并且锁会膨胀为重量级锁

    • 在重量级锁的视线中,Mark Word 中存储的是指向重量级锁的指针,也就是指向的是一个 Monitor 对象的起始地址 (每个对象都存在一个 Monitor 对象与之关联),而 Monitor 又是由 ObjectMonitor 来实现的,在 ObjectMonitor 中有字段可以记录非加锁状态 (锁标识为 01) 下的 Mark Word 的信息,其中就存储了原来的哈希码

  • 偏向锁并非总是对程序有利的,如果程序中大多数锁总是被多个不同的线程访问,那么偏向模式就是多余的,此时我们可以使用参数 -XX:UseBiasedLocking 来禁用偏向锁优化,反而能够提升性能


六、归纳总结

  • 锁的四种状态

    • 无锁、偏向锁、轻量级锁、重量级锁
  • 锁膨胀方向

    • 无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁
  • 自旋锁 (Spinning)

    • 是指当一个线程在获取锁的时候,如果锁已经被其他线程所持有,那么该线程将执行一个忙循环 (自旋,且次数是固定的,默认为 10 次),然后不断地判断锁是否能够被成功获取

    • 优点

      • 不会使线程状态发生变化,减少了不必要的上下文切换,执行速度快
    • 缺点

      • 如果线程长时间获取不到锁,自旋会造成 CPU 资源的浪费
  • 自适应自旋锁 (Adaptive Spinning)

    • 是指自旋次数不固定的自旋锁

    • 自旋次数由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定

      • 如果在同一个锁对象上,自旋等待刚刚成功获得过的锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能成功,进而允许自旋等待持续相对较长的时间

      • 如果在同一个锁对象上,自旋很少成功获得过锁,那么在以后要获取这个锁的时候有可能直接省略掉自旋过程,以避免浪费 CPU 资源

  • 锁消除 (Lock Eliminate)

    • 是指在 JIT 编译时,对运行上下文进行扫描,去除不可能存在竞争的锁
  • 锁粗化 (Lock Coarsening)

    • 扩大加锁范围,避免反复加锁和解锁
  • 轻量级锁 (Lightweight Locking)

    • 是指在没有多线程竞争的情况下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗

    • 当有两条以上的线程竞争同一个锁时,轻量级锁将失效,会膨胀为重量级锁

    • 优点

      • 竞争的线程不会阻塞,提高了响应速度
    • 缺点

      • 如果线程长时间获取不到锁,自旋会造成 CPU 资源的浪费
  • 偏向锁 (Biased Locking)

    • 是指在没有多线程竞争的情况下,减少同一线程获取锁的代价,进一步地提高了程序的运行性能

    • 当有第二个线程尝试去获取这个锁时,偏向锁将失效,之后会根据锁对象是否处于被锁定状态来决定是否撤销偏向 (将偏向标识设为 0),撤销后恢复锁标识为未锁定状态 (锁标识为 01) 或轻量级锁定状态 (锁标识为 00)

    • 优点

      • 加锁和解锁不需要 CAS 操作,没有额外的性能消耗
    • 缺点

      • 如果线程间存在锁竞争,会带来额外的锁撤销的消耗
  • 重量级锁

    • 是指在存在多线程竞争的情况下,只有一个线程可以获取到锁,其余线程将被阻塞,直到锁被释放后重新进行竞争

    • 操作系统实现线程之间的切换时,需要从用户态切换到内核态,转换时间较长,时间成本相对较高

    • 优点

      • 线程竞争不使用自旋
    • 缺点

      • 线程阻塞,响应时间缓慢,在多线程的情况下,频繁地获取释放锁,会带来巨大的性能消耗
  • 偏向锁 VS 轻量级锁 VS 重量级锁

    优点缺点使用场景
    偏向锁加锁和解锁不需要 CAS 操作,没有额外的性能消耗如果线程间存在锁竞争,会带来额外的锁撤销的消耗只有一个线程访问同步块或同步方法的场景
    轻量级锁竞争的线程不会阻塞,提高了响应速度若线程长时间获取不到锁,自旋会消耗 CPU 资源线程交替执行同步块或同步方法的场景
    重量级锁线程竞争不使用自旋线程阻塞,响应时间缓慢,在多线程的情况下,频繁地获取释放锁,会带来巨大的性能消耗追求吞吐量,同步块或同步方法执行时间较长的场景

七、参考资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值