java 15弃用

JDK 15已经在2020年9月15日发布,详情见 JDK 15 官方计划。其中有一项更新是废弃偏向锁,官方的详细说明在:JEP 374: Disable and Deprecate Biased Locking。【PS:文章是英文,有看的头痛的小伙伴往下看就好】

当时为什么要引入偏向锁

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

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

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

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

锁的发展过程

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

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

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

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

  3. 到了 JDK 1.6 版本之后,Java 对 Synchronized 同步锁做了充分的优化,甚至在某些场景下,它的性能已经超越了 Lock 同步锁。偏向锁和轻量级锁的引入解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。

传统的锁(也就是下文要说的重量级锁)依赖于系统的同步函数,在linux上使mutex互斥锁,最底层实现依赖于futex,这些同步函数都涉及到用户态和内核态的切换、进程的上下文切换,成本较高。对于加了synchronized关键字但运行时并没有多线程竞争,或两个线程接近于交替执行的情况,使用传统锁机制无疑效率是会比较低的。

小试牛刀

package 并发;
​
public class MySyncTest {
    public void syncCodBlock() {
        synchronized (this) {
            System.out.println("sync cod block");
        }
    }
​
    public synchronized void syncMythod() {
        System.out.println("sync mythod");
    }
}

对以上代码编译成class之后,使用javap -v 查一下class文件的字节码信息

  public void syncCodBlock();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter                      // monitorenter指令进入同步块
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String sync cod block
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit                       // monitorexit指令退出同步块
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit                       // monitorexit指令退出同步块
        20: aload_2
        21: athrow
        22: return
      Exception table:
         from    to  target type
             4    14    17   any
            17    20    17   any
      LineNumberTable:
        line 7: 0
        line 8: 4
        line 9: 12
        line 10: 22
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      23     0  this   L并发/MySyncTest;
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 17
          locals = [ class 并发/MySyncTest, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4
​
  public synchronized void syncMythod();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED  // 添加了ACC_SYNCHRONIZED标志
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #5                  // String sync mythod
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 13: 0
        line 14: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  this   L并发/MySyncTest;

从上面的中文注释处可以看到,两种方式是有区别的:

  • 同步代码块:会生成对应的monitorenter和monitorexit指令分别对应synchronized同步块的进入和退出,有两个monitorexit指令的原因是:为了保证抛异常的情况下也能释放锁,所以javac为同步代码块添加了一个隐式的try-finally,在finally中会调用monitorexit命令释放锁。

  • 同步方法:生成了一个ACC_SYNCHRONIZED关键字,在JVM进行方法调用时,发现调用的方法被ACC_SYNCHRONIZED修饰,则会先尝试获得锁

对象头是关键

在JVM中,对象在内存中除了本身的数据外还会有个对象头,对于普通对象而言,其对象头中有两类信息:mark word和类型指针。另外对于数组而言还会有一份记录数组长度的数据。

类型指针是指向该对象所属类对象的指针,mark word用于存储对象的HashCode、GC分代年龄、锁状态等信息。在32位系统上mark word长度为32bit,64位系统上长度为64bit。为了能在有限的空间里存储下更多的数据,其存储格式是不固定的,在32位系统上各状态的格式如下:

如果想要继续了解变化标示为的变化可以继续往下看,不想的话,可以直接跳过

无锁状态

打印对象头的工具:

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

代码:

/**
 * 无锁
 */
@Test
public void fun01() {
    MySyncTest mySyncTest = new MySyncTest();
    //不调用hashCode() 不会记录哈希码
    int hashCode = mySyncTest.hashCode();
    //转16进制输出,与头信息中HashCode进行比较
    String hex = Integer.toHexString(hashCode);
    System.out.println("HashCode十六进制:" + hex);
    print(mySyncTest);
}
​
/**
 * 打印对象头信息的方法
 * @param obj
 */
static void print(Object obj) {
    System.err.println(ClassLayout.parseInstance(obj).toPrintable());
}

偏向锁

注意:jdk1.8默认是开启偏向锁,但会有延迟4s,也可以直接在在JVM启动参数添加关闭延时的操作

代码:

/**
 * 偏向锁
 * JVM有个偏向锁的延时,可以手动延时或在启动参数去掉延时
 */
@Test
public void fun02() throws InterruptedException {
    Thread.sleep(4001);
    MySyncTest mySyncTest = new MySyncTest();
    mySyncTest.syncMythod();
}

轻量级锁

说明:轻量级锁我在开启偏向锁的时候比较难测出,所以这里直接是在没有启用偏向锁的情况下,引起锁的竞争

代码:

/**
 * 轻量级锁
 * 偏向锁在退出后,如果有另一个线程来竞争锁资源,会升级为轻量级锁
 */
@Test
public void fun03() throws InterruptedException {
    Thread.sleep(4001);
    MySyncTest mySyncTest = new MySyncTest();
    mySyncTest.syncMythod(); // 偏向锁
    mySyncTest.syncMythod(); // 偏向锁
    new Thread(() -> {
        synchronized (mySyncTest) {
            mySyncTest.syncMythod(); // 轻量级锁
        }
    }).start();
}

重量级锁

说明:通过多线程的方式引起锁的竞争,

@Test
public void fun04() throws InterruptedException {
    final MySyncTest mySyncTest = new MySyncTest();
    int count = 5;
    CountDownLatch countDownLatch = new CountDownLatch(count);
    for (int i = 0; i < count; i++) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                mySyncTest.syncMythod();
                print(mySyncTest);
                countDownLatch.countDown();
            }
        }).start();
    }
​
    countDownLatch.await();
}

同步指令说明

monitorenter

官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.monitorenter

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

  • 若与 objectref 相关联的 monitor 计数为 0,线程进入 monitor 并设置 monitor 计数为 1,这个线程成为这个 monitor 的拥有者。

  • 如果该线程已经拥有与 objectref 关联的 monitor,则该线程重新进入 monitor,并增加 monitor 的计数。

  • 如果另一个线程已经拥有与 objectref 关联的 monitor,则该线程将阻塞,直到 monitor 的计数为零,该线程才会再次尝试获得 monitor 的所有权。

monitorexit

官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.monitorexit

  • 执行 monitorexit 的线程必须是与 objectref 引用的实例相关联的 monitor 的所有者。

  • 线程将与 objectref 关联的 monitor 计数减一。如果计数为 0,则线程退出并释放这个 monitor。其他因为该 monitor 阻塞的线程可以尝试获取该 monitor。

ACC_SYNCHRONIZED

官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.11.10

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

磕源码

流程简述

偏向锁

对象创建

当JVM启用了偏向锁模式(1.6以上默认开启),当新创建一个对象的时候,如果该对象所属的class没有关闭偏向锁模式,那新创建对象的mark word将是可偏向状态,此时mark word中的thread id为0,表示未偏向任何线程,也叫做匿名偏向(anonymously biased)。

加锁过程

  1. 当该对象第一次被线程获得锁的时候,发现是匿名偏向状态,则会用CAS指令,将mark word中的thread id由0改成当前线程Id。如果成功,则代表获得了偏向锁,继续执行同步块中的代码。否则,将偏向锁撤销,升级为轻量级锁。

  2. 当被偏向的线程再次进入同步块时,发现锁对象偏向的就是当前线程,在通过一些额外的检查后,会往当前线程的栈中添加一条Displaced Mark Word为空的Lock Record中(作用是冲入的计数),然后继续执行同步块的代码,因为操纵的是线程私有的栈,因此不需要用到CAS指令;由此可见偏向锁模式下,当被偏向的线程再次尝试获得锁时,仅仅进行几个简单的操作就可以了,在这种情况下,synchronized关键字带来的性能开销基本可以忽略。

  3. 当其他线程进入同步块时,发现已经有偏向的线程了,则会进入到撤销偏向锁的逻辑里,一般来说,会在safepoint中去查看偏向的线程是否还存活,如果存活且还在同步块中则将锁升级为轻量级锁,原偏向的线程继续拥有锁,当前线程则走入到锁升级的逻辑里;如果偏向的线程已经不存活或者不在同步块中,则将对象头的mark word改为无锁状态(unlocked),之后再升级为轻量级锁。

由此可见,偏向锁升级的时机为:当锁已经发生偏向后,只要有另一个线程尝试获得偏向锁,则该偏向锁就会升级成轻量级锁。当然这个说法不绝对,因为还有批量重偏向这一机制。

解锁过程

当有其他线程尝试获得锁时,是根据遍历偏向线程的lock record来确定该线程是否还在执行同步块中的代码。因此偏向锁的解锁很简单,仅仅将栈中的最近一条lock record的obj字段设置为null。需要注意的是,偏向锁的解锁步骤中并不会修改对象头中的thread id。

批量重偏向与撤销

当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。safe point这个词我们在GC中经常会提到,其代表了一个状态,在该状态下用户所有线程都是暂停的。偏向锁的撤销是有一定成本的,如果说运行时的场景本身存在多线程竞争的,那偏向锁的存在不仅不能提高性能,而且会导致性能下降。因此,JVM中增加了一种批量重偏向/撤销的机制。

批量重偏向(bulk rebias)机制是为了解决,一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种场景下,会导致大量的偏向锁撤销操作。

批量撤销(bulk revoke)则是为了解决,存在明显多线程竞争的场景下使用偏向锁是不合适的,例如生产者/消费者队列。

过程:

  1. 以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1

  2. 当撤销这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。

  3. 每个class对象会有一个对应的epoch字段,每个处于偏向锁状态对象的mark word中也有该字段,其初始值为创建该对象时,class中的epoch的值。

  4. 每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。

  5. 下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其mark word的Thread Id 改成当前线程Id。

  6. 当达到重偏向阈值后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。

流程图

轻量级锁

JVM的开发者发现在很多情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁的概念。

线程在执行同步块之前,JVM会先在当前的线程的栈帧中创建一个Lock Record,其包括一个用于存储对象头中的 mark word(官方称之为Displaced Mark Word)以及一个指向对象的指针。下图右边的部分就是一个Lock Record。

加锁过程

  1. 在线程栈中创建一个 Lock Record,将其obj(即上图的Object reference)字段指向锁对象。

  2. 直接通过CAS指令将 Lock Record 的地址存储在对象头的 mark word 中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。如果失败,进入到步骤3。

  3. 如果是当前线程已经持有该锁了,代表这是一次锁重入。设置 Lock Record 第一部分(Displaced Mark Word)为null,起到了一个重入计数器的作用。然后结束。

  4. 走到这一步说明发生了竞争,需要膨胀为重量级锁。

解锁过程

  1. 遍历线程栈,找到所有 obj 字段等于当前锁对象的 Lock Record。

  2. 如果 Lock Record 的 Displaced Mark Word 为null,代表这是一次重入,将 obj 设置为null后continue。

  3. 如果 Lock Record 的 Displaced Mark Word 不为null,则利用CAS指令将对象头的 mark word 恢复成为 Displaced Mark Word。如果成功,则continue,否则膨胀为重量级锁。

重量级锁

重量级锁是我们常说的传统意义上的锁,其利用操作系统底层的同步机制去实现Java中的线程同步。

重量级锁的状态下,对象的 mark word 为指向一个堆中monitor对象的指针。(重量级锁涉及到多线程竞争,所以指针指向是堆中的共享区域)

一个monitor对象包括这么几个关键字段:cxq(下图中的ContentionList),EntryList ,WaitSet,owner。

其中cxq ,EntryList ,WaitSet都是由ObjectWaiter的链表结构,owner指向持有锁的线程。

当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个ObjectWaiter对象插入到cxq的队列尾部,然后暂停当前线程。当持有锁的线程释放锁前,会将cxq中的所有元素移动到EntryList中去,并唤醒EntryList的队首线程。

如果一个线程在同步块中调用了 Object#wait方法,会将该线程对应的ObjectWaiter从EntryList移除并加入到WaitSet中,然后释放锁。当wait的线程被notify之后,会将对应的ObjectWaiter从WaitSet移动到EntryList中。

ObjectMonitor的结构

  // initialize the monitor, exception the semaphore, all other fields
  // are simple integers or pointers
  ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; // 调用wait方法进入的队列
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ; // 抢锁失败插入的队列
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 抢锁的队列,当尺有所的线程要释放前_cxq的元素会移到_EntryList中
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }

源码

前言

在HotSpot中,实现了两种具体的解释器,即模板解释器和C++解释器,它们分别由TemplateInterpreter子模块和CppInterpreter子模块实现。其中,模板解释器正是目前HotSpot的默认解释器。 事实上,除了上述两种解释器,HotSpot中仍保留了另外一种解释器,即字节码解释器(BytecodeInterpreter)。它没有使用编译优化,在运行期就是纯粹地以解释方式执行。由于历史原因,它已经淡出了HotSpot解释器的舞台,但在OpenJDK7中,仍然保留了这部分代码。模板解释器与它有一些渊源,前者便是将BytecodeInterpreter字节码的执行语句换成汇编代码而来的。也正是因此,模板解释器模块的代码更佳以来计算机架构的汇编指令,在可读性上要差一些。