JAVA对象头之锁的膨胀

对象内存布局

其实之前在JVM中的对象一文中讲到了对象在JVM中的布局,其中就包括对象头的信息。不了解的可以先看看前面的文章。

根据java虚拟机规范里面的描述:java对象分为三部分:对象头(Object Header), 实例数据(instance data),对齐填充(padding)。

对象头

HotSpot 虚拟机的对象头主要包括两部分(若是数组对象还包括一个数组的长度)信息,对象头在32位系统上占用8bytes,64位系统上占用16bytes(开启压缩指针)。

  • Mark Word,主要存储哈希码(HashCode)、GC 分代年龄、锁状态标识、线程持有的锁、偏向线程 ID、偏向时间戳。
  • 类型指针(klass pointer),即对象指向它的类元数据的指针(即存在于方法区的Class类信息),虚拟机通过这个指针来确定这个对象是哪个类的实例。
  • 数组长度,如果对象是一个数组,那么在对象头中还有一块用于记录数组长度的数据(这里不考虑)。

实例数据

实例数据部分是对象真正存储的有效信息(也就是被new出来的对象信息),也是在程序代码中所定义的各种类型的字段内容。原生类型(primitive type)的内存占用如下:

image-20210914085902311

reference类型在32位系统上每个占用4bytes,在64位系统上每个占用8bytes。

对齐填充

对齐填充不是必然存在的,没有特别的含义,它仅起到占位符的作用。由于 HotSpot VM 的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,也就是说对象的大小必须是 8 字节的整数倍(这是个规定)。对象头部分是 8 字节的倍数,所以当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

HotSpot中的对象

HotSpot对对象头的定义为:http://openjdk.java.net/groups/hotspot/docs/HotSpotGlossary.html

Common structure at the beginning of every GC-managed heap object. (Every oop points to an object header.) Includes fundamental information about the heap object’s layout, type, GC state, synchronization state, and identity hash code. Consists of two words. In arrays it is immediately followed by a length field. Note that both Java objects and VM-internal objects have a common object header format.

谷歌翻译:

每个 GC 管理的堆对象开头的公共结构。 (每个 oop 都指向一个对象头。)包括关于堆对象的布局、类型、GC 状态、同步状态和身份哈希码的基本信息。 由两个词组成。 在数组中,它紧跟一个长度字段。 请注意,Java 对象和 VM 内部对象都有一个共同的对象头格式。

因此,HotSpot 虚拟机的对象头主要包括Mark Word和**类型指针(klass pointer)**两部分:

image-20210914093243514

而对于Mark Word的大小在 64 位的 HotSpot 虚拟机中 markOop.cpp 中有很好的注释,其大小为64 bits,而klass pointer在开启压缩指针的情况下为32 bits

PS:1byte = 8bit,即1字节为8位

image-20210913225358187

把它转化为下面的表格:

image-20210914150807934

PS:我们知道在处理并发的情况时,一般都是通过加锁来保证线程的安全,例如synchronized,而这个锁其实就是给对象头中锁状态标识,所以这篇文章不仅仅是解释对象头是什么,同时也为了解synchronized的实现奠定基础。

通过上面的表格可以看到Java的对象头在对象的不同状态下会有不同的表现形式,主要有三种状态:无锁状态、加锁状态、gc标记状态。那么我可以理解Java当中的锁其实可以理解是给对象上锁,也就是改变对象头的状态,如果上锁成功则进入同步代码块(synchronized)。

但是Java当中的锁有分为很多种,从上图可以看出大体分为偏向锁、轻量锁、重量锁三种锁状态。这三种锁的效率完全不同,我们只有合理的设计代码,才能合理的利用锁、那么这三种锁的原理是什么?所以我们需要先研究这个对象头。

JOL分析对象布局

一个对象在JVM中到底占用多少内存呢?如Object obj = new Object()占多少字节?

我们可以通过JOL(Java Object Layout)工具来查看 new 出来的一个 java 对象的内部布局,以及一个普通的 java 对象占用多少字节。

:以下测试都是开启压缩指针的情况,默认开启。

引入依赖:

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

1、新建一个类 A,里面不包括任何的实例数据

public class A {

}

2、测试

public class JOLTest {

    static A a = new A();

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

运行结果:

image-20210914101202781

从结果可以看到,一个对象的大小为16 bytes,其中object header对象头占用12 bytes,还有4 bytes是对齐的字节(因为在64位虚拟机上对象的大小必须是 8 的倍数,也就是对齐填充),由于这个对象里面没有任何字段,故而对象的实例数据为 0 。由此可以引出两个问题:

  1. 什么是实例数据?
  2. object header12 bytes存的是什么?

第一个问题,实例数据是被 new 出来的对象信息,在 A 中添加一个 boolean 类型字段,我们知道 boolean 在内存中占用1 byte(基础数据类型 boolean 的 0 值为 false):

public class A {
    boolean b;
}

测试结果如下:

image-20210914103409983

尽管我们添加了一个 boolea 类型的实例数据,但我们看到整个对象的大小仍是16 bytes,其中对象头12 bytes,boolean 字段 b(对象的实例数据)占 1 byte、剩下的3 bytes就是对齐填充数据。当然,如果你定义一个 int 类型字段,那就刚好 4 字节,无需填充。由此我们可以认为一个对象的布局大体分为三个部分分别是对象头(Object header)、对象的实例数据和对齐填充。

image-20210914104324374

第二个问题object header(对象头)中的12 bytes存的是什么?在这之前我们先来了解什么是大小端模式

什么是大小端模式?

在 JVM 的定义中已经知道对象头的8 bytesMark Word4 bytesklass pointer

image-20210914105755517

而且在Mark Word中的有一个锁状态标识,在初始new的情况下,默认是无锁的,即在无锁无hash的情况下,Mark Word的存储内容为:

image-20210914105914390

但实际对比发现,在前 8 位就明显已经使用了:00000001

image-20210914111818570

这难道是JVM的bug吗?当然不是。在计算机系统中,我们是以字节为单位存放数据的,每个地址单元都对应着一个字节,1个字节为 8 位。但在C语言中存在不同的数据类型,占用的字节数也各不相同,那么就存在怎样存放多个字节的问题,因此就出现了大端存储模式和小端存储模式。

  • 大端(存储)模式:即高字节存在低地址,低字节存在高地址。
  • 小端(存储)模式:即高字节存在高地址,低字节存在低地址。

这里解释一下什么是高低字节和高低地址:

高低字节:在十进制中我们都说靠左边的是高位,靠右边的是低位,在其他进制也是如此。如 0x12345678,从高位到低位的字节依次是0x12、0x34、0x56和0x78。

高低地址:由高到低0x0000001 -> 0x0000002-> ...  -> 0x0000092

而在这里对应的值是从后往前对应的,因为是小端存储,所以我们打印刚好是反着来的。

image-20210914152740456

上图是没有计算 hash 的情况,如果计算 hash :

public class JOLTest {

    static A a = new A();
    
    public static void main(String[] args) {
        System.out.println("---hashcode before---");
        System.out.println(ClassLayout.parseInstance(a).toPrintable());
        //转化成16进制,方便比较
        System.out.println("对象的hashcode值:" + Integer.toHexString(a.hashCode()));
        System.out.println("   ");
        System.out.println("----hashcode after---");
        //计算完hashcode之后的a对象的布局
        System.out.println(ClassLayout.parseInstance(a).toPrintable());
    }
}

测试结果如下:image-20210914154740035

因此,没有进行 hashcode 之前的对象头信息,可以看到2b-8b之前的的56bit是没有值,打印完hashcode之后行就有值了,为什么是2-8B,不应该是1-7B呢?主要原因就是因为小端存储。第一个字节当中的八位分别存的就是分带年龄、偏向锁信息,和对象状态,这个8 bits分别表示的信息如下图,这个图会随着对象状态改变而改变,下图是无锁状态下:

image-20210914155209836

对象锁的膨胀

一个对象的状态不会一成不变,随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。

:这里只看结果,不分析具体的实现,所以不要问为什么,怎么升级的,后续文章会讲。

无锁状态

无锁状态就是上面分析的情况。

偏向锁

/**
 * 演示偏向锁
 * jdk6默认开启偏向锁,但是是输入延时开启,也就是说:
 * 程序刚启动创建的对象是不会开启偏向锁的,几秒后后创建的对象才会开启偏向锁
 * 可以通过参数关闭延迟开启偏向锁
 * VM:-XX:BiasedLockingStartupDelay=0
 */
public class BiasedLockTest {

    static A a = new A();

    public static void main(String[] args) {
        synchronized (a) {
            System.out.println(ClassLayout.parseInstance(a).toPrintable());
        }
    }
}

测试结果:

image-20210914170008860

轻量级锁

public class LightWeightLockTest {

    static A a = new A();

    public static void main(String[] args) {
        System.out.println("befre lock");
        System.out.println(ClassLayout.parseInstance(a).toPrintable());
        synchronized (a) {
            System.out.println("befre ing");
            System.out.println(ClassLayout.parseInstance(a).toPrintable());
        }
        System.out.println("after lock");
        System.out.println(ClassLayout.parseInstance(a).toPrintable());
    }
}

测试结果:

image-20210914171422729

重量级锁

public class HeavyWeightLockTest {

    static A a = new A();

    public static void main(String[] args) throws Exception {
        System.out.println("befre lock");
        System.out.println(ClassLayout.parseInstance(a).toPrintable());
        Thread t1 = new Thread() {
            @Override
            public void run() {
                synchronized (a) {
                    try {
                        Thread.sleep(5000);
                        System.out.println("name:" + Thread.currentThread().getName());
                        System.out.println(ClassLayout.parseInstance(a).toPrintable());
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };

        t1.setName("t1");
        t1.start();
        System.out.println("name:t1");
        System.out.println(ClassLayout.parseInstance(a).toPrintable());//轻量锁
        lock();
    }

    /**
     * 资源竞争---mutex  重量锁
     */
    public static void lock() {
        synchronized (a) {//t1 locked  t2 ctlock
            System.out.println("name:" + Thread.currentThread().getName());
            System.out.println(ClassLayout.parseInstance(a).toPrintable());
        }
    }
}

测试结果:

image-20210914180416747

总结

本文主要是讲对象头中的信息,附带讲了一下锁的膨胀,但对于锁的膨胀并没有深入,其实锁的膨胀主要就是synchronized的实现,后续会讲到。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

汪了个王

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值