JVM系列:虚拟机中的对象布局

1.对象的创建

创建一个对象(克隆、反序列化)通常通过一个new关键字。那它到底是个什么过程呢?

  • 虚拟机遇到一条new指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析、初始化,如果没有,则进行相应得类加载过程。
  • 类加载检查通过后,接下来为新生对象分配内存,即在java堆中划分一块内存出来。
  • 对对象的对象头信息设置。
2.对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域
java  对象内存布局

  1. 对象头用于存储对象的元数据信息:Markword + 类型指针+ [数组长(对于数组对象才需要此部分信息)]

    • Mark Word 存储对象自身的运行时数据如哈希值等。Mark Word一般被设计为非固定的数据结构,以便存储更多的数据信息和复用自己的存储空间。在32位系统占4字节,在64位系统中占8字节;

    • 类型指针 指向它的类元数据的指针,用于判断对象属于哪个类的实例。在32位系统占4字节,在64位系统中占8字节;

    • Length:如果是数组对象,还有一个保存数组长度的空间,占4或8个字节;

  2. 实例数据:

    实例数据存储的是真正有效数据,如各种字段内容,各字段的分配策略为longs/doubles、ints、shorts/chars、bytes/boolean、oops(ordinary object pointers),相同宽度的字段总是被分配到一起,便于之后取数据。父类定义的变量会出现在子类定义的变量的前面。

    byte和boolean是1个字节,short和char是2个字节,int和float是4个字节,long和double是8个字节,reference是4个字节(64位系统中是8个字节)。

  3. 对齐填充:

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

对象头占用空间大小

这里说明一下32位系统和64位系统中对象所占用内存空间的大小:

  • 在32位系统下,存放Class Pointer的空间大小是4字节,MarkWord是4字节,对象头为8字节;

  • 在64位系统下,存放Class Pointer的空间大小是8字节,MarkWord是8字节,对象头为16字节;

  • 64位开启指针压缩的情况下,存放Class Pointer的空间大小是4字节,MarkWord是8字节,对象头为12字节;

  • 如果是数组对象,对象头的大小为:markword8字节+数组长度4字节+对齐4字节=16字节。其中对象引用占4字节(未开启指针压缩的64位为8字节),数组MarkWord为4字节(64位未开启指针压缩的为8字节);

  • 静态属性不算在对象大小内。

对齐填充例子:
在32位系统下,一个包含两个属性的对象:int和byte,对象头占用8字节,int和byte占用5字节,总共13个字节。这时就需要加上大小为3字节的padding进行8字节对齐,最终占用大小为16个字节。

3.数组和字符串的内存使用情况

计算一个JAVA数组使用的内存

32位系统下,一个一维数组是一个对象,这个对象头占用了12个字节。具体每个元素需要多少字节,取决于元素的类型。如果是引用类型,每一个元素需要4个字节来存储它的引用。如果总的字节数不是8个倍数,同样需要填充字节。

二维数组的占用内存情况

Java中多维数组事实上是一系列的内嵌数组。这说明二维数组的每一行都有和对象一样的内存开销,因为他本质上就是一个独立的对象。有一个10×10的int数组:

java
每一行中都有一个数组对象,一个数组对象的对象头开销为12字节,并且有10个int.开销104 = 40 字节,另外还有4个填充字节,所以每一行的数组对象占用56个字节。所以一共有5610+52 = 612个字节,加上4个填充字节一共616个字节。所以,得出的字节总数会比如果只是考虑10104=400 字节要多。 多维数组和二维数组同理可得。

理解String 是怎么占用内存的

来看一个每个String对象的各个属性,一个String包括如下的属性:

  • 一个char数组(是个独立的对象用来存储字符串中的字符)
  • 一个int 的offset属性(偏移量,用来指出字符串是从char数组中第几个字符开始的)
  • 一个int 的count属性(字符串的长度)
  • 最后一个int的hash属性(用来存储hashCode的值)

也就是说,即使一个String不包含任何字符,也需要在数组的引用上面消耗4个字节,再加上3个int类型的属性,3*4=12字节,加上对象头的8个字节,以上一共24个字节(目前还不需要加上填充字节)。然后,一个char数组的对象头需要12个字节,加上4个填充字节,一个空的String 消耗了40字节。

如果String 包含了17个字符,那么String 对象本身需要24个字节,但是现在17个字符的char数组要需12字节 加上 172=34字节,12+172=46字节,46不是8的倍数,加上填充字节46+2=48字节,那么17个字符的字符串会用到48+24 = 72 字节,可以看到在C语言中占据18个字节的String 在JAVA中占据了72个字节。

4.指针压缩

64位JVM消耗的内存会比32位的要多大约1.5倍,这是因为对象指针在64位JVM下有更宽的寻址。对于那些将要从32位平台移植到64位的应用来说,平白无辜多了1/2的内存占用。使用http://www.javamex.com/中提供的classmexer.jar来计算对象的大小,将jar包添加到依赖中,并且条件vm参数:

-javaagent:/path_to_agent/classmexer.jar

32位HotSpot VM是不支持UseCompressedOops参数的,只有64位HotSpot VM才支持。本文中使用的是JDK 1.8,默认该参数就是开启的。

I:\project\demo>java -version
java version "1.8.0_171"
Java(TM) SE Runtime Environment (build 1.8.0_171-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.171-b11, mixed mode)
4.1 普通属性案例

1. 开启指针压缩
执行如下代码:

public class MyTestSize {
  int a;
  long b;
  static int c;

  public static void main(String[] args) throws IOException {

    MyTestSize size = new MyTestSize();
    // 打印对象的shallow size
    System.out.println("Shallow Size: " + MemoryUtil.memoryUsageOf(size) + " bytes");
    //  打印对象的 retained size
    System.out.println("Retained Size: " + MemoryUtil.deepMemoryUsageOf(size) + " bytes");
    System.in.read();
  }

}

结果分别为

Shallow Size24 bytes
Retained Size24 bytes

Shallow Size和Retained Size

  • Shallow Size
    对象自身占用的内存大小,不包括它引用的对象。 针对非数组类型的对象,它的大小就是对象与它所有的成员变量大小的总和。当然这里面还会包括一些java语言特性的数据存储单元。

    针对数组类型的对象,它的大小是数组元素对象的大小总和。

  • Retained Size
    Retained Size为当前对象大小+当前对象可直接或间接引用到的对象的大小总和。(间接引用的含义:A->B->C, C就是间接引用)
    换句话说,Retained Size就是当前对象被GC后,从Heap上总共能释放掉的内存。

    不过,释放的时候还要排除被GC Roots直接或间接引用的对象。他们暂时不会被被当做Garbage。

分析:对象size内存大小 ,默认情况下开启指针压缩。

对象头:MarkWord:8+class point:4 = 12
实际数据:int 4 +long 8 = 12
静态变量不计算

所以总共24byte,由于对象里的属性都是基本数据类型,因此Retained Size也为24

2. 关闭指针压缩
如果要关闭指针压缩,在JVM参数中添加-XX:-UseCompressedOops来关闭,再运行上述代码查看结果:

Shallow Size: 32 bytes
Retained Size: 32 bytes

分析一下在64位未开启指针压缩的情况下:

  • 对象头大小=Class Pointer的空间大小为8字节+MarkWord为8字节=16字节;
  • 实际数据大小=int类型4字节+long类型8字节=12字节(静态变量不在计算范围之内);
    这里计算后大小为16+12=28字节,这时候就需要padding来补齐了,所以padding为4字节,最后的大小就是32字节。
4.2 数组案例

64位系统中,数组对象的对象头占用24 bytes,启用压缩后占用16字节。比普通对象占用内存多是因为需要额外的空间存储数组的长度。基础数据类型数组占用的空间包括数组对象头以及基础数据类型数据占用的内存空间。由于对象数组中存放的是对象的引用,所以数组对象的Shallow Size=数组对象头+length * 引用指针大小,Retained Size=Shallow Size+length*每个元素的Retained Size。

public class ArraySize {
        int[][] arr = new int[10][2];

    public static void main(String[] args) throws IOException {
        ArraySize size = new ArraySize();
        System.out.println("Shallow Size: " + MemoryUtil.memoryUsageOf(size) + " bytes");
        System.out.println("Retained Size: " + MemoryUtil.deepMemoryUsageOf(size) + " bytes");
        System.in.read();
    }
}

输出的结果的:
Shallow Size: 16 bytes
Retained Size: 312 bytes

Shallow Size比较简单,这里对象头大小为12字节, 实际数据大小为4字节,所以Shallow Size为16。

对于Retained Size来说,要计算数组占用的大小,其实可以发现312-16 =296,也就是压缩指针后数组占用了296byte。
对于数组对象来说它的对象头部多了一个用来存储数组长度的空间,该空间大小为4字节,所以数组对象的大小=24*10+56 = 296。

如果不启动压缩指针, Retained Size输出为448,让我们计算一下,当前对象为24byte,数组的大小计算为:

(24+8)*10 = 320
24+8*10 = 104
合计总共424,正好~
5.关于Padding

思考这样一个问题,是不是padding都加到对象的后面呢,如果对象头占12个字节,对象中只有1个long类型的变量,那么该long类型的变量的偏移起始地址是在12吗?用下面一段代码测试一下:

public class PaddingTest {

    long a;
    
    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
        Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
        theUnsafe.setAccessible(true);
        Unsafe unsafe = (Unsafe) theUnsafe.get(null);
        System.out.println(unsafe.objectFieldOffset(PaddingTest.class.getDeclaredField("a")));
    }
}

这里使用Unsafe类来查看变量的偏移地址为16。CPU一次直接操作的数据可以到64位,也就是8个字节,那么字长就是64,而long类型本身就是占64位,如果这时偏移地址是12,那么需要分两次读取该数据,而如果偏移地址从16开始只需要通过一次读取即可。也就是JVM优化了JVM对象内存的划分,会智能的填充对其对象内存。

6.对象的访问定位

对象的访问定位也取决于具体的虚拟机实现。当我们在堆上创建一个对象实例后,就要通过虚拟机栈中的reference类型数据来操作堆上的对象。现在主流的访问方式有两种(HotSpot虚拟机采用的是第二种):

  • 使用句柄访问对象。即reference中存储的是对象句柄的地址,而句柄中包含了对象实例数据与类型数据的具体地址信息,相当于二级指针。

  • 直接指针访问对象。即reference中存储的就是对象地址,相当于一级指针。
    javajava
    两者的比较:
    1.句柄的方式的好处是当对象移动时只会改变句柄中实例数据的指针,而引用不会改变。
    2.直接引用的方式的好处是速度快。

一个疑问
一次无意间作者去实践测量int[10][10]的内存大小,但是和预计的不同,更诡异的是该工具输出的int[10][10]和int[10][9]占用的内存情况竟然一样。如果有读者知道原因,求解释_

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值