JVM中的对象探秘(三)- 对象的实例数据与对齐填充

引言

上一篇文章我们讲解了JVM对象内存布局的第一部分对象头,今天我们继续来讲讲剩下的两部分实例数据(Instance Data) 、对齐填充(Padding)。

实例数据与对齐填充

这两部分我们放在一起将,先来看一下概念(深入理解JAVA虚拟机 第2.3.2节):

实例数据(Instance Data):

实例数据部分是对象真正存储的有效信息,也既是我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的都需要记录下来。 这部分的存储顺序会受到虚拟机分配策略参数FieldsAllocationStyle和字段在Java源码中定义顺序的影响。HotSpot虚拟机 默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),从分配策略中可以看出,相同宽度的字段总是被分配到一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果 CompactFields参数值为true(默认为true),那子类之中较窄的变量也可能会插入到父类变量的空隙之中。

对齐填充(Padding):

对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。对象头正好是8字节的倍数(1倍或者2倍),因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

上面其实说的也很明白,实例数据就是我们所赋予给对象的那些属性信息,这部分内容是紧挨着对象头(Header)存储的,这没啥好说的;所以我们今天要研究的就是实例数据中的数据是否是按照我们在类中定义的那样排序的,每部分数据所占用的大小是怎样的,虚拟机分配策略又是什么以及怎么样根据定义的类来估算一个对象的大小。

代码验证

咱们直接用代码配合启动参数的修改再结合输出结果来解答上述问题。

先把老朋友Person类抬上来,接下来因为要频繁修改属性定义,写Getter和Setter太麻烦了,我就直接用了lombok。

		<!-- 除了pom引入依赖外还需要安装插件,请自行百度 -->
		<dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>RELEASE</version>
        </dependency>
package com.cjf.test;

import lombok.Data;
import org.openjdk.jol.info.ClassLayout;

/**
 * @author Chenjf
 * @date 2020/8/6
 **/
@Data
public class Person {
    private Person children;
    private String name;
    private Integer age;
    private String address;
    private Boolean man;

    
    public static void printf(Person p) {
        // 查看对象的整体结构信息
        //JOL工具类
        System.out.println(ClassLayout.parseInstance(p).toPrintable());
    }

}

先直接输出一波:

    public static void main(String[] args) {
        Person person = new Person();
        //输出对象内存地址
        System.out.println(GraphLayout.parseInstance(person).toPrintable());
        //打印对象布局信息
        printf(person);
    }

输出结果:
在这里插入图片描述

从图中可以看到,偏移量为12,大小为4字节的位置存放着Person类中children属性的指针。

引申问题
为什么是指针而不是具体的数据?

我说一句你就懂了,如果另一个Person的children和这个对象的children是相同的呢,如果存具体数据的话是不是就得存两份,完全没意义啊。所以这里肯定存的是指针,只要能定位到具体的数据就可以了,这样无论哪个对象引用了另一个对象,该对象的实例数据中只要保存指向那个对象的指针即可。另外我们可以看到,实例数据中的Size清一色的都是4,在64位系统下光一个对象头就占12字节了(开启了指针压缩),所以这里必然存的是指向另一个对象的指针。

那为什么Size是4不是5不是6不是其它的呢?

刚才说了实例数据中children属性存的是指向这个children对象的指针;那不妨大胆猜测一下,这个指针究竟是什么?我本人猜测存的就是内存地址,但不是绝对地址是相对地址,具体如何定位的我们也无需去关心,JVM拿到这串地址之后,通过简单运算之后可以定位到就行了。当然我只是猜的,因为这部分数据JOL它没输出给我,我从hsdb中也看不到。既然我猜测是地址,那么就顺着这个思路去猜。

32位处理器,计算机中的位数指的是CPU一次能处理的最大位数。32位计算机的CPU一次最多能处理32位数据,例如它的EAX寄存器就是32位的,当然32位计算机通常也可以处理16位和8位数据。在Intel由16位的286升级到386的时候,为了和16位系统兼容,它先推出的是386SX,这种CPU内部预算为32位,外部数据传输为16位。直到386DX以后,所有的CPU在内部和外部都是32位的了。

在计算机中,“位(bit)”和"字节(Byte)"、KB、MB以及TB的关系是:8位等于一字节,即8bit=1Byte,1KB=1024Byte(字节)=8*1024bit,1MB=1024KB,1GB=1024MB,1TB=1024GB 。

32位处理器每次处理 4Byte(32bit),同理,64位处理器每次处理 8Byte(64bit) 。

32位处理器每次能处理4字节的单位,所以这个指针存的就是4字节的内存地址(相对地址),这就是为什么Size是4而不是其它的原因。

可是我的机器是64位的啊,为什么是4而不是8?

还记得之前总提到的指针压缩吗?因为在JDK1.6之后,JVM在64位操作系统中默认开启了指针压缩技术(堆内存小于4G时无需开启,直接舍弃高位用地位,堆内存大于32G时指针压缩无效)。那么我们把指针压缩关掉再测试一下,实例数据中的Size果然都变成了8。

-XX:-UseCompressedOops

输出结果如下:

在这里插入图片描述

为什么要开启指针压缩呢?

以下内容摘自 JVM压缩指针

在堆中,32位的对象引用(指针)占4个字节,而64位的对象引用占8个字节。也就是说,64位的对象引用大小是32位的2倍。64位JVM在支持更大堆的同时,由于对象引用变大却带来了性能问题。

  • 增加了GC开销:64位对象引用需要占用更多的堆空间,留给其他数据的空间将会减少,从而加快了GC的发生,更频繁的进行GC。

  • 降低CPU缓存命中率:64位对象引用增大了,CPU能缓存的oop将会更少,从而降低了CPU缓存的效率。

为了能够保持32位的性能,oop必须保留32位。那么,如何用32位oop来引用更大的堆内存呢?

答案是——压缩指针(CompressedOops)。

什么是oop

OOP = “ordinary object pointer” 普通对象指针。 启用CompressOops后,会压缩的对象:

1. 每个Class的属性指针(静态成员变量)
    2. 每个对象的属性指针
    3. 普通对象数组的每个元素指针

当然,压缩也不是万能的,针对一些特殊类型的指针,JVM是不会优化的。 比如指向 PermGen的Class 对象指针,本地变量,堆栈元素,入参,返回值,NULL指针不会被压缩。

那对象的引用为4字节,是不是代表最大内存也被限制在了(2^32) 4G呢?

答案不是。

当采用4字节表示引用时, 直观来看是表示4G bytes大小的空间, 但是, 由于对象分配时是8字节对齐的(后面会讲到,即使引用为4字节也是按8字节对齐,所以就有了对齐填充), 也就是对象指针的低3bit是0, 因此可以把这3bit压缩掉, 实际32bit的可以表示4G * 8 bytes = 32G bytes的内存空间, 对于大部分服务来说足够了。因此堆内存小于32G时, 指针压缩默认开,超过32G,指针压缩就不生效了。

回到我们的代码验证,现在已经知道了如果是对象类型,那么实例数据中存放的即为这个对象的引用;那么如果是基本数据类型存放的是什么呢?是具体的数据,一起来验证一下。

修改Person类的定义,如下:

package com.cjf.test;

import lombok.Data;
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.info.GraphLayout;
import org.openjdk.jol.vm.VM;

/**
 * @author Chenjf
 * @date 2020/8/6
 **/
@Data
public class Person {
    private Person children;
    private String name;
    private long height;
    private int age;
    private String address;
    private boolean man;


    public static void printf(Person p) {
        // 查看对象的整体结构信息
        //JOL工具类
        System.out.println(ClassLayout.parseInstance(p).toPrintable());
    }

}

测试代码:

 public static void main(String[] args) {
        Person person = new Person();
        person.setName("小陈");
        person.setAge(18);
        Person children = new Person();
        children.setName("小小陈");
        children.setAge(1);
        person.setChildren(children);

        System.out.println(Integer.toHexString((int) VM.current().addressOf(person)));
        //输出对象内存地址
        System.out.println(GraphLayout.parseInstance(person).toPrintable());
        //打印对象布局信息
        printf(person);
    }

输出结果如下:

在这里插入图片描述

首先后面的value不再只显示一个(Object),而是直接显示了具体的值;其次,我们可以发现Size的大小也不再是清一色的4了。int为4,long为8,boolean为1,是不是很眼熟。

基本数据类型有8种:byte、short、int、long、float、double、boolean、char

计算机的基本单位:bit .  一个bit代表一个0或1   1个字节是8个bit

byte:1byte = 8bit short:2byte int:4byte  long:8byte

​ float:4byte double:8byte boolean:1byte char:2byte

由此可以得知,实例数据中的基本数据类型存放的是具体的值。因此,int 所能表示的范围为-2^31 ~ 2^31-1,即 -2147483648 ~ 2147483647

//JAVA中Intger类的定义
@Native public static final int MIN_VALUE = 0x80000000;
@Native public static final int MAX_VALUE = 0x7fffffff;

然后我们发现在属性man(boolean类型)的下面,还有一串(alignment/padding gap) 大小为3字节,这又是啥嘞?

字面含义是(对齐/空隙填充),没错这个就是对齐填充。还记得我们刚才说的JVM管理内存都是以8字节对齐的吗(64位是这样的,我没有32位机器,无法验证32位下是按照8字节对齐还是4字节对齐,其实按8字节对齐就一定是按4字节对齐,因为8永远是4的整数倍),即对象的大小永远都是8字节的整数倍。

我们知道64位系统CPU每次能处理8字节的数且只能以
0x00000000 - 0x00000007,0x00000008-0x0000000f这样访问内存地址,不能0x00000002 - 0x00000009这样访问(为什么?因为硬件不允许,否则就不需要对齐填充了)。
那么如果没有对齐填充就可能会存在数据跨内存地址区域存储的情况。

举个栗子:

比如现在有4个数据,boolean,int,char,long,内存起始地址为0x00(简写)。

在没有对齐填充的情况下,内存地址存放情况如下:

在这里插入图片描述

因为处理器只能0x00-0x07,0x08-0x0F这样读取数据,所以当我们想获取这个long型的数据时,处理器必须要读两次内存,第一次(0x00-0x07),第二次(0x08-0x0F),然后将两次的结果才能获得真正的数值。

那么在有对齐填充的情况下,内存地址存放情况是这样的:
在这里插入图片描述
现在处理器只需要直接一次读取(0x08-0x0F)的内存地址就可以获得我们想要的数据了。

对齐填充存在的意义就是为了提高CPU访问数据的效率,这是一种以空间换时间的做法;正如我们所见,虽然访问效率提高了(减少了内存访问次数),但是在0x07处产生了1bit的空间浪费。

那么如何才能不浪费掉这1字节的地址空间呢?

试想一下,如果此时又有一个1字节的数据存到内存中,是不是可以把这1字节的数据直接插到0x07的地方,这样就不需要填充也能实现8字节对齐了呢?没错,JVM就是这样做的,因此JVM在为对象分配内存时,对象中的实例数据的排序规则并不是完全按照我们在类中的所定义的顺序来排序的。

回到实例数据的概念:

实例数据部分是对象真正存储的有效信息,也既是我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的都需要记录下来。 这部分的存储顺序会受到虚拟机分配策略参数FieldsAllocationStyle和字段在Java源码中定义顺序的影响。HotSpot虚拟机 默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),从分配策略中可以看出,相同宽度的字段总是被分配到一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果 CompactFields参数值为true(默认为true),那子类之中较窄的变量也可能会插入到父类变量的空隙之中。

虚拟机分配策略的存在能尽可能的帮助我们在对其填充的情况下减少内存地址的空间浪费,下面就展开来讲讲虚拟机分配策略吧。

用Clion打开HotSpot源码,直接搜索一下FieldsAllocationStyle,然后就可以在globals.hpp的第1250行看到下面这个定义(HotSpot在线源码

  product(intx, FieldsAllocationStyle, 1,                                   \
          "0 - type based with oops first, 1 - with oops last, "            \
          "2 - oops in super and sub classes are together")                 \

可以看到这是一个int类型的参数,默认值为1,取值范围为0,1,2代表着三种分配策略。

  • 取值为0时,引用在原始类型前面, 然后依次是longs/doubles、ints、shorts/chars、bytes/booleans;即基本类型->填充字段(可以没有)->引用类型

  • 取值为1时,引用在原始类型后面,即longs/doubles、ints、shorts/chars、bytes/booleans 然后是引用类型;即引用类型->基本类型->填充字段(可以没有)

  • 取值为2时,父类中的引用类型与子类中的引用类型放在一起,此时父类采用策略0,子类采用策略1。

取值0和取值1都是将基本数据类型按照从大到小的方式排序,这样可以降低空间开销。而取值3 将父类和子类的引用放在一起,这样可以增加 GC 效率,试想在GC 扫描引用时,由于父类和子类的引用连续,可能只需要扫描一个内存行即可,若父类和子类的引用不连续,则需要扫描多个内存行;另外连续的内存引用还可减少 OopMap 的个数,从而达到提高 GC 效率的目的。

再回头看看我们刚才的测试结果,排序却是按照age(int)、height(long)、man(boolean)然后是引用类型;基本类型的排序不符合longs/doubles、ints、shorts/chars、bytes/booleans的规则啊。原因很简单,因为对象头占了12字节,还记得我们刚刚说的8字节对齐吗,这里正好可以插入一个4字节的数据,如果long类型排在前面的话,这4字节是不是就浪费了呢,所以JVM就帮我们把age(int)放到了long类型之前(可以通过-XX:+/-CompactFields进行控制,默认开启);

我们关闭指针压缩,来验证一下:

在这里插入图片描述

可以看到结果是long类型按照规则确实排在了int前面。

现在我们再简单验证一下策略0:

//启动参数 开启指针压缩(默认开启)   指定分配策略为0
-XX:+UseCompressedOops -XX:FieldsAllocationStyle=0

测试代码不变:

  public static void main(String[] args) {
        Person person = new Person();
        person.setName("小陈");
        person.setAge(18);
        Person children = new Person();
        children.setName("小小陈");
        children.setAge(1);
        person.setChildren(children);

        System.out.println(Integer.toHexString((int) VM.current().addressOf(person)));
        //输出对象内存地址
        System.out.println(GraphLayout.parseInstance(person).toPrintable());
        //打印对象布局信息
        printf(person);

    }

输出结果如下:

在这里插入图片描述

符合我们的预期结果,引用类型->基本类型->填充字段。

最后来看一看策略2,先改动一下我们的Person类让它继承Biology类。

public class Biology {
    protected String type;
    protected int id;
}

@Data
public class Person extends Biology{
    private Person children;
    private String name;
    private long height;
    private int age;
    private String address;
    private boolean man;


    public static void printf(Person p) {
        // 查看对象的整体结构信息
        //JOL工具类
        System.out.println(ClassLayout.parseInstance(p).toPrintable());
    }

    public static void main(String[] args) {
        Person person = new Person();
        person.setName("小陈");
        person.setAddress("北京");
        person.setAge(18);
        Person children = new Person();
        children.setName("小小陈");
        children.setAge(1);
        person.setChildren(children);

        System.out.println(Integer.toHexString((int) VM.current().addressOf(person)));
        //输出对象内存地址
        System.out.println(GraphLayout.parseInstance(person).toPrintable());
        //打印对象布局信息
        printf(person);
		//没啥用,在这打个断点而已
        System.out.println("zzz");
    }

当指定分配策略为0时,输出结果如下:

在这里插入图片描述

父类引用类型->父类基本类型->子类引用类型->子类基本类型->填充字段。

当指定分配策略为0时,输出结果如下:

在这里插入图片描述

父类基本类型->父类引用类型->子类基本类型->子类引用类型->填充字段。

上述两个结果可以看到,当Person类继承了父类之后,无论使用哪种策略,永远都是父类的属性排在前面,然后才是子类属性,而且父类的引用类型的属性和子类的引用类型的属性是被分隔开的(中间穿插着基本类型)。

因为父类引用和子类引用之间穿插的基本类型,那么就很可能导致原先一个内存行就可以放完引用类型而现在不得不分两个内存行存放的情况,这样gc在扫描时就不得不扫描多个内存行了,于是策略2便诞生了。

当指定分配策略为2时,输出结果如下:

在这里插入图片描述

为了使父类的引用可以和子类的引用连续在一起,父类采用了策略0进行分配,而子类采用了策略1。这样就形成了,父类基本类型->父类引用类型->子类引用类型->子类基本类型的排序关系,一定程度上减少了GC夸内存行扫描的情况,提升了GC效率。

再引申一个问题:

如果父类属性定义为了满足8字节对齐而出现了间隙的话,子类小字段是否会穿插进去?

举个栗子:

//修改父类
public class Biology {
    protected String type;
    protected int id;
    protected int rootId;
    protected boolean extince;
}

现在对象头12字节,父类id(int)4字节,父类rootId(int)4字节,然后extince(boolean)1字节,一共21字节为了满足8字节对齐,现在基本类型和引用类型之间就产生了3字节的间隙,子类现在有个man(boolean)1字节是否会插入进去,减少1字节的浪费呢?如果会的话,那么预期的结果就是下面这样的:

OFFSET SIZE TYPE DESCRIPTION
0 4 (object header)
4 4 (object header)
8 4 (object header)
12 4 int Biology.id
16 4 int Biology.rootId
20 1 boolean Biology.extince
21 1 boolean Person.man
22 2 (loss due to the next object alignment)
24 4 java.lang.String Biology.type
28 4 com.cjf.test.Person Person.children
32 4 java.lang.String Person.name
36 4 java.lang.String Person.address
40 8 long Person.height
48 4 int Person.age
52 4 (loss due to the next object alignment)
Instance size: 56 bytes

共计56字节,其中因为对齐填充产生了6字节的浪费。

现在我们运行程序来验证一下;
在这里插入图片描述

虽然最后对象的大小的确为56,但是字段的排序并没有像我们预期的结果一样将子类1字节的字段插入到父类的间隙中。

那么JVM为什么不把子类的小对象插入到父类的间隙中呢?难道是因为我们预期的分配策略最终产生了6字节的浪费,而不将子类属性插入间隙也是6字节的浪费,所以JVM觉得没有必要优化?那若果我们现在在子类中再定义一个4字节的属性,这样的话是不是以我们的分配方式就可以减少这4字节的浪费了?

在子类中再加一个4字节大小的属性:

@Data
public class Person extends Biology{
    private Person children;
    private String name;
    private long height;
    private int age;
    private String address;
    private boolean man;
    private int areaCode;

    public static void printf(Person p) {
        // 查看对象的整体结构信息
        //JOL工具类
        System.out.println(ClassLayout.parseInstance(p).toPrintable());
    }
}

测试走起,结果如下:

在这里插入图片描述

很遗憾,JVM并没有把子类的小对象插入到父类间隙中;甚至为了向8字节对齐,不得不多浪费了4字节的地址空间,最终共造成了10字节的空间浪费。

最后这个问题也留作思考吧;为什么JVM不把小对象插入到父类间隙之中呢?我查了很多资料,很多博主都说会,甚至深入理解JAVA虚拟机书中也写的会…

至此,对象的探秘就结束了~Bye!

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值