在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头、实例数据、对齐填充

对象头包括两部分信息:

  1. 标记字段:用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。
  2. 类型指针对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

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

1、对象头

Java对象头其实是一个比较复杂的东西,它通常也会由多部分组成,其中包含了MarkWord和类型指针(ClassMetadataAddress/KlassWord),如果是数组对象,还会存在数组长度。如下:

image.png|238

虚拟机位数 对象头结构信息 说明 大小
64位 MarkWord unused、HashCode、分代年龄、是否偏向锁和锁标记位 8byte/64bit
64位 ClassMetadataAddress/KlassWord 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例 8byte/64bit 开启指针压缩的情况下为4byte/32bit
64位 ArrayLenght 如果是数组对象存储数组长度,非数组对象不存在 4byte/32bit

64位JVM中对象头内MarkWord的默认信息存储着HashCode、分代年龄、是否偏向锁、锁标记位、unused,如下:

位数 锁状态 哈希码 分代年龄 是否偏向锁 锁标志信息 unused
64位 无锁态(默认) 31bit 4bit 1bit 2bit 26bit
关于MrakWord这块区域更多是提供给Synchronized锁使用。另外可以看出分代年龄的大小为4位,最大为15,所以这就是为什么对象的最大年龄为15

简单总结一下,对象头主要由MarkWord、KlassWord和有可能存在的数组长度三部分组成。MarkWord主要是用于存储对象的信息以及锁信息,KlassWord则是存储指向元空间中类元数据的指针,当然,如果当前对象是数组,那么也会在对象头中存储当前数组的长度。

2、实例数据

实例数据是指一个聚合量所有标量的总和,也就是是指当前对象属性成员数据以及父类属性成员数据。举个例子:

1
2
3
4
5
6
7
8
9
public class A{
int ia = 0;
int ib = 1;
long l = 8L;

public static void main(String[] args){
A a = new A();
}
}

上述案例中,A类存在三个属性ia、ib、l,其中两个为int类型,一个long类型,那么此时对象a的实例数据大小则为4 + 4 + 8 = 16byte(字节)

那此时再给这个案例加点料试试看,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class A{
int ia = 0;//4个字节
int ib = 1;//4个字节
long l = 8L;//8个字节
B b = new B();//8个字节 引用类型,算作引用指针,占用4个字节,不用考虑B类中有什么

public static void main(String[] args){
A a = new A();
}

public static class B{
Object obj = new Object();
}
}

此时对象a的实例数据大小又该如何计算呢?需要把B类的成员数据也计算进去嘛?实则不需要的,如果当类的一个成员属于引用类型,那么是直接存储指针的,而引用指针的大小为一个字宽,在64位的VM中为64bit大小,4个字节。所以此时对象a的实例数据大小为:4 + 4 + 8 + 8 = 24byte(未开启指针压缩的情况下是这个大小,但如果开启了则不为这个大小,稍后详细分析)。

3、对齐填充

对齐填充在一个对象中是可能存在,也有可能不存在的,因为在64bit的虚拟机中,《虚拟机规范》中规定了:为了方便内存的单元读取、寻址、分配,Java对象的总大小必须要为8的整数倍,所以当一个对象的对象头+实例数据大小不为8的整数倍时,此刻就会出现对齐填充部分,将对象大小补齐为8的整数倍。

如:一个对象的对象头+实例数据大小总和为28bytes,那么此时就会出现4bytes的对齐填充,JVM为对象补齐成8的整数倍:32bytes。

所以,对齐填充并不是每个对象都有这部分仅仅是为了字节对齐,避免减少堆内存的碎片空间和方便OS读取。

4、指针压缩

指针压缩属于JVM的一种优化思想,一方面可以节省很大的内存开支,第二方面也可以方便JVM跳跃寻址(稍后分析),在64bit的虚拟机中为了提升内存的利用率,所以出现了指针压缩这一技术,指针压缩的技术会将Java程序中的所有引用指针(类型指针、堆引用指针、栈帧内变量引用指针等)都会压缩一半,而在Java中一个指针的大小是占一个字宽单位的,在64bit的虚拟机中一个字宽的大小为64bit,所以也就意味着在64位的虚拟机中,指针会从原本的64bit压缩为32bit的大小,而指针压缩这一技术在JDK1.7之后是默认开启的。

5、JOL对象大小计算实战

为了方便观察到对象的内存布局,首先导入一个OpenJDK组织提供的工具:JOLmaven依赖如下:

1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>

在该工具中提供了两个API:

  • GraphLayout.parseInstance(obj).toPrintable():查看对象外部信息:包括引用的对象
  • GraphLayout.parseInstance(obj).totalSize():查看对象占用空间总大小

对象头 + 实例数据 + 对齐填充

  • 对象头: MarkWord占用 8 个字节,KlassWold 占用 8 个字节,开启指针压缩后占用 4 个字节,数组长度占用4个字节(固定大小,没有数组且开启指针压缩后占用12字节
  • 实例数据:就看类属性的大小,注意引用指针占用8 个字节,开启指针压缩后占用 4 个字节
  • 对其填充:保证整个对象的大小为 8 字节的整数倍

面试题:在Java中创建一个Object对象会占用多少内存?

按照上面的讲解,我们可以来进行初步计算,对象头大小应该理论上为mrakword+klassword=16bytes=128bit,同时Object类中是没有定义任何属性的,所以不存在实例数据。但如果在开启指针压缩的情况下,只会有12bytes,因为对象头中的类元指针会被压缩一半,所以会出现4bytes的对齐填充,最终不管是否开启了指针压缩,大小应该为16字节,接着来论证一下(环境:默认开启指针压缩的JDK1.8版本):

1
2
3
4
public static void main(String[] args){
Object obj = new Object();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}

结果运行如下:

1
2
3
4
5
6
7
8
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) ......
4 4 (object header) ......
8 4 (object header) ......
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

从结果中可以很明显的看到,0~12byte为对象头,12~16byte为对齐填充数据,最终大小为16bytes,与上述的推测无误,在开启指针压缩的环境下,会出现4bytes的对齐填充数据。

5.1、数组对象大小计算

上述简单分析了Object对象的大小之后,我们再来看一个案例,如下:

1
2
3
4
public static void main(String[] args){
Object obj = new int[9];
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}

此时大小又为多少呢?因为该数组为int数组,而int类型的大小为32bit/4bytes,所以理论上它的大小为:(12bytes对象头+9*4=36bytes数组空间) = 48bytes,对吗?先看看运行结果:

1
2
3
4
5
6
7
8
9
10
[I object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) .....
4 4 (object header) .....
8 4 (object header) .....
12 4 (object header) .....
16 36 int [I.<elements> N/A
52 4 (loss due to the next object alignment)
Instance size: 56 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

从结果中可以看出最终大小为56bytes,实际的大小与前面的推断存在明显出入,为什么呢?这是因为目前的obj对象是一个数组对象,在前面分析对象头构成的时候曾分析过,如果一个对象是数组对象,那么它的对象头中也会使用4bytes存储数组的长度,所以此时的obj对象头大小为16bytes,其中12~16bytes用于存储数组的长度,再加上9int类型的数组空间36bytes,大小为52bytes,因为52不为8的整数倍,所以JVM会为其补充4bytes的对齐填充数据,最终大小就成了上述运行结果中的56bytes

PS/拓展:
①当平时开发过程中,使用数组对象array.length属性时,它的长度是从哪儿获取的呢?从现在之后,你就能得到答案:从对象的头部中获取到的。
②如果Java中,不考虑内存的情况下,一个数组对象最大长度可以为多大呢?答案是int类型能够表达的最大值,因为对象头中只使用了4bytes存储数组长度。
怎么样?是不是很有趣?其实往往很多平时开发过程中的疑惑,当你搞懂底层概念之后,答案也自然而然的浮现在你眼前了。

5.2、实例对象大小计算

前面分析了数组对象之后,接着再来看看开发过程中经常定义的实例对象,案例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class ObjectSizeTest {
public static class A{
int i = 0;
long l = 0L;
Object obj = new Object();
}

public static void main(String[] args){
A a = new A();
System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
}

// --------- 运行结果:-------------
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) ......
4 4 (object header) ......
8 4 (object header) ......
12 4 int A.i 0
16 8 long A.l 0
24 4 java.lang.Object A.obj (object)
28 4 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

结果没啥意外的,掌握了前面知识的小伙伴都可以独立计算出来这个结果,唯一值得一提的就是可以看到,在24~28bytes这四个字节存储的是obj对象的堆引用指针,此时因为开启了指针压缩,所以占32bit/4bytes大小。

至此,Java对象在内存中的布局方式以及大小计算的方式已经阐述完毕