Java对象布局&synchronized及其锁升级

47

12、Java对象内存布局和对象头

12.1、对象的内存布局

​ 在hotpot虚拟机中,一个Java对象的内存布局分为3个部分,对象头(Header)、实例数据(Instance Data)以及对齐填充(Padding)

概览图:

image-20230421144051844

细说对象头:

​ 对象头又分为对象标记(mark word【C++内部对象为MarkOop】)和类元信息(kclass word【内部对象为KlassOop】),类元信息存储的是指向该对象类元数据(类名,修饰符,父类信息等)的首地址,具体源码如下:

image-20230421160957602

12.1.1、Mark Word

​ Mark Word包含实例的identity hashcode, biased locking pattern, locking information, and GC metadata

image-20230421163138745

​ ⚠️在64位系统中,Mark Word占了8个字节,类型指针占了8个字节(开启了压缩指针后将会变成4个字节,默认是开启的),对象头一共是16个字节(说大小为12个字节也没有问题)。

具体结构图:

image-20230421163712480

image-20230423112725036

image-20230421163202289

​ Mark Word默认存储的对象是Hash Code,分代年龄和锁标志位等。这些信息都是和对象自身定义无关的数据,所以Mark Word没被设置为一个固定的数据结构,以便在最小的空间内存储尽可能多的信息,它会根据对象的状态复用自己的存储空间,也就是说Mark Word的内容会随着锁标志位的变化而变化。

12.1.2、类元信息(Class Pointer)

​ 对象指向它的类元信息的指针,虚拟机通过这个指针来确定对象是属于哪个类的实例。包含如下内容:

  1. 类的完全限定名(Fully Qualified Name)
  2. 类的修饰符(public、private等)
  3. 父类的完全限定名
  4. 实现的接口列表
  5. 类中定义的字段和方法
  6. 方法的参数类型和返回类型
  7. 类的静态初始化器(static initializer)代码块
  8. 类加载器的引用

12.1.3、实例数据(Instance Data)

​ 存放类的属性(Field)数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。

12.1.4、对齐填充(Padding)

​ 虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这部分内存按8字节补充对齐。

other

术语说明

对象头C++源码

jdk8u/jdk8u/hotspot: 89fb452b3688 src/share/vm/oops/oop.hpp (openjdk.org)

class oopDesc {
 friend class VMStructs;
private:
 volatile markOop  _mark;
 union _metadata {
   Klass*      _klass;
   narrowKlass _compressed_klass;
 } _metadata;

 // Fast access to barrier set.  Must be initialized.
 static BarrierSet* _bs;

public:
 markOop  mark() const         { return _mark; }
 markOop* mark_addr() const    { return (markOop*) &_mark; }

_mark字段是mark word,_metadata是类指针klass pointer,对象头(object header)即是由这两个字段组成,这些术语可以参考Hotspot术语表。

看看Object obj = new Object()的对象头

​ 使用JOL查看对象布局。pom导入坐标:

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

测试结果:

image-20230423113547735

GC分代年龄说明:

​ 我们都知道一个对象在新生代的 s0和s1之间最多交换15次就会进入到老年代,原因就是对象头中GC分代年龄的标志只有4个字节,所以最大值就是15,当然也可以通过JVM的虚拟机设置(-XX:MaxTenuringThreshold=15)来配置分代年龄,但依然不能大于15。

image-20230423114026595

查看JVM默认启动的参数

​ 可以在命令行后或者IDEA VM Options中添加-XX:+PrintCommandLineFlags -version即可打印出启动参数和版本等。

-XX:InitialHeapSize=536870912 -XX:MaxHeapSize=8589934592 -XX:MaxTenuringThreshold=10 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC 
java version "1.8.0_311"
Java(TM) SE Runtime Environment (build 1.8.0_311-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.311-b11, mixed mode)

Process finished with exit code 0

通过以上的输出可以清楚的看到默认开启了类型指针压缩的-XX:+UseCompressedClassPointers,且我还设置了分代年龄为10。

关闭类元信息指针压缩

​ 通过-XX:-UseCompressedClassPointers 关闭指针压缩,以上面的new Object()为例,结果如下:

image-20230423115341559

对象头中随着锁状态的变化对应的偏向锁和锁标志位的变化说明:

​ 1、默认无锁状态,偏向锁标志位为0,锁标志位为01。

​ 2、偏向锁:偏向锁标志位为1,锁标志位为01。

​ 3、轻量级锁:偏向锁标志位没有了,锁标志位为00。

​ 4、重量级锁:偏向锁标志位没有了,锁标志位为10。

13、synchronized和锁升级

​ 在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着Java SE 1.6对synchronized进行了各种优化之后,有些情况下它就并不那么重了。

13.1、为什么说之前的synchronized是重量级锁呢

image-20230424121218609

​ Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态与内核态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。

​ 在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,挂起线程和恢复线程都需要转入内核态去完成,阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长”,时间成本相对较高,这也是为什么早期的synchronized效率低的原因

Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁。

13.2、每个对象都可以是锁?

​ 查看C++中对象头中标记头markOop的源码可以看出通过value()获得当前对象的标记头然后通过异或操作将其与一个特殊的值monitor_value进行异或。monitor_value是一个常量值,用于获取标记头中的monitor标记位,用于判断对象是否已经被加锁。由于monitor_value中只有一个标记位为1,因此通过异或操作可以将标记头中的monitor位取反。如果monitor位为1,那么异或操作后这一位就会变成0;如果monitor位为0,那么异或操作后这一位就会变成1。

​ 最后,将异或操作后的结果转换为ObjectMonitor*类型的指针,并返回。这个指针指向的就是当前对象对应的ObjectMonitor对象,可以用于实现同步操作

image-20230424145400947

Monitor与Java对象以及线程是如何关联 ?

1.如果一个Java对象被某个线程锁住,则该Java对象的Mark Word字段中LockWord指向monitor的起始地址。(当需要获取 ObjectMonitor 指针时,JVM 通过进行 XOR 运算,将 Lock Word 中的值还原成原始的 ObjectMonitor 指针。这样就可以通过 Lock Word 存储指向 ObjectMonitor 的指针,而不会增加对象头的大小。)

2.Monitor的Owner字段会存放拥有相关联对象锁的线程id

Mutex Lock 的切换需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。

13.3、synchronized锁的种类和升级步骤

13.3.1、锁的种类

13.3.1.1、无锁
import org.openjdk.jol.info.ClassLayout;

public class NoLockTest {

    public static void main(String[] args) {
        Object o = new Object();
        System.out.println("10 进制: " + o.hashCode());
        System.out.println("16 进制: " + Integer.toHexString(o.hashCode()));
        System.out.println("2 进制: " + Integer.toBinaryString(o.hashCode()));
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
        System.out.println("1100001000001000101010111010110".length());
    }
}

红色为hashcode,蓝色为偏向锁状态+锁状态为001,代表无锁

image-20230424163415526

13.3.1.2、偏向锁

​ 在实际应用运行过程中发现,“锁总是同一个线程持有,很少发生竞争”,也就是说锁总是被第一个占用他的线程拥有,这个线程就是锁的偏向线程。

那么只需要在锁第一次被拥有的时候,记录下偏向线程ID。这样偏向线程就一直持有着锁(后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁)。

如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了,直到竞争发生才释放锁。以后每次同步,检查锁的偏向线程ID与当前线程ID是否一致,如果一致直接进入同步。无需每次加锁解锁都去CAS更新对象头。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。

假如不一致意味着发生了竞争,锁已经不是总是偏向于同一个线程了,这时候可能需要升级变为轻量级锁,才能保证线程间公平竞争锁。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。

技术实现:

​ 一个synchronized方法被一个线程抢到了锁时,那这个方法所在的对象就会在其所在的Mark Word中将偏向锁修改状态位,同时还会有占用前54位来存储线程指针作为标识。若该线程再次访问同一个synchronized方法时,该线程只需去对象头的Mark Word 中去判断一下是否有偏向锁指向本身的ID,无需再进入 Monitor 去竞争对象了。

image-20230424164457894

详细说明:

​ 假如有一个线程执行到synchronized代码块的时候,JVM使用CAS操作把线程指针ID记录到Mark Word当中,并修改偏向标示,标示当前线程就获得该锁。锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。

​ 这时线程获得了锁,可以执行同步代码块。当该线程第二次到达同步代码块时会判断此时持有锁的线程是否还是自己(持有锁的线程ID也在对象头里),JVM通过对象的Mark Word判断:当前线程ID还在,说明还持有着这个对象的锁,就可以继续进入临界区工作。由于之前没有释放锁,这里也就不需要重新加锁。 如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。

结论:JVM不用和操作系统协商设置Mutex(争取内核),它只需要记录下线程ID就标示自己获得了当前锁,不用操作系统接入。

上述就是偏向锁:在没有其他线程竞争的时候,一直偏向偏心当前线程,当前线程可以一直执行。

偏向状态状态查看:

image-20230424172238149

​ ⚠️⚠️ 偏向锁有启动延迟,测试时通过-XX:BiasedLockingStartupDelay=0 JVM参数关闭延迟启动即可见到效果

偏向锁的撤销:

​ 前面也说到偏向锁是对单个线程的优化,如果出现了多个线程来竞争同一个对象的锁,那么就要撤销偏向锁,否则都会在那里不断CAS自旋,造成负优化降低性能。

​ 想要撤销偏向锁时还不能对持有该偏向锁的线程有影响,就需要等待该线程到达一个安全点(safepoint),这里的安全点是指JVM在垃圾回收时为了保证引用状态不会发生变化而设置的一种安全状态,在这种状态下会暂停所有线程的工作。在这种状态下会挂起所有持有偏向锁的线程。此时撤销偏向锁就可能会有2种情况:

​ 1、线程已经离开了同步块,此时可以直接撤销偏向锁。

​ 2、线程还在同步块内,那么需要将偏向锁升级为轻量级锁。

批量重偏向(bulk rebias)

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

BiasedLockingBulkRebiasThreshold = 20

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

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

  1. 找到该 class 所有正处于加锁状态的偏向锁对象,将其epoch字段改为新值
  2. 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
  1. 如果在距离上次批量重偏向发生的 25 秒之内,并且累计撤销计数达到40,就会发生批量撤销(偏向锁彻底 game over)
  2. 如果在距离上次批量重偏向发生超过 25 秒之外,那么就会重置在 [20, 40) 内的计数, 再给次机会

image-20230425152652491

升级为偏向锁之后Mark Word中的hashcode去哪了?

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

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

场景1:

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

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

        o.hashCode();
        System.out.println("已生成 hashcode,MarkWord 为:");
        System.out.println(ClassLayout.parseInstance(o).toPrintable());

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

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

image-20230425154601212

场景2:

​ 已偏向某一个线程,然后生成 hashcode,然后同一个线程又进入同步块,会发生什么呢?

public class ObjectHeaderHashcodeTest2 {

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

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

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

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

​ 结果:同场景1一样,生成hashcode后,直接升级为轻量级锁。

image-20230425155340212

场景3:

​ 一个线程获得偏向锁后,再在同步块中获取hashcode会怎样?

public class ObjectHeaderHashcodeTest2 {

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

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

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

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

​ 结果:直接变为重锁。

image-20230425155652003

以下是《深入理解Java虚拟机》的13.3.5 偏向锁中所说:

image-20230425153548377

13.3.1.3、轻量级锁

​ 轻量级锁是为了在线程近乎交替执行同步块时提高性能。

​ 主要目的: 在没有多线程竞争的前提下,通过CAS减少重量级锁使用操作系统互斥量产生的性能消耗,说白了先自旋再阻塞。

​ 升级时机: 当关闭偏向锁功能或多线程竞争偏向锁会导致偏向锁升级为轻量级锁

​ 假如线程A已经拿到锁,这时线程B又来抢该对象的锁,由于该对象的锁已经被线程A拿到,当前该锁已是偏向锁了。

​ 而线程B在争抢时发现对象头Mark Word中的线程ID不是线程B自己的线程ID(而是线程A),那线程B就会进行CAS操作希望能获得 锁。

此时线程B操作中有两种情况:

如果锁获取成功,直接替换Mark Word中的线程ID为B自己的ID(A → B),重新偏向于其他线程(即将偏向锁交给其他线程,相当于当前线程"被"释放了锁),该锁会保持偏向锁状态,A线程Over,B线程上位;

image-20230425163600667

如果锁获取失败,则偏向锁升级为轻量级锁,此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程B会进入自旋等待获得该轻量级锁。

image-20230425163630269

何时升级为重量级锁:

​ JDK1.6前:

​ 默认自旋超过10次(-XX:PreBlockSpin=10来修改),或者自旋线程数超过CPU核心数一般。

​ JDK1.6及以后:

​ 自适应升级,不固定。

13.3.3.4、重量级锁

​ 它是操作系统提供的一种同步原语,通常也被称为互斥量(mutex)或者信号量(semaphore)。涉及到用户态和内核态的切换。

在使用重量级锁时,当一个线程需要获取锁时,它会尝试申请锁,如果锁被其他线程持有,那么申请锁的线程就会被阻塞,直到锁被释放。锁的申请和释放操作都需要操作系统提供的系统调用来完成,因此称为“重量级锁”。

13.3.3.5、总结

image-20230425170203071

synchronized锁升级过程总结:一句话,就是先自旋,不行再阻塞。

实际上是把之前的悲观锁(重量级锁)变成在一定条件下使用偏向锁以及使用轻量级(自旋锁CAS)的形式

synchronized在修饰方法和代码块在字节码上实现方式有很大差异,但是内部实现还是基于对象头的MarkWord来实现的。

JDK1.6之前synchronized使用的是重量级锁,JDK1.6之后进行了优化,拥有了无锁->偏向锁->轻量级锁->重量级锁的升级过程,而不是无论什么情况都使用重量级锁。

偏向锁:适用于单线程适用的情况,在不存在锁竞争的时候进入同步方法/代码块则使用偏向锁。

轻量级锁:适用于竞争较不激烈的情况(这和乐观锁的使用范围类似), 存在竞争时升级为轻量级锁,轻量级锁采用的是自旋锁,如果同步方法/代码块执行时间很短的话,采用轻量级锁虽然会占用cpu资源但是相对比使用重量级锁还是更高效。

重量级锁:适用于竞争激烈的情况,如果同步方法/代码块执行时间很长,那么使用轻量级锁自旋带来的性能消耗就比使用重量级锁更严重,这时候就需要升级为重量级锁。

13.3.2、锁的优化

锁消除:

​ 锁消除即删除不必要的加锁操作。JVM在运行时,对一些“在代码上要求同步,但是被检测到不可能存在共享数据竞争情况的锁进行消除。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么就可以认为这段代码是线程安全的,无需加锁。

/** * 锁消除 
 * 从JIT角度看相当于无视它,synchronized (o)不存在了,这个锁对象并没有被共用扩散到其它线程使用, 
 * 极端的说就是根本没有加这个锁对象的底层机器码,消除了锁的使用 
 */
public class LockClearUPDemo {

    static Object objectLock = new Object();

    //正常的
    public void m1() {
        //锁消除,JIT会无视它,synchronized(对象锁)不存在了。不正常的
        Object o = new Object();
        synchronized (o) {
            System.out.println("-----hello LockClearUPDemo" + "\t" + o.hashCode() + "\t" + objectLock.hashCode());
        }
    }

    public static void main(String[] args) {
        LockClearUPDemo demo = new LockClearUPDemo();
        for (int i = 1; i <= 10; i++) {
            new Thread(() -> {
                demo.m1();
            }, String.valueOf(i)).start();
        }
    }
}

锁粗化:

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

如果JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。

/**
 * 锁粗化
 * 假如方法中首尾相接,前后相邻的都是同一个锁对象,那JIT编译器就会把这几个synchronized块合并成一个大块,
 * 加粗加大范围,一次申请锁使用即可,避免次次的申请和释放锁,提升了性能
 */
public class LockExpandDemo {
    static Object objectLock = new Object();


    public static void main(String[] args)
    {
        new Thread(() -> {
            synchronized (objectLock) {
                System.out.println("11111");
            }
            synchronized (objectLock) {
                System.out.println("22222");
            }
            synchronized (objectLock) {
                System.out.println("33333");
            }
        },"a").start();

        new Thread(() -> {
            synchronized (objectLock) {
                System.out.println("44444");
            }
            synchronized (objectLock) {
                System.out.println("55555");
            }
            synchronized (objectLock) {
                System.out.println("66666");
            }
        },"b").start();

    }
}