【注】这里阐述的是对象的内存布局,不是虚拟机的内存分配
在 Java 程序中,我们拥有多种新建对象的方式。除了最为常见的 new Object()
语句之外,我们还可以通过反射机制、Object.clone 方法、反序列化以及 Unsafe.allocateInstance 方法来新建对象
- Object.clone 方法和反序列化通过直接复制已有的数据,来初始化新建对象的实例字段
- Unsafe.allocateInstance 方法则没有初始化实例字段
- new 语句和反射机制,则是通过调用构造器来初始化实例字段
<u>可以回顾一下那些操作会触发对象的初始化操作</u>
new命令操作形成的字节码,编译而成的字节码包含用来请求内存的 new
指令,以及用来调用构造器的 invokespecial
指令
// Student stu = new Student();
0 new Student
3 dup
4 invokespecial Student()
7 astore_1
当我们调用一个构造器时,它将优先调用父类的构造器,直至 Object 类。这些构造器的调用者皆为同一对象,也就是通过 new 指令新建而来的对象
通过 new 指令新建出来的对象,它的内存其实涵盖了所有父类中的实例字段。也就是说,虽然子类无法访问父类的私有实例字段,或者子类的实例字段隐藏了父类的同名实例字段,但是子类的实例还是会为这些父类实例字段分配内存的
那么这些字段在内存中的具体分布是怎么样的呢?
压缩指针
在Java的对象当中,每一个对象都有一个对象头(object header),由 标记字段 和 类型指针 构成
- 标记字段:存储 Java 虚拟机有关该对象的运行数据,如哈希码、GC 信息以及锁信息
- 类型指针:指向该对象的类
在 64 位的 Java 虚拟机中,对象头的标记字段占 64 位,而类型指针又占了 64 位。也就是说,每一个 Java 对象在内存中的额外开销就是 16 个字节
以一个Integer为例,储存的int类型才占4个字节,额外的信息却要占16字节,这也是为什么 Java 要引入基本类型的原因之一(哪怕违背了一切皆对象的豪言壮语)
为了尽量较少对象的内存使用量,64 位 Java 虚拟机引入了 压缩指针 的概念(对应虚拟机选项 -XX:+UseCompressedOops,默认开启),将堆中原本 64 位的 Java 对象指针压缩成 32 位的,这样一来,保存在Java对象头中的类型指针也就变成了32位,从16字节缩小到12字节
什么是压缩指针?以及其原理
我们将每一个对象想象成一个占据一定长度的盒子
原先的内存寻址找的是下标,假设我这里的下标是12,那么我找的的就是第4个对象
现在我们要改变一种查找的方式,我们给每个对象编一个号。假设我们需要找第4个对象的下标,那么我们只需要知道前三个对象的总长度和第一个对象的偏移量就可以了
由图1我们可以得知,前三个对象的所占的长度为12,且偏移量为0,那么第四个对象的起始下标就为12
那么问题又来了,我怎么知道前面的对象占了多少长度呢?你应该已经想到了,对于每一个对象,我们让其长度固定为某个定值的倍数即可,假设这里的定值是2
这样我们可以直接拿这个编号来乘这个定值,就是我们需要下标了,第四个对象的编号是6,那么下标就是至于有些空间没用到?那只能舍弃掉了
这个概念我们称之为内存对齐(对应虚拟机选项 -XX:ObjectAlignmentInBytes,默认值为 8)默认情况下,Java 虚拟机堆中对象的起始地址需要对齐至 8 的倍数。如果一个对象用不到 8N 个字节,那么空白的那部分空间就浪费掉了。这些浪费掉的空间我们称之为对象间的填充(padding)
在默认情况下,Java 虚拟机中的 32 位压缩指针可以寻址到 2 的 35 次方个字节,也就是 32GB 的地址空间(超过 32GB 则会关闭压缩指针)。
在对压缩指针解引用时,我们需要将其左移 3 位,再加上一个固定偏移量,便可以得到能够寻址 32GB 地址空间的伪 64 位指针了
内存对齐不仅存在于对象与对象之间,也存在于对象中的字段之间。比如说,Java 虚拟机要求 long 字段、double 字段,以及非压缩指针状态下的引用字段地址为 8 的倍数
字段内存对齐的其中一个原因,是让字段只出现在同一 CPU 的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的
字段重排序
字段重排列,顾名思义,就是 Java 虚拟机重新分配字段的先后顺序,以达到内存对齐的目的。
Java 虚拟机中有三种排列方法(对应 Java 虚拟机选项** -XX:FieldsAllocationStyle**,默认值为 1),但都会遵循如下两个规则
-
①如果一个字段占据 C 个字节,那么该字段的偏移量需要对齐至 NC。这里偏移量指的是字段地址与对象的起始地址差值
以long为例,一个long需要占据8个字节
对象头 12 字节,long字段不能从12开始,只能对齐到16
所以偏移量是 16,而不是 12,中间留出 4 个字节用于对齐
②子类所继承字段的偏移量,需要与父类对应字段的偏移量保持一致