对象头中的MarkWord

2023-07-27 jvm

本篇可以说是介绍对象头中的MarkWord的构成,也可以说是对JVM中锁的探索。

首先运行下面代码,其中MyObject()是一个空对象,里面什么属性都没有。

public static void main(String[] args) {
    MyObject obj = new MyObject();
    System.out.println(obj + " 十六进制哈希:" + Integer.toHexString(obj.hashCode()));
    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}

运行结果如下,注意70 c1 09哈希值和第一行09 c1 70正好相反,原因百度搜索“计算机基础大端与小端”,只要知道我们打印输出的哈希值和头信息相同即可。

com.xk857.test3.MyObject@d70c109 十六进制哈希:d70c109
com.xk857.test3.MyObject object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
                                                                     // --16进制-- -------------------- 2进制 --------------------   -- 10进制 --
      0     4        (object header)                           01 09 c1 70 (00000001 00001001 11000001 01110000) (1891698945)
      4     4        (object header)                           0d 00 00 00 (00001101 00000000 00000000 00000000) (13)
      8     4        (object header)                           43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

这个图中的二进制是第二行+第一行的数据也就是Markword,具体功能解释见后文。

image-20230726222848669

# Java对象头为什么存储锁信息

高并发多线程抢obj,如果是线程B抢到,线程B就锁住了obj,其他线程就不能抢;问题:谁来记录线程B抢到obj,并告诉其他线程等待?如果是你,你会怎么做?

  • A方案:开辟一个空间来存储,obj=B, 当B解锁时把obj=nulI,其他线程每次检查obj是否为null,不是为null就能继续抢obj。
  • B方案:在obj的对象头开辟一块锁空间把B设置进去,当B解锁时,obj的对象头锁空间清空,其他线程请求时只要对象头锁空间为空,都可以继续抢。

这2种方案中,A方案有个致命性的缺陷,就是新开辟的空间有线程安全问题,还要继续加锁,麻烦。而B方案就没有线程安全的问题了,obj本身就是被锁住,谁拿到锁谁在obj身上设置自己。这个就是对象头Mark Word空间。

锁

# 什么是无锁,什么是匿名偏向锁 ?

先来看无锁代码,二进制下锁的表示,001表示无锁。

无锁

给上图代码加上睡眠,查看控制台输出,发现从无锁001→偏向锁101(1是偏向锁,01是锁的类型即无锁),匿名偏向锁是没有线程ID的,至于偏向锁看下一个图片。

匿名偏向锁

# 偏向锁与匿名偏向锁

注意看红色的是锁,蓝色的线程ID,线程ID为0代表的偏向锁是匿名偏向锁,这就是二者的区别。

偏向锁与匿名偏向锁

那么为什么要睡眠?

因为虚拟机在启动的时候对于偏向锁有延迟,如果没有偏向锁的延迟的话,虚拟机在启动的时候,可能JVM某个线程调用你的线程,这样就有可能变成了轻量锁或者重量锁,所以要做偏向锁的延迟(可以简单理解成JVM的优化),那我们怎么看到打印的对象头是偏向锁呢?

  1. 加锁之前先让线程睡几秒。
  2. 加上JVM的运行参数,关闭偏向锁的延迟,具体的命令如下:-XX: +UseBiasedLocking -XX:BiasedLockingStartupDelay=0

# 偏向锁线程ID的作用

  • 一个线程在第一次进入同步块时,会在对象头和栈帧中的锁记录里存储锁的偏向的线程ID。
  • 当下次该线程进入这个同步块时,会去检查锁的MarkWord里面是不是放的自己的线程ID。
  • 如果是,表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁
  • 如果不是,就代表有另一个线程来竞争这个偏向锁。
  • 这个时候会尝试使用CAS来替换MarkWord里面的线程ID为新线程的ID,这个时候要分两种情况
    • 成功,表示之前的线程不存在了,Mark Word里面的线程ID为新线程的ID,锁不会升级,仍然为偏向锁;
    • 失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为0,并设置锁标志位为00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。
  • 本质上的功能是在无竞争的情况下减少锁竞争的开销
  • 注意:这个线程ID并不是JVM分配的线程ID号,和Java Thread中的ID是两个概念。

# 什么情况偏向锁会升级为轻量级锁?

观察如下代码,当主线程进入的时候锁没有升级,此时还是偏向锁;但是当其他的线程进入的时候,偏向锁便升级为了轻量级锁。

public void test4() throws InterruptedException {
    MyObject obj1 = new MyObject();
    System.out.println(ClassLayout.parseInstance(obj1).toPrintable()); // 无锁001

    Thread.sleep(5000);
    MyObject obj2 = new MyObject();
    System.out.println(ClassLayout.parseInstance(obj2).toPrintable()); // 匿名偏向锁101

    synchronized (obj2) {
        System.out.println(ClassLayout.parseInstance(obj2).toPrintable()); // 偏向锁101,有线程id 
    }

    new Thread(() -> {
        synchronized (obj2) {
            System.out.println(ClassLayout.parseInstance(obj2).toPrintable()); // 轻量级锁 000
        }
    });
}

# 什么是轻量级锁?

多个线程在不同时段获取同一把锁,即不存在锁竞争的情况,也就没有线程阻塞。针对这种情况,JVM采用轻量级锁来避免线程的阻塞与唤醒。

偏向锁用CAS替换MarkWord里面的线程ID为新线程的ID,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。

  • 一旦获取失败,就说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。
  • 自旋:不断尝试去获取锁,用循环实现;
    • 自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。
    • 解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。
    • JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
    • 自旋也不是一直进行下去的,如果自旋到一定程度(和JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。
    • 同时这个锁就会升级成重量级锁。

# 轻量级锁什么情况会升级为重量级锁?

自旋一直失败(达到一定程度)依然拿不到锁就会阻褰,此时升级为重量级锁。重量级锁依赖于操作系统的互斥量实现的,而操作系统中线程间状态的转换需要相对比较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗CPU。

重量级锁是悲观锁的一种,自旋锁、轻量级锁与偏向锁属于乐观锁。偏向锁的对象头MarkWord格式:偏向锁为0、锁类型为10

public void test5() throws InterruptedException {
    MyObject obj = new MyObject();
    Thread t2 = new Thread(() -> {
        synchronized (obj) {
            System.out.println(ClassLayout.parseInstance(obj).toPrintable()); // 线程1重量级锁010
        }
    });
    Thread t1 = new Thread(() -> {
        synchronized (obj) {
            System.out.println(ClassLayout.parseInstance(obj).toPrintable()); // 线程2重量级锁010
        }
    });
    t1.start();
    t2.start();
	Thread.sleep(20 * 1000);
}

# 总结

  1. 添加线程睡眠会从无锁变为匿名偏向锁,原因是当一个线程获取的锁时,JVM会将此全局线程ID记录在对象头中,如果其他线程尝试获取该锁,JVM会检查对象头的线程ID是否与全局线程ID相同,如果相同,则仍然是偏向锁状态,这样可以避免在对象头中记录线程ID的开销。

    匿名偏向锁的优点是在短暂的锁竞争场景下,减少了锁升级的开销,并提高了应用程序的并发性能和吞吐量。

  2. 如果使用synchronized锁住对象,会从匿名偏向锁升级为偏向锁,因为此时锁住的时具体的对象,需要在对象头中记录线程ID,以此当其他线程尝试获取对象时,可以从头信息知道此对象已被偏向锁定位,会等待其释放资源才会获取到信息(CAS)。

  3. 多个synchronized会从偏向锁升级为轻量级锁,如果获取失败会使用自旋的方式(for循环)尝试获取对象。

  4. 在同一线程顺序执行的情况下使用轻量级锁,如果是多个线程同时获取同一加锁对象,此时可能会造成自旋时间过长,那么此时就会升级成重量级锁,重量级锁依赖于操作系统的互斥量实现,属于悲观锁的一种,重量级锁效率很低,但被阻塞的线程不会消耗CPU。

上次更新: 5 个月前