JAVA15废弃偏向锁

简介

前言

偏向锁主要用来优化同一线程多次申请同一个锁的竞争。在某些情况下,大部分时间是同一个线程竞争锁资源,例如,在创建一个线程并在线程中执行循环监听的场景下,或单线程操作一个线程安全集合时,同一线程每次都需要获取和释放锁,每次操作都会发生用户态与内核态的切换。

偏向锁的作用就是,当一个线程再次访问这个同步代码或方法时,该线程只需去对象头的 Mark Word 中去判断一下是否有偏向锁指向它的 ID,无需再进入 Monitor 去竞争对象了。当对象被当做同步锁并有一个线程抢到了锁时,锁标志位还是 01,“是否偏向锁”标志位设置为 1,并且记录抢到锁的线程 ID,表示进入偏向锁状态。
一旦出现其它线程竞争锁资源时,偏向锁就会被撤销。偏向锁的撤销需要等待全局安全点,暂停持有该锁的线程,同时检查该线程是否还在执行该方法,如果是,则升级锁,反之则被其它线程抢占。

在高并发场景下,当大量线程同时竞争同一个锁资源时,偏向锁就会被撤销,发生 stop the word 后, 开启偏向锁无疑会带来更大的性能开销,这时我们可以通过添加 JVM参数关闭偏向锁来调优系统性能

问什么引入偏向锁

偏向锁是HotSpot虚拟机使用的一项优化技术,能减少无竞争锁定的开销。偏向锁的目的是假定monitor一直由某个特定线程持有,知道另一个线程尝试获取它,这样既可以避免获取monitor时执行CAS的院子操作。monitor首次锁定时偏向的线程,这样既可以避免同意兑现后续同步操作步骤需要原子指令。从历史上看,偏向锁使得JVM性能得到了改善。

现在问什么又要废弃偏向锁

但是过去看到的性能提升,在现在看来不那么明显了。受益于偏向锁的应用程序,往往是使用了早期 Java 集合 API的程序(JDK 1.1),这些 API(Hasttable 和 Vector) 每次访问时都进行同步。JDK 1.2 引入了针对单线程场景的非同步集合(HashMap 和 ArrayList),JDK 1.5 针对多线程场景推出了性能更高的并发数据结构。这意味着如果代码更新为使用较新的类,由于不必要同步而受益于偏向锁的应用程序,可能会看到很大的性能提高。此外,围绕线程池队列和工作线程构建的应用程序,性能通常在禁用偏向锁的情况下变得更好。

偏向锁位同步系统引入了许多复杂的代码,并且hotspot的其他组件产生了影响。这种复杂性已经成为代码的障碍,也阻碍了对同步系统进行重构。因此,我们希望禁用,废弃并最终删除偏向锁

思考

现在很多面试题都是讲述 CMS、G1 这些垃圾回收的原理,但是实际上官方在 Java 11 就已经推出了 ZGC,号称 GC 方向的未来。对于锁的原理,其实 Java 8 的知识也需要更新了,毕竟技术一直在迭代,还是要不断更新自己的知识……学无止境……

话说回来偏向锁产生的原因,很大程度上是 Java 一直在兼容以前的程序,即使到了 Java 15,以前的 Hasttable 和 Vector 这种老古董性能差的类库也不会删除。这样做的好处很明显,但是坏处也很明显,Java 要一直兼容这些代码,甚至影响 JVM 的实现。

本篇文章系统整理下 Java 的锁机制以及演进过程。

锁的发展过程

在 JDK 1.5 之前,Java 是依靠 Synchronized 关键字实现锁功能来做到这点的。Synchronized 是 JVM 实现的一种内置锁,锁的获取和释放是由 JVM 隐式实现。

到了JDK1.5版本,并发包中新增了Lock接口来实现锁功能的,它提供了syncrhonized类似的同步功能,只是在使用时需要显示和获取和释放锁。

Lock 同步锁是基于 Java 实现的,而 Synchronized 是基于底层操作系统的 Mutex Lock 实现的,每次获取和释放锁的时候,都会带来用户态和内核态的切换,从而增加系统开销。因此在锁竞争很激烈的情况下,Syncronized同步锁在性能上就表现的非常糟糕,它也被大家称为重量级锁。

特别是在单个线程重复申请锁的情况下,JDK1.5 版本的 Synchronized 锁性能要比 Lock 的性能差很多。

到了 JDK 1.6 版本之后,Java 对 Synchronized 同步锁做了充分的优化,甚至在某些场景下,它的性能已经超越了 Lock 同步锁。

同步指令

官方

每个对象都与一个monitor相关联。当且仅当mintor相关联。当且仅当monitor对线有一个所有者时才会被锁定,执行monitorenter的线程试图获得与objectref关联的所有权

  • 若与 objectref 相关联的 monitor 计数为 0,线程进入 monitor 并设置 monitor 计数为 1,这个线程成为这个 monitor 的拥有者。
  • 如果该线程已经拥有与 objectref 关联的 monitor,则该线程重新进入 monitor,并增加 monitor 的计数。
  • 如果另一个线程已经拥有与 objectref 关联的 monitor,则该线程将阻塞,直到 monitor 的计数为零,该线程才会再次尝试获得 monitor 的所有权。

主要意思是说

  • 执行 monitorexit 的线程必须是与 objectref 引用的实例相关联的 monitor 的所有者。
  • 线程将与 objectref 关联的 monitor 计数减一。如果计数为 0,则线程退出并释放这个 monitor。其他因为该 monitor 阻塞的线程可以尝试获取该 monitor。

ACC_SYNCHRONIZED

JVM对于方法级别的同步是隐式的,是方法调用和返回值的一部分。同步方法在运行时处理厂的method_info结构中由ACC_SYNCHRONIZED标志位来区分,它由方法调用指令来检查。当调用设置了ACC_SYNCHRONIZED标志位的方法时,调用线程会获取monitor,调用方法本身,再退出monitor

操作系统管城Monitor

管城是一种在信号量机制上进行改进的并发编程模型

管程模型

管程的组成如下

  • 共享变量
  • 入口等待队列
  • 一个锁:控制整个管城代码的互斥访问
  • 0个活多个条件变量:每个条件变量都包含一个自己的等待队列,以及相应的出入操作

objectMonitor

JVM 中的同步就是基于进入和退出管程(Monitor)对象实现的。每个对象实例都会有一个 Monitor,Monitor 可以和对象一起创建、销毁。Monitor 是由 ObjectMonitor 实现,而 ObjectMonitor 是由 C++ 的 ObjectMonitor.hpp 文件实现,如下所示:

ObjectMonitor() {
   _header = NULL;
   _count = 0; //记录个数
   _waiters = 0,
   _recursions = 0;
   _object = NULL;
   _owner = NULL;
   _WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
   _WaitSetLock = 0 ;
   _Responsible = NULL ;
   _succ = NULL ;
   _cxq = NULL ;
   FreeNext = NULL ;
   _EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
   _SpinFreq = 0 ;
   _SpinClock = 0 ;
   OwnerIsThread = 0 ;
}

本文使用的是 Java 11,其中有 sun.jvm.hotspot.runtime.ObjectMonitor 类,这个类有如下的初始化方法:

private static synchronized void initialize(TypeDataBase db) throws WrongTypeException {
    heap = VM.getVM().getObjectHeap();
    Type type  = db.lookupType("ObjectMonitor");
    sun.jvm.hotspot.types.Field f = type.getField("_header");
    headerFieldOffset = f.getOffset();
    f = type.getField("_object");
    objectFieldOffset = f.getOffset();
    f = type.getField("_owner");
    ownerFieldOffset = f.getOffset();
    f = type.getField("FreeNext");
    FreeNextFieldOffset = f.getOffset();
    countField  = type.getJIntField("_count");
    waitersField = type.getJIntField("_waiters");
    recursionsField = type.getCIntegerField("_recursions");
}

可以和 C++ 的 ObjectMonitor.hpp 的结构对应上,如果查看 initialize 方法的调用链,能够发现很多 JVM 的内部原理,本篇文章限于篇幅和内容原因,不去详细叙述了。

当多个线程同时访问一段同步代码时,多个线程会被存放在EntryList集合中,处于block状态的线程,都会被加入到该列表。接下来当线程获取到对象的monitor时,monitor是靠底层操作系统的mutex成功,则持有该mutex。

如果线程调用wait()方法,就会释放当前持有的Mutex,并且该线程会进入waitSet集合中,等待下一次被唤醒。瑞国线程顺利执行完方法也能释放Mutex

Monitor 依赖于底层操作系统的实现,存在用户态内核态的转换,所以增加了性能开销。但是程序中使用了 Synchronized 关键字,程序也不全会使用 Monitor,因为 JVM 对 Synchronized 的实现也有 3 种:偏向锁、轻量级锁、重量级锁。

对象头

锁升级

偏向锁

为什么要有偏向锁呢?偏向锁主要用来优化同一线程多次申请同一个锁的竞争。可能大部分时间一个锁都是被一个线程持有和竞争。假如一个锁被线程 A 持有,后释放;接下来又被线程 A 持有、释放……如果使用 monitor,则每次都会发生用户态和内核态的切换,性能低下。

作用:当一个线程再次访问这个同步代码方法时,该线程只需要去对象头的MarkWord判断是否有偏向锁指向它的id,无需在进入Monotor去竞争对象了。当对线被做同步锁并有一个线程抢到了锁,锁的标志位还是01,是否偏向锁的标志位设置为1,并且记录抢到锁线程的ID,表示进入偏向状态。

一旦出现其他线程竞争锁资源,偏向锁就会被撤销。撤销时机是在安全点处,暂停持有该锁的线程,同时检查该线程是否还在执行该方法。是则升级锁,不是则被其他线程抢占。

高并发场景下,大量线程同时竞争同一个锁资源,偏向锁会被撤销,发生 stop the world后,开启偏向锁会带来更大的性能开销(这就是 Java 15 取消和禁用偏向锁的原因),可以通过添加 JVM 参数关闭偏向锁:

-XX:-UseBiasedLocking //关闭偏向锁(默认打开)

-XX:+UseHeavyMonitors  //设置重量级锁
Last modification:July 10, 2023
如果觉得我的文章对你有用,请随意赞赏