Java对象内存布局概述

以HotSpot虚拟机为例,对象在内存中可以分为三块区域:对象头实例数据对齐填充。其中,对象头包含Mark Word和类型指针,关于对象头的内容,在gitchat中对其实现和原理都已经结合openjdk源码进行了详细的说明,其也不是本博文的主题,这里就不细说了;实例数据部分则是对象真正存储的有效信息,包含代码中所定义的字段内容;对齐填充则不是必须存在的,只是起占位符的作用,比如Hot Spot虚拟机要求对象大小必须是8字节的整数倍,而对象头刚好是8字节的倍数,所以当对象的实例数据没有对齐时,就需要通过对齐填充来补全。

注:关于类型指针,虚拟机可以通过这个它来确认该对象的元数据信息,比如它属于哪个类的实例。但是我们要注意,并不是所有的虚拟机都必须以这种方式来确定对象的元数据信息。对象的访问定位一般有句柄直接指针两种,如果使用句柄的话,那么对象的元数据信息可以直接包含在句柄中(当然也包括对象实例数据的地址信息),也就没必要将这些元数据和实例数据存储在一起了。至于实例数据和对齐填充,这里暂不做讨论。

一个对象字段既包括自身定义的,也包括从父类继承下来的,这些字段会按照顺序存储下来。而具体的存储顺序会受到虚拟机分配策略参数和字段在代码中定义顺序的影响。默认为longs/doubles、ints、shorts/chars、bytes/booleans、references,可以看到,相同宽度的字段总是被分配到一起。在满足整个前提瞧见的情况下,父类中的字段会出现在子类之前。如果开启了CompactFields,那么子类中较窄的字段可能会插入到父类字段的空隙之中。

简单来说就是,大字段在前,小字段在后,references最后,同大小看声明顺序,然后在考虑父类和CompactFields的情况。其中对于reference类型字段的位置,JVM有个参数FieldsAllocationStyle控制,其取不同的值会有不同的策略:

  •  0:references, longs/doubles, ints, shorts/chars, bytes, 对齐填充

  •  1:longs/doubles, ints, shorts/chars, bytes, references, 对齐填充

  •  2:使父类reference字段和子类reference字段挨在一起

对一个对象,我们需要对其在内存中的存储有个大致的想象:这块内存空间是如何分配的。我们以64位系统举例,假设现在有以下类Test,其包含两个字段,一个int类型的value1和一个long类型的value2:

public class Test {
    private long value2;
    private int value1;
}

首先我们考虑对象头,64位系统中,Mark Word占8个字节;开启了压缩指针,类型指针占4个字节。那么整个对象头就占了12个字节。

接下来是实例数据,value1是int类型,占4个字节,vaue2为long类型,占8个字节。这个情况下,整个对象占12+4+8=24,正好是8的整数倍,所以不需要对齐填充。那么按照我们前面介绍的分配顺序,对象头后面应该要紧跟value2才对。但是这样的话可能会有个问题:对象头占12个字节,64位系统中一次能读取8个字节的内容,那么读取了对象头的8字节之后,下一次读取的内容其实是包含对象头剩余的4字节和value2其中的4个字节的,value2剩余的4个字节要在下一次才能读取。而我们的value1正好占4个字节,所以将其放到对象头之后的话就正正好。所以最终的结果就是:对象头->value1->value2。接下来我们通过代码验证:

public class Test {
    private long value2;
    private int value1;

    public static void main(String[] args) throws Exception {
        Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
        unsafeField.setAccessible(true);
        Unsafe theUnsafe = (Unsafe) unsafeField.get(null);
        long offset1 = theUnsafe.objectFieldOffset(Test.class.getDeclaredField("value1"));
        long offset2 = theUnsafe.objectFieldOffset(Test.class.getDeclaredField("value2"));
        System.out.println("size:" + RamUsageEstimator.shallowSizeOf(new Test()));
        System.out.println("offset of value1:" + offset1);
        System.out.println("offset of value2:" + offset2);
    }
}

注:由于博主的项目中有使用Lucene,所以直接使用Lucene提供的RamUsageEstimator获取对象大小,读者朋友可以尝试使用Instrumentation获取。

上述代码的输出结果为:

size:24
offset of value1:12
offset of value2:16

我们可以看到,总大小是24个字节。其中对象头占12字节,value1占4字节,value2占8字节,没有对齐填充。并且value1的offset为12,说明其是紧跟对象头之后的;value2的offset为16(12+4),其紧跟value1之后。这块内存看起来像是下面这样:

如果我们关闭指针压缩,添加JVM启动参数-XX:-UseCompressedOops,再看看输出结果:

size:32
offset of value1:24
offset of value2:16

我们看到,总大小变成了32字节,value1和value2的offset也随之发生了变化。由于关闭了指针压缩,类型指针占8字节,这样对象头就占16字节,总大小成了28字节,但是不是8的正数倍,所以需要4个字节的对齐填充,最终大小成了32个字节。并且value2排在了value1的前面。

注:FieldsAllocationStyle和CompactFields参数对诸如Long.class、Integer.class、Class.class等是无效的,因为这些类字段的offset被硬编码指定了。JVM对class文件的解析主要由classFileParser#parseClassFile方法负责,而对象字段的内存布局主要由classFileParser#layout_fields方法处理,感兴趣的同学如果时间充足可以去研究研究。

猜你喜欢

转载自blog.csdn.net/huangzhilin2015/article/details/101177223