对象的内存布局
在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头、实例数据、对齐填充。
对象头包括两部分信息:
- 标记字段:用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。
- 类型指针:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全
1、对象头
Java对象头其实是一个比较复杂的东西,它通常也会由多部分组成,其中包含了MarkWord
和类型指针(ClassMetadataAddress/KlassWord
),如果是数组对象,还会存在数组长度。如下:
虚拟机位数 | 对象头结构信息 | 说明 | 大小 | |
---|---|---|---|---|
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 | public class A{ |
上述案例中,A
类存在三个属性ia、ib、l
,其中两个为int
类型,一个long
类型,那么此时对象a
的实例数据大小则为4 + 4 + 8 = 16byte(字节)
。
那此时再给这个案例加点料试试看,如下:
1 | public class A{ |
此时对象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
组织提供的工具:JOL
,maven
依赖如下:
1 | <!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core --> |
在该工具中提供了两个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 | public static void main(String[] args){ |
结果运行如下:
1 | java.lang.Object object internals: |
从结果中可以很明显的看到,0~12byte
为对象头,12~16byte
为对齐填充数据,最终大小为16bytes
,与上述的推测无误,在开启指针压缩的环境下,会出现4bytes
的对齐填充数据。
5.1、数组对象大小计算
上述简单分析了Object
对象的大小之后,我们再来看一个案例,如下:
1 | public static void main(String[] args){ |
此时大小又为多少呢?因为该数组为int
数组,而int
类型的大小为32bit/4bytes
,所以理论上它的大小为:(12bytes
对象头+9*4=36bytes
数组空间) = 48bytes
,对吗?先看看运行结果:
1 | [I object internals: |
从结果中可以看出最终大小为56bytes
,实际的大小与前面的推断存在明显出入,为什么呢?这是因为目前的obj
对象是一个数组对象,在前面分析对象头构成的时候曾分析过,如果一个对象是数组对象,那么它的对象头中也会使用4bytes
存储数组的长度,所以此时的obj
对象头大小为16bytes
,其中12~16bytes
用于存储数组的长度,再加上9
个int
类型的数组空间36bytes
,大小为52bytes
,因为52
不为8的整数倍,所以JVM会为其补充4bytes
的对齐填充数据,最终大小就成了上述运行结果中的56bytes
。
PS/拓展:
①当平时开发过程中,使用数组对象array.length
属性时,它的长度是从哪儿获取的呢?从现在之后,你就能得到答案:从对象的头部中获取到的。
②如果Java中,不考虑内存的情况下,一个数组对象最大长度可以为多大呢?答案是int
类型能够表达的最大值,因为对象头中只使用了4bytes
存储数组长度。
怎么样?是不是很有趣?其实往往很多平时开发过程中的疑惑,当你搞懂底层概念之后,答案也自然而然的浮现在你眼前了。
5.2、实例对象大小计算
前面分析了数组对象之后,接着再来看看开发过程中经常定义的实例对象,案例如下:
1 | public class ObjectSizeTest { |
结果没啥意外的,掌握了前面知识的小伙伴都可以独立计算出来这个结果,唯一值得一提的就是可以看到,在24~28bytes
这四个字节存储的是obj
对象的堆引用指针,此时因为开启了指针压缩,所以占32bit/4bytes
大小。
至此,Java对象在内存中的布局方式以及大小计算的方式已经阐述完毕