写点什么

从对象内存布局了解锁的膨胀

作者:Ayue、
  • 2021 年 12 月 19 日
  • 本文字数:4492 字

    阅读完需:约 15 分钟

从对象内存布局了解锁的膨胀

对象内存布局

其实之前在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)的内存占用如下:



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)**两部分:



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


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



把它转化为下面的表格:



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()); }}
复制代码


运行结果:



从结果可以看到,一个对象的大小为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;}
复制代码


测试结果如下:



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



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


什么是大小端模式?


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



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



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



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


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

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


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


高低字节:在十进制中我们都说靠左边的是高位,靠右边的是低位,在其他进制也是如此。如 0x12345678,从高位到低位的字节依次是0x12、0x34、0x56和0x78。
高低地址:由高到低0x0000001 -> 0x0000002-> ... -> 0x0000092
复制代码


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



上图是没有计算 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()); }}
复制代码


测试结果如下:



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


对象锁的膨胀

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


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

无锁状态

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

偏向锁

/** * 演示偏向锁 * 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()); } }}
复制代码


测试结果:


轻量级锁

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()); }}
复制代码


测试结果:


重量级锁

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()); } }}
复制代码


测试结果:


总结

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

发布于: 3 小时前阅读数: 6
用户头像

Ayue、

关注

个人站点:javatv.net 2019.10.16 加入

学习知识,目光坚毅

评论

发布
暂无评论
从对象内存布局了解锁的膨胀