深入理解Java的对象头mark word

上一篇博客我们编译了Linux源码来证明了Java中有偏向锁,但是我们从周志明大佬的《深入理解java虚拟机》的书中知道,我们可以通过分析Java对象头中MarkWord来查看是那种锁,下面是32位JVM的对象中的Mark Word图,但是随着JDK的不断升级,JDK没有32位的版本,所以我们要研究64的JVM中对象的MarkWord。

深入理解Java的对象头mark word
当我在网上找了很多资料的后,发现都是32位JVM,无法满足我们对64位JVM的研究,于是我想到了JDK源码,看看其中有没有注释,于是我去编译好的JDK源码找找看,找到对应的源码的注释如下:

深入理解Java的对象头mark word
我们可以将上面的注释转成以下的表格

|-----------------------------------------------------------------------------------------------------------------|
|                                             Object Header(128bits)                                              |
|-----------------------------------------------------------------------------------------------------------------|
|                                   Mark Word(64bits)               |  Klass Word(64bits)    |      State         |
|-----------------------------------------------------------------------------------------------------------------|
| unused:25|identity_hashcode:31|unused:1|age:4|biase_lock:1|lock:2 | OOP to metadata object |      Nomal         |
|-----------------------------------------------------------------------------------------------------------------|
| thread:54|      epoch:2       |unused:1|age:4|biase_lock:1|lock:2 | OOP to metadata object |      Biased        |
|-----------------------------------------------------------------------------------------------------------------|
|                     ptr_to_lock_record:62                 |lock:2 | OOP to metadata object | Lightweight Locked |
|-----------------------------------------------------------------------------------------------------------------|
|                    ptr_to_heavyweight_monitor:62          |lock:2 | OOP to metadata object | Heavyweight Locked |
|-----------------------------------------------------------------------------------------------------------------|
|                                                           |lock:2 | OOP to metadata object |    Marked for GC   |
|-----------------------------------------------------------------------------------------------------------------|

从上面的表格,我们可以看出Java的对象头在对象的不同的状态下会有不同的表现形式,主要有三种状态,无锁状态,加锁状态,GC标记状态。那么就可以理解Java当中的上锁其实可以理解给对象上锁。也就是改变对象头的状态,如果上锁成功则进入同步代码块。但是Java当中的锁又分为很多种,从上图可以看出大体分为偏向锁、轻量锁、重量锁三种锁状态。这三种锁的效率是完全不同、关于效率的分析会在下文分析。我们需要查看对象头,就需要用到借助JOL工具。

首先我们在项目中引入JOL的依赖,具体如下图:

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

然后创建A.java

public class A{}
  • 然后创建JOLExample1.java
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;

import static java.lang.System.out;

public class JOLExample1 {
    static A a;

    public static void main(String[] args) {
        a = new A();
        //打印JVM的详细信息
        out.println(VM.current().details());
        //打印对应的对象头信息
        out.println(ClassLayout.parseInstance(a).toPrintable());
    }
}

运行的结果如下
深入理解Java的对象头mark word

不是说Klass是64bits(8个字节)但是这儿只有4个字节,是因为我们开启了指针压缩,我们可以关闭指针压缩看看,是不是8个字节。我们只需要使用以下的JVM运行参数

-XX:-UseCompressedOops
  • 1

深入理解Java的对象头mark word

再次运行刚才的程序,可以看到我们Klass对象是64bits(16个字节),具体如下图

深入理解Java的对象头mark word

解决了上面的问题后,我们再回到原来开启指针压缩图上来

深入理解Java的对象头mark word

我们可以看到整个对象是16B(16个字节),其中对象头(object header)12B(12个字节),还有4B是对齐的字节(因为在64位虚拟机上对象的大小必须是8的倍数),由于这个对象里面没有任何字段,故而对象的实例数据为0B,那怎么让它不是0B呢?我们可以在A中添加一个boolean类型的数据,再看结果,修改A.java如下所示

public class A {
    //占一个字节的boolean字段
    private boolean flag;
}

我们再次运行JOLExample.java,查看结果如下:

深入理解Java的对象头mark word

这个对象的大小还是没有改变一共16B,其中对象头(object header) 12B,boolean字段flag(对象的实例数据)占1B,剩下的3B就是对齐字节。由此我们可以认为一个对象的布局大体分为三个部分分别是对象头(Object header)、对象的实例数据、字节对齐。

上面说是64位虚拟机上对象的大小必须是8的倍数,我们可以证明一下,再加一个int(4个字节)数据,我们再次修改A.java如下所示

public class A {
    //占一个字节的boolean字段
    private boolean flag;
    //占四个字节的int字段
    private int a;
}

再次运行程序,如下所示,可以看到对象的大小是8的倍数

深入理解Java的对象头mark word

看完了对象的实例数据,我们就来到了今天的重头戏,Java的对象头(在开启JVM指针压缩的情况下是12B),那么这12B存储的是什么?我们可以看下
OpenJDK的官网的解释

首先引用openjdk文档当中对对象头的解释

深入理解Java的对象头mark word

上述引用中提到一个java对象头包含2个word,并且包含了堆对象的布局、类型、GC状态、同步状态和标识哈希码,具体怎么包含的呢?又是哪两个word呢?
深入理解Java的对象头mark word

Mark word为第一个word根据文档可以知道它里面包含了锁的信息、hashcode、gc信息等等,第二个word是什么呢?

深入理解Java的对象头mark word

klass word 为对象头的第二个word主要指向对象的元数据。

深入理解Java的对象头mark word

假设我们理解一个对象头主要由上图两个部分组成(数组对象除外,数组对象的对象还包含一个数组长度),由我们的推导出Mark word是8个字节,klass word(开启指针压缩的情况下是4个字节,不开启的时候是8个字节)。我们打印出来的对象头是12个字节,所以其中的8个字节是Mark word,剩下的4个字节是klass word,但是和锁相关的就是Mark word,那么接下来要重点分析Mark word里面信息。

由最开始的64位的表格,我们可以得知在无锁的情况下Markword当中前56bit存的是对象的hashcode,我们来验证一下

修改A.java 的代码如下

public class A {
    //占一个字节的boolean字段
    private boolean flag;
}

新建一个JOLExample2.java具体代码如下

import org.openjdk.jol.info.ClassLayout;

import static java.lang.System.out;
public class JOLExample2 {

    public static void main(String[] args) {
        A a = new A();
        //没有计算HashCode之前的对象头
        out.println("before hash");
        out.println(ClassLayout.parseInstance(a).toPrintable());
        
        //jvm计算HashCode
        out.println("jvm----------" + Integer.toHexString(a.hashCode()));
        
        //当计算完HashCode之后,我们可以查看对象头的信息变化
        out.println("after hash");
        out.println(ClassLayout.parseInstance(a).toPrintable());
        
    }
}

运行的结果如下:

深入理解Java的对象头mark word

可以看到我们在没有进行hashcode运算的时候,所有的值都是空的。当我们计算完了hashcode,对象头就是有了数据。因为是小端存储,所以你看的值是倒过来的。前25bit没有使用所以都是0,后面31bit存的hashcode,所以第一个字节中八位存储的分别就是分代年龄、偏向锁信息、对象状态,这8bit分别表示的信息如下图所示,这个图会随着对象的状态改变而改变,下图是无锁的状态下

深入理解Java的对象头mark word

关于对象状态一共分为五种状态,分别是无锁、偏向锁、轻量锁、重量锁、GC标记,但是2bit只能表示4种状态(00,01,10,11)JVM的做法将偏向锁和无锁的状态表示为同一个状态,然后根据图中偏向锁的标识再去标识是无锁还是偏向锁状态。写个代码分析一下,在写代码之前我们先记得无锁状态下的信息是00000001,写个偏向锁的例子如下所示

新建一个JOLExample3.java,代码如下:

import org.openjdk.jol.info.ClassLayout;

import static java.lang.System.out;

public class JOLExample3 {

    static A a;

    public static void main(String[] args) throws InterruptedException {
        a = new A();
        out.println("before lock");
        out.println(ClassLayout.parseInstance(a).toPrintable());

        sync();

        out.println("after lock");
        out.println(ClassLayout.parseInstance(a).toPrintable());
        
    }

    private static void sync() {
        synchronized (a) {
            out.println("我不知道要打印什么");
        }
    }
}

查看运行结果如下:

深入理解Java的对象头mark word

上面这个程序只有一个线程去调用sync方法,应该是偏向锁,但是你会发现输出的结果(第一个字节)依然是00000001和无锁的时候一模一样,其实这是因为虚拟机在启动的时候对于偏向锁有延迟,如果没有偏向锁的延迟的话,虚拟机在启动的时候,可能JVM某个线程调用你的线程,这样就有可能变成了轻量锁或者重量锁,所以要做偏向锁的延迟,那我们怎么看到打印的对象头是偏向锁呢?有两种方式:第一种是加锁之前先让线程睡几秒。第二种加上JVM的运行参数,关闭偏向锁的延迟,具体的命令如下:

-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
  • 第一种方式:修改JOLExample3.java如下
import org.openjdk.jol.info.ClassLayout;

import static java.lang.System.out;

public class JOLExample3 {

    static A a;

    public static void main(String[] args) throws InterruptedException {
        //切记延迟一定要放在对象创建之前,不然是无效的,因为在你对象创建之前,偏向锁的延迟的时间
        //没有给你睡过去,这时候,对象已经创建了,对象头的信息已经生成了。
        Thread.sleep(5000);
        a = new A();
        out.println("before lock");
        out.println(ClassLayout.parseInstance(a).toPrintable());

        sync();

        out.println("after lock");
        out.println(ClassLayout.parseInstance(a).toPrintable());

    }

    private static void sync() {
        synchronized (a) {
            out.println("lock ing");
            out.println(ClassLayout.parseInstance(a).toPrintable());
        }
    }
}

再次运行,查看结果如下:

深入理解Java的对象头mark word

可以发现已经变成了00000101,偏向锁,需要注意的after lock,退出同步后依然保持了偏向信息。

第二种方式:利用jvm参数,首先我们先关闭睡眠5秒的,然后运行配置如下:

深入理解Java的对象头mark word

再次运行查看结果如下:

深入理解Java的对象头mark word

这时候大家会有疑问了,为什么在没有加锁之前是偏向锁,准确的说,应该是叫可偏向的状态,因为它后面没有存线程的ID,当lock ing的时候,后面存储的就是线程的ID(44969989)既然这儿存储是线程的ID,那么HashCode又存储到什么地方去了?是不是计算了HashCode就是不能偏向了?我们来验证一下,计算完HashCode,还是不是偏向锁了

我们再次修改JOLExample3.java,具体代码如下:

import org.openjdk.jol.info.ClassLayout;

import static java.lang.System.out;

public class JOLExample3 {

    static A a;

    public static void main(String[] args) throws InterruptedException {
        //切记延迟一定要放在对象创建之前,不然是无效的,因为在你对象创建之前,偏向锁的延迟的时间
        //没有给你睡过去,这时候,对象已经创建了,对象头的信息已经生成了。
        //Thread.sleep(5000);
        a = new A();
        out.println("before lock");
        out.println(ClassLayout.parseInstance(a).toPrintable());
        a.hashCode();

        sync();

        out.println("after lock");
        out.println(ClassLayout.parseInstance(a).toPrintable());

    }

    private static void sync() {
        synchronized (a) {
            out.println("lock ing");
            out.println(ClassLayout.parseInstance(a).toPrintable());
        }
    }
}

同时关闭JVM中偏向锁的延迟,运行的结果如下:
深入理解Java的对象头mark word

我们可以发现:在before lock的时候是可偏向的状态,lock ing的时候变成了轻量锁,after lock 的时候变成了无锁,所以我们得出对象计算了HashCode,就不是偏向锁了。

看完了偏向锁的对象头,我们再来看看轻量锁的对象头,轻量级锁尝试在应用层面解决线程同步问题,而不触发操作系统的互斥操作,轻量级锁减少多线程进入互斥的几率,不能代替互斥。

创建JOLExample4.java,代码如下:

import org.openjdk.jol.info.ClassLayout;

import static java.lang.System.out;

public class JOLExample4 {
    static A a;

    public static void main(String[] args) {
        a = new A();
        out.println("before lock");
        out.println(ClassLayout.parseInstance(a).toPrintable());

        sync();

        out.println("after lock");
        out.println(ClassLayout.parseInstance(a).toPrintable());
    }

    private static void sync() {
        synchronized (a) {
            out.println("lock ing");
            out.println(ClassLayout.parseInstance(a).toPrintable());
        }
    }
}

运行结果如下:

深入理解Java的对象头mark word

可以得出:before lock 的时候是 00000001 无锁的状态,lock ing 的时候是 01010000 轻量锁的状态,after lock 的时候是 00000001 无锁的状态。

看完了轻量锁的对象头,我们再来看看重量锁的对象头,我们先创建一个JOLExample5.java具体代码如下:

import org.openjdk.jol.info.ClassLayout;

import static java.lang.System.out;

public class JOLExample5 {
    static A a;

    public static void main(String[] args) throws InterruptedException {
        a = new A();
        out.println("before lock");
        out.println(ClassLayout.parseInstance(a).toPrintable());
        
        Thread t1 = new Thread(()->{
            synchronized (a) {
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                out.println("t1 release");
            }
        });
        t1.start();
        
        Thread.sleep(1000);
        out.println("t1 lock ing");
        out.println(ClassLayout.parseInstance(a).toPrintable());
        
        sync();
        out.println("after lock");
        out.println(ClassLayout.parseInstance(a).toPrintable());

        System.gc();
        out.println("after gc()");
        out.println(ClassLayout.parseInstance(a).toPrintable());
    }

    private static void sync() {
        synchronized (a) {
            out.println("main lock ing");
            out.println(ClassLayout.parseInstance(a).toPrintable());
        }
    }
}

运行结果如下:

深入理解Java的对象头mark word

在加锁之前(before lock)是 00000001 无锁,这时候t1来加锁,因为只有他一个线程所以轻量锁(t1 lock ing 00010000)由于t1在run方法中睡眠了5秒,这时候主线程也来尝试加锁,这个时候就是两个线程竞争了,所以是重量锁(main lock ing 00101010
深入理解Java的对象头mark word

当结束的时候,还是重量锁(afteer lock 00101010),当执行一次gc操作过后发现变成了无锁但是年龄加了1(after gc() 00001001

还有一点需要我们注意的就是:当调用wait方法会直接变成重量锁,我们来验证一下,创建JOLExample6.java,代码如下:

import org.openjdk.jol.info.ClassLayout;

import static java.lang.System.out;

public class JOLExample6 {
    static A a;

    public static void main(String[] args) throws Exception {

        a = new A();
        out.println("before lock");
        out.println(ClassLayout.parseInstance(a).toPrintable());

        Thread t1 = new Thread(() -> {
            try {
                synchronized (a) {
                    out.println("before wait");
                    out.println(ClassLayout.parseInstance(a).toPrintable());
                    a.wait();
                    out.println("after wait");
                    out.println(ClassLayout.parseInstance(a).toPrintable());
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        });
        t1.start();
        Thread.sleep(5000);
        synchronized (a) {
            a.notifyAll();
        }
    }
}

运行结果如下:

深入理解Java的对象头mark word

可以看到在加锁之前是无锁的状态,执行wait方法之前是轻量锁,执行wait 方法之后,被唤醒的后是重量锁。

既然synchronized关键字有这三种锁,我们简单的比较它们之间的性能(粗略的比较下),书写以下的代码

public class A {
    int i;
    public synchronized void parse() {
        i++;
    }
}
//关闭偏向锁延迟‐XX:BiasedLockingStartupDelay=0
public class JOLExample7 {
    public static void main(String[] args) throws Exception {
        A a = new A();
        long start = System.currentTimeMillis();
        //调用同步方法1000000000L 来计算1000000000L的++,对比偏向锁和轻量级锁的性能
        //如果不出意外,结果灰常明显
        for (int i = 0; i < 1000000000L; i++) {
            a.parse();
        }
        long end = System.currentTimeMillis();
        System.out.println(String.format("%sms", end - start));
    }

}

先运行加上jvm参数关闭偏向锁延迟,就是偏向锁,然后运行的结果如下:

深入理解Java的对象头mark word

我们在开启偏向锁延迟就是轻量锁,然后运行结果如下:

深入理解Java的对象头mark word

最后我们在看重量锁,具体代码如下:

public class A {
    int i;
    public synchronized void parse() {
        JOLExample8.countDownLatch.countDown();
        i++;
    }
}

import java.util.concurrent.CountDownLatch;

public class JOLExample8 {
    static CountDownLatch countDownLatch = new CountDownLatch(1000000000);

    public static void main(String[] args) throws Exception {
        final A a = new A();

        long start = System.currentTimeMillis();

        //调用同步方法1000000000L 来计算1000000000L的++,对比各种锁的性能
        //如果不出意外,结果灰常明显
        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                while (countDownLatch.getCount() > 0) {
                    a.parse();
                }
            }).start();
        }
        countDownLatch.await();
        long end = System.currentTimeMillis();
        System.out.println(String.format("%sms", end - start));
    }
}

重量级锁的执行结果如下:

深入理解Java的对象头mark word

最后总结的结果如下:

偏向锁 轻量锁 重量锁
2355ms 23564ms 31227ms

最后我们再画个图总结下各种锁的对象头(只画出了最重要的部分,其他的省略)
深入理解Java的对象头mark word

上一篇:Pytest(9)skip跳过用例


下一篇:算法-BFS