Java虚拟机-对象内存布局
Java对象的内存布局:对象头(Header),实例数据(Instance Data)和对齐填充(Padding)。
1 | +------------------+ |
对象大小分为:
- 自身的大小(Shadow heap size)
- 所直接或间接引用的对象的大小(Retained heap size)。
本文讨论的是Java 对象的 Shadow heap size。Retained heap size可以通过遍历引用得到。
对象头
1 | +------------------+------------------+------------------ + |
上面是对象头的结构,结构说明如下:
结构块 | 描述 | 32位vm | 64位vm | 64位指针压缩 |
---|---|---|---|---|
mark word | 包含一系列的标记位,比如轻量级锁的标记位,偏向锁标记位等等 | 32bit | 64bit | 64bit |
klass pointer | 用来指向对象对应的Class对象(其对应的元数据对象)的内存地址 | 32bit | 64bit | 32bit |
array size | 如果是数组对象,对象头部有一个保存数组长度的空间 | 32bit | 64bit | 32bit |
- 32位JVM的对象头
1 | |----------------------------------------------------------------------------------------|--------------------| |
- 64位JVM的对象头
1 | |------------------------------------------------------------------------------------------------------------|--------------------| |
- 64位开启压缩的JVM对象头
1 | |--------------------------------------------------------------------------------------------------------------|--------------------| |
字段 | 说明 |
---|---|
hashcode | 保存对象的哈希码 |
age | 保存对象的分代年龄 |
biased_lock | 偏向锁标识位 |
lock | 锁状态标识位 |
thread | 保存持有偏向锁的线程ID |
epoch | 保存偏向时间戳 |
ptr_to_lock_record | 指向栈中锁记录的指针 |
ptr_to_heavyweight_monitor | 指向重量级指针 |
MarkWord(标记字段) :哈希码、分代年龄、锁标志位、偏向线程ID、偏向时间戳等信息。Mark Word被设计成了一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态(lock)区分不同的状态位,从而区分不同的存储结构。
Klass Pointer(类型指针): 即指向当前对象的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说查找对象的元数据信息并不一定要经过对象本身。
另外,如果是数组,对象头中还有一块用于存放数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。
对象实际数据
对象实际数据包括了对象的所有成员变量,其大小由各个成员变量的大小决定,成员变量的大小一般是基于变量所属类型。
type | size(bits) | bytes |
---|---|---|
boolean | 8 | 1 |
byte | 8 | 1 |
char | 16 | 2 |
short | 16 | 2 |
int | 32 | 4 |
long | 64 | 8 |
float | 32 | 4 |
double | 64 | 8 |
可能认为布尔值占用一位或一个字节的八分之一。在虚拟机的具体实现中,可以是1位,1字节,或者最有可能的是,它占用基础硬件的基本字长(32或64位)。Hotspot中每个boolean占用1个字节。
stack overflow上的一个高票回答。
在 32 位的 JVM 上,一个对象引用占用 4 个字节;在 64 位上,占用 8 个字节。使用 8 个字节是为了能够管理大于 4G 的内存,如果你的程序不需要访问大于 4G 的内存,可通过 -XX:+UseCompressedOops 选项,开启指针压缩,一个对象引用占用 4 个字节。
对齐填充
对齐填充是底层CPU数据总线读取内存数据时的要求,例如,通常CPU按照字单位读取,如果一个完整的数据体不需要对齐,那么在内存中存储时,其地址有极大可能横跨两个字,例如某数据块地址未对齐,存储为1-4,而cpu按字读取,需要把0-3字块读取出来,再把4-7字块读出来,最后合并舍弃掉多余的部分。这种操作会很多很多,且很频繁,但如果进行了对齐,则一次性即可取出目标数据,将会大大节省CPU资源。
在hotSpot虚拟机中,默认的对齐位数是8,与CPU架构无关。换句话说就是对象的大小必须是8字节的整数倍。因此当对象没有对齐的话,就需要通过对齐填充来补全。
指针压缩
从上文的分析中可以看到,64位JVM消耗的内存会比32位的要多大约1.5倍,这是因为对象指针在64位JVM下有更宽的寻址。对于那些将要从32位平台移植到64位的应用来说,平白无辜多了1/2的内存占用,这是开发者不愿意看到的。
从JDK 1.6 update14开始,64位的JVM正式支持了-XX:+UseCompressedOops
这个可以压缩指针,起到节约内存占用的新参数。
启用CompressOops后,会压缩的对象:
- 每个Class的属性指针(静态成员变量);
- 每个对象的属性指针;
- 普通对象数组的每个元素指针。
当然,压缩也不是所有的指针都会压缩,对一些特殊类型的指针,JVM是不会优化的,例如指向PermGen的Class对象指针、本地变量、堆栈元素、入参、返回值和NULL指针不会被压缩。
从 Java 1.6.0_23 起,这个选项默认是开的。可通过 jinfo -flag UseCompressedOops <pid>
查看。注意:32位HotSpot VM是不支持-XX:+UseCompressedOops
参数的,只有64位HotSpot VM才支持。如果想关闭指针压缩可以使用选项-XX:-UseCompressedOops
.
总结
指针压缩对对象的影响:
- 64位开启指针压缩的情况下,存放Klass pointer的空间大小是4字节,MarkWord是8字节,对象头为12字节;
- 64位系统中,数组对象的对象头占用24 bytes,启用压缩后占用16字节。比普通对象占用内存多是因为需要额外的空间存储数组的长度(数组长度8bytes->4bytes)。
- 64位机器上reference类型占用8个字节,开启指针压缩后占用4个字节。
对象的内存布局
- 每个对象的内存占用按 8 字节对齐
- 空对象和类实例成员变量空对象,指的非 inner-class,没有实例属性的类。Object 类或者直接继承 Object 没有添加任何实例成员的类。空对象的不包含任何成员变量,其大小即对象头大小:
- 在 32 位 JVM 上,占用 8 字节;
- 在未开启 UseCompressedOops 的 64 位 JVM 上,16 字节。
- 在开启 UseCompressedOops 的 64 位 JVM 上,12 + 4 = 16;
- 对象实例成员重排序,实例成员变量紧随对象头。每个成员变量都尽量使本身的大小在内存中尽量对齐。比如 int 按 4 位对齐,long 按 8 位对齐。为了内存紧凑,实例成员在内存中的排列和声明的顺序可能不一致,实际会按以下顺序排序:
- doubles and longs
- ints and floats
- shorts and chars
- booleans and bytes
- references
- 父类和子类的成员变量分开存放
- 先是父类的实例成员。父类实例成员变量结束之后,按4位对齐,随后接着子类实例成员变量。
- 如果子类首个成员变量是 long 或者 double 等 8 字节数据类型,而父类结束时没有 8 位对齐。会把子类的小于 8 字节的实例成员先排列,直到能 8 字节对齐。
- 非静态的内部类,有一个隐藏的对外部类的引用。
6.数组也是对象,故有对象的头部,另外数组还有一个记录数组长度的 int 类型,随后是每一个数组的元素:基本数据类型或者引用。8 字节对齐。
- 32 位的机器上
- byte[0] 8 字节的对象头部,4 字节的 int 长度, 12 字节,对齐后是 16 字节,实际 byte[0] ~ byte[4] 都是 16 字节。
- 64 位+UseCompressedOops
- byte[0] 是 16 字节大小,byte[1] ~ byte[8] 24 字节大小。
- 64 位-UseCompressedOops
- byte[0], 16 字节头部,4 字节的 int 长度信息,20 字节,对齐后 24 字节。byte[0] ~ byte[4] 都是 24 字节。
- 字符串大小
FieldType | 64 bit -UseCompressedOops | 64 bit +UseCompressedOops | 32 bit | |
---|---|---|---|---|
HEADER | 16 | 12 | 8 | |
value | char[] | 8 | 4 | 4 |
offset | int | 4 | 4 | 4 |
count | int | 4 | 4 | 4 |
hash | int | 4 | 4 | 4 |
PADDING | 4 | 4 | 0 | |
TOTAL | 40 | 32 | 24 |
不计算 value 引用的 Retained heap size, 字符串本身就需要 24 ~ 40 字节大小。
对象实例成员重排序
1 | class MyClass { |
实际内存布局(field重拍序):
1 | 32 bit 64bit +UseCompressedOops |
父类和子类的实例成员
1 | class A { |
实际内存布局:
1 | 32 bit 64bit +UseCompressedOops |
如果子类首个成员变量是 long 或者 double 等 8 字节数据类型,而父类结束时没有 8 位对齐。会把子类的小于 8 字节的实例成员先排列,直到能 8 字节对齐。
1 | class A { |
内存结构如下:
1 | 32 bit 64bit +UseCompressedOops |
查看对象的大小的方法
Instrumentation
使用java.lang.instrument.Instrumentation.getObjectSize()方法,可以很方便的计算任何一个运行时对象的大小,返回该对象本身及其间接引用的对象在内存中的大小。不过,这个类的唯一实现类InstrumentationImpl的构造方法是私有的,在创建时,需要依赖一个nativeAgent,和运行环境所支持的一些预定义类信息,我们在代码中无法直接实例化它,需要在JVM启动时,通过指定代理的方式,让JVM来实例化它。
github Instrumentation demo
unsafe 类
java中的sun.misc.Unsafe类,有一个objectFieldOffset(Field f)方法,表示获取指定字段在所在实例中的起始地址偏移量,如此可以计算出指定的对象中每个字段的偏移量,值为最大的那个就是最后一个字段的首地址,加上该字段的实际大小,就能知道该对象整体的大小。
使用Unsafe可以完全不care对象内的复杂构成,可以很精确的计算出对象头的大小(即第一个字段的偏移)及每个字段的偏移。缺点是Unsafe通常禁止开发者直接使用,需要通过反射获取其实例,另外,最后一个字段的大小需要手工计算。其次需要手工写代码递归计算才能得到对象及其所引用的对象的综合大小,相对比较麻烦。
github unsafe demo