初探java对象头

322 阅读5分钟

在java的世界中,万物皆可用对象来描述,而一个对象在jvm中又是由以下几个部分组成:

  1. 对象头
  2. 实例数据
  3. 对其填充

结构如图所示 lo5JFx79sW.jpg 为了能够更清楚的了解对象头,我们可以借助一个java工具,用来打印对象头,用maven引入jol-core,版本我用的0.12版本,差异影响不是很大

        <dependency>
            <groupId>org.openjdk.jol</groupId>
            <artifactId>jol-core</artifactId>
            <version>0.12</version>
        </dependency>
  • 对象头是什么

对象头,是用来描述java对象一些抽象信息的公共结构,例如如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等

UnH372RlyV.jpg

根据openjdk的官方文档对象头由两部分组成:

  • mark word(在64位jvm下该部分由8byte组成)
  • klass pointer(在64位jvm下,如果开启了指针压缩(jvm默认开启了)该部分由4byte组成,如果关闭了指针压缩,该部分由8byte组成)

mark word是什么

在64位jvm下,markword会根据不同的锁的状态,呈现不同的存储形态,如下图 image.png

最后三个bit比较重要,根据这三个bit可以划分出以下几种状态,下面会通过代码展现各种状态的mark work(由于jvm虚拟机默认开启了4秒钟的延迟偏向,所以在以下实验中需要将延迟偏向时间设置为0(添加jvm参数-XX:BiasedLockingStartupDelay=0),不然不会出现想要的结果

  • 101无锁可偏向
    • 如图所示最后3位为101,代表处于无锁可偏向状态
    • 3uuEbJKSpm.jpg
  • 001无锁不可偏向
    • 偏向锁依赖线程id,但是hashcode和线程id共用的一份存储空间,如果计算过对象的hashcode,那么将不会再有偏向锁的过程,如图所示,最后3位已经变成了001,此时是无锁状态,但是已经不能偏向了,并且可以看到打印出来的hashcode和对象头中存储的hashcode是一样的
    • TThM0VcvMm.jpg
  • 00轻量级锁
    • A线程执行完 B线程抢到锁,两个线程交替抢到锁,此时锁的状态就是轻量级锁。锁的状态为00
public static void main(String[] args) throws InterruptedException {
    User user = new User();
    //线程1
    new Thread(()->{
        synchronized (user){
            System.out.println(ClassLayout.parseInstance(user).toPrintable());
        }
    }).start();
    Thread.sleep(5000);
    //线程2
    //必须要有个线程一直存活,这样第3个线程的线程id和第一个线程的id才会不一样,
    //线程的id不一样才会触发轻量级锁,如果线程的id一样,那么锁的状态就还是偏向锁
    new Thread(()->{
        for (;;){
            try {
                Thread.sleep(30000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }).start();
    Thread.sleep(1000);
    //线程3
    new Thread(()->{
        synchronized (user){
            System.out.println(ClassLayout.parseInstance(user).toPrintable());
        }

    }).start();
}

打印一下对象头

image.png 可以看到刚开始锁的状态是无锁可偏向状态(101),第一个线程释放锁后第二个线程抢到锁的时候就变成了轻量级锁(00)

  • 10重量级锁
    • 以下代码模拟两个线程竞争锁,从打印的结果来看,第二个线程加入竞争之后,锁的状态由无锁可偏向(101)变为重量级锁(010)
public static void main(String[] args) throws InterruptedException {
    User user = new User();
    new Thread(()->{
        synchronized (user){
            for (;;){
                System.out.println(ClassLayout.parseInstance(user).toPrintable());
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
        }

    }).start();
    Thread.sleep(2000);
    new Thread(()->{
        synchronized (user){

        }

    }).start();
}

打印一下对象头 image.png

  • 11被gc标记
    • 跟垃圾收集器有关

klass pointer是什么

Klass Pointer是一个指向方法区中Class信息的指针,虚拟机通过这个指针确定该对象属于哪个类的实例。在64位的JVM中,支持指针压缩功能,根据是否开启指针压缩,Klass Pointer占用的大小将会不同,如果开启了指针压缩(jvm默认开启),那么将会占用4个字节,如果关闭了指针压缩,那么将会占用8个字节,可以根据jvm参数

-XX:(+-)UseCompressedOops来开启或者关闭指针压缩 我们可以打印对象的对象头,来看到klass pointer占4个字节

image.png 关闭指针压缩之后再来打印

image.png 可以看到klass pointer已经变成了8个字节

  • 实例数据

实例数据大小常常是不固定的,用户可以定义不同的字段,导致不同的实例数据大小,我们可以根据jol-core来看一个对象的实例数据的大小

定义一个类User


public class User {
    
    private int age;

}

打印User对象的对象头

public static void main(String[] args) {
    User user = new User();
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
}

image.png

该部分就是user对象的实例数据部分,由于User类中只有一个int的字段,所以该实例数据部分只有4个字节

  • 对其填充

在jvm规范中,要求一个对象所占用到的内存是8bit的倍数,所以当不足8bit的整数倍时,会有一部分对其填充的部分。 在User中添加一个flag字段,再次打印对象头


public class User {
    
    private int age;
    private boolean flag;

}

打印user对象的对象头

public static void main(String[] args) {
    User user = new User();
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
}

image.png

可以看到图中的1代表新增的boolean实例数据,大小是1个bit,因为jvm要求是8bit的倍数,所以在2处有一个7个bit的对齐填充