Java对象的内存布局与压缩指针原理

Java对象在内存中的结构

一个Java对象,依据Hotspot的实现来讲,分为三块区域:对象头,实例数据,对齐填充块,如下图
object
首先来认识下对象头

对象头(Header)

对象头由两部分组成:一部分是Markword,另一部分是类型指针;

Markword在32位操作系统中占用4字节,在64位操作系统中占用8字节。

下图是Markword在32位系统中的存储示例:
markword
从图中可见,在32位地址的Markword中,前25位是对象的HashCode,后4位是对象的分代年龄,后2位是锁标志位,最后一位固定为0
HashCode类似于对象的ID,通过Hash算法生成,常用equals()比较对象是否相等;
分代年龄是指该对象经历了多少次垃圾回收,默认情况下,一个对象在新生代中经历15次垃圾回收(分代年龄>15),仍然存活的话,便会进入老年代;
锁标志位是JVM用来识别该对象是否被上锁,以及锁的级别(JVM根据锁膨胀过程会有偏向锁,轻量级锁和重量级锁三个等级);

考虑JVM的空间效率,Markword的结构是动态的,会根据对象的不同状态而变化(被锁定,被垃圾回收等)

类型指针(Class Pointer)记录的是该对象类型在MetaSpace的地址引用
比如new JavaObject()这个对象,类型指针记录的就是JavaObject.class的地址引用
类型指针占用的内存大小分两种情况,当开启对象压缩时占用4字节(JVM默认开启),关闭时占用8字节

关闭压缩指针参数:-XX:-UseCompressedOops

实例数据(Instance Data)

这一块就是实例数据的大小,举个例子来说明,我们定义一个叫JavaObject的对象如下

public class JavaObject {
    private int i;
    private long l;
    private Object obj;
    private List list;
}

这个对象中定义了两个基本类型,两个引用类型,我们知道int类型占4字节,long类型8字节,引用类型默认是占4字节,关闭指针压缩后占8字节。
所以JavaObject对象的实例数据部分在开启指针压缩的内存占用是20字节

对齐填充(Padding)

这个部分存在的目的是为了保持对象的大小与8字节的倍数对齐
假如一个对象占用12字节,12不是8的倍数,则需要填充4字节,16刚好是8的倍数,那么这块区域就会用0进行填充;
如果对象大小刚好等于8的倍数,如16,32等,则该区域大小为0。

下图中每一行代表一个对象,形象说明了Padding的作用
padding
上图中为了展示Padding的填充效果,把对象换行了,实际上对象在内存中的存储是连续的,也就是内存地址是连续的,如下图所示
object_memory
这里可能会有疑问,为什么要进行8字节内存对齐?

内存对齐

原因之一,在默认情况下,JVM堆中的对象默认要对齐8字节倍数,可以通过参数-XX:ObjectAlignmentInBytes修改

还有一个原因,是由于CPU进行内存访问时,一次寻址的指针大小是8字节,正好也是L1缓存行的大小;如果不进行内存对齐,则可能出现跨缓存行的情况,这叫做缓存行污染,如图所示:
cache
之所以叫做“污染”,是由于当obj1对象的字段被修改后,那么CPU在访问obj2对象时,必须将其重新加载到缓存行,因此影响了程序执行效率

使用JOL查看对象内存布局

Maven依赖

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

这里依然使用上面的JavaObject对象作为示例

public class JavaObject {
    private int i;
    private long l;
    private Object obj;
    private List list;
}

使用ClassLayout打印对象

ClassLayout classLayout = ClassLayout.parseInstance(new JavaObject());
System.out.println(classLayout.toPrintable());

可以在控制台中看到,当开启压缩指针(默认)时的内存布局如下

 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           05 00 00 00 (00000101 00000000 00000000 00000000) (5)
      4     4                    (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4                    (object header)                           08 cb 16 00 (00001000 11001011 00010110 00000000) (1493768)
     12     4                int JavaObject.i                              0
     16     8               long JavaObject.l                              0
     24     4   java.lang.Object JavaObject.obj                            (object)
     28     4     java.util.List JavaObject.list                           (object)
Instance size: 32 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

OFFSET是偏移量,SIZE是占用的字节数,TYPE是类型描述,VALUE是这个类型具体的值(详见下图)。

VALUE

可以看出,Instance size: 32 bytes即表示该对象占用32字节,其中对象头(object header)占用12字节,实例数据部分占用20字节,由于32为8的倍数,所以没有对齐填充(Padding)部分。

当关闭压缩指针(-XX:-UseCompressedOops)时,对象内存布局如下

 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           05 00 00 00 (00000101 00000000 00000000 00000000) (5)
      4     4                    (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4                    (object header)                           d8 c6 c0 33 (11011000 11000110 11000000 00110011) (868271832)
     12     4                    (object header)                           02 00 00 00 (00000010 00000000 00000000 00000000) (2)
     16     8               long JavaObject.l                              0
     24     4                int JavaObject.i                              0
     28     4                    (alignment/padding gap)                  
     32     8   java.lang.Object JavaObject.obj                            (object)
     40     8     java.util.List JavaObject.list                           (object)
Instance size: 48 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total

Space losses记录了对齐所用的字节数

通过对比可以发现,关闭压缩指针后,在OFFSET=28的位置,有4字节的padding gap,之所以padding会出现在中间部分,是因为接下来的字段类型都是引用类型,而OFFSET(0~28)的位置占用28字节,所以这里需要补4字节。

JVM会优先将内存大小相同的字段排列在一起,所以即使将对象的字段按照如下排列,JVM依然会将基本类型与引用类型分开排列,这叫做字段重排列

private int i;
private Object obj = new Object();
private long l;
private List list = new ArrayList();

为了演示Padding补齐在对象尾部的例子,需要将JavaObject作如下调整,只保留一个int字段

public class JavaObject {
    private int i;
}

再次打印信息如下

 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 00 00 00 (00000101 00000000 00000000 00000000) (5)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           28 06 8e 12 (00101000 00000110 10001110 00010010) (311297576)
     12     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
     16     4    int JavaObject.i                              0
     20     4        (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

loss due to the next object alignment直译过来:“由于下一个对象的基准线而丢失”,其实就是为了内存对齐。


压缩指针原理

首先需要明确的是,压缩指针不仅可以作用于对象头的类型指针,还可以作用于引用类型的字段,以及引用类型的数组。

什么是压缩指针呢?
在64位操作系统中,对象头中的类型指针占用64位(8字节),开启压缩指针后占用32位(4字节),压缩指针的目的即节省内存空间

如何进行压缩

我们把内存空间抽象成下面这幅图,每一个带有数字的格子用来指代一块实际内存,那么下图抽象表示了8块连续等长的内存地址

mem
在关闭压缩指针时,指针记录的地址就是实际的内存地址;
而开启压缩指针时,指针记录的是内存地址上的那个对象编号;
reference
如此以来,4字节(32位)的引用,能够表示的最大数为 2 32 2^{32} ,即最多能表示 2 32 2^{32} 个对象;

但是又该如何得到对象的实际地址呢?
这里就需要基于对象内存对齐了,由于对象是内存对齐的,那么对象占用的内存就可以通过一个偏移量算出,比如上图中,要计算对象三的内存地址,已知前面有两个对象,每个对象占用2个格子,即对象三的地址就是2 * 2 + 1 = 5

当然,不是所有的对象都占用2个格子,实际运用中偏移量的计算并非这么简单。

内存扩容

上面,我们已经知道了指针压缩的过程,可能会发现一个新问题
2 32 2^{32} 所能表示的最大内存只有4GB,而4GB空间在现在看来是非常小了,于是我们需要扩容

如何扩容呢?
我们可以把指针左移3位, 2 32 < < 3 = 2 35 2^{32}<<3=2^{35} 2 35 2^{35} 能表示的最大内存就是32GB了
这也是为什么,当内存大于32GB时,开启指针压缩的参数会失效

扩容过后,在计算对象内存地址时,需要先将引用左移3位,然后加上偏移量,就能得到实际内存地址了。


总结

至此,本文介绍了Java对象在内存中的存储结构,了解了一个对象是由对象头、实例数据、对齐填充三部分组成;虚拟机会按照字段占用的空间大小,对字段进行重排列;
接着,介绍了为了节省内存空间,虚拟机可以对指针进行压缩,了解了指针压缩的原理,以及压缩后的内存空间问题如何解决。

另外,若文中有不当或者有误之出,欢迎及时指正!

猜你喜欢

转载自blog.csdn.net/yasin_huang/article/details/106845721