新建对象方式
- new语句
- 反射机制
- Object.clone方法
- 反序列化
- Unsafe.allocateInstance方法
new语句和反射机制,通过调用构造器来初始化实例字段。
Object.clone方法和反序列化,通过直接复制已有的数据来初始化新建对象的实例字段。
Unsafe.allocateInstance方法没有初始化实例字段。
以new语句为例,它编译而成的字节码将包含用来请求内存的new指令,以及用来调用构造器的invokespecial指令。
1 | //Foo foo = new Foo();//编译而成的字节码 |
Java对构造器的约束
- 如果一个类没有定义任何构造器的话,Java编译器会自动添加一个无参数的构造器。
- 子类的构造器需要调用父类的构造器。如果父类存在无参数构造器的话,该调用可以是隐式的,Java编译器会自动添加对父类构造器的调用。但是,如果父类没有无参数构造器,那么子类的构造器则需要显式地调用父类带参数的构造器。
- 直接使用”super”关键字调用父类构造器。
- 使用”this”关键字调用同一个类中的其他构造器。
无论是直接的显式调用,还是间接的显式调用,都需要作为构造器的第一条语句,以便优先初始化集成而来的父类字段。(但是可以通过调用其他生成参数的方法,或者字节码注入来绕开)
当调用一个构造器时,它将优先调用父类的构造器,直至Object类。这些构造器的调用者皆为同一对象,也就是通过new指令新建而来的对象。
它的内存涵盖了所有父类中的实例字段。虽然子类无法访问父类的私有实例字段,或者子类的实例字段隐藏了父类的同名实例字段,但是子类的实例还是会为这些父类实例字段分配内存的。
压缩指针
在JVM中,每个Java对象都有一个对象头(object header),这个由标记字段和类型指针所构成。其中,标记字段(mark word)用以存储JVM有关该对象的运行数据,如哈希码、GC信息以及锁薪资,而类型指针则指向该对象的类。
在64位的JVM中,对象头的标记字段占64位,而类型指针又占了64位。每一个Java对象在内存中的额外开销就是16字节。以Integer类为例,它仅有一个int类型的私有字段,占4个字节。因此,每一个Integer对象的额外内存开销至少是400%。这是Java引入基本类型的原因之一。
为了尽量减少对象的内存使用量,64位JVM引入了压缩指针的概念(虚拟机选项-XX:+UseCompressedOops,默认开启),将堆中原本64位(8字节)的Java对象指针压缩成32位(4字节)的。这样,对象头中的类型指针也会被压缩成32位,使得对象头的大小从16字节降至12字节(标记字段8字节+类型指针4字节)。压缩指针不仅可以作用于对象头的类型指针,还可以作用于引用类型的字段,以及引用类型数组。
压缩指针原理
内存对齐,虚拟机选项-XX:ObjectAlignmentInBytes,默认值为8(2的3次方)。java对象的内存对齐大小。默认是8字节,JVM实际计算堆内存上限的方法是 4GB * ObjectAlignmentInBytes。
默认 8 字节对齐,那么最低 3 位是零,所以移动 3 位,那么就有 232+3字节 = 32 GB 压缩引用空间。如果 16 字节对齐,那么就有 232+4字节 = 64 GB 压缩引用堆空间
JVM Anatomy Quark #24: 对象对齐
默认情况下,JVM堆中对象的起始地址需要对齐至8的倍数。如果一个对象用不到8N个字节,那么空白的那部分空间就浪费掉了。这些浪费掉的空间称之为对象间的填充(padding)。
在默认情况下,JVM中的32位压缩指针可以寻址到2的35次方个字节,也就是32GB的地址空间(超过32GB则会关闭压缩指针)。在压缩指针解引用时,需要将其左移3位,再加上一个固定偏移量,便可以得到能够寻址32GB地址空间的伪64位指针了。
并没有通过压缩指针让两个寻址范围一致,而是通过压缩指针放大了32位的寻址空间使它够用了。
此外可以通过内存对齐选项-XX:ObjectAlignmentInBytes来进一步提升寻址范围。但是同时也可能增加对象间填充,导致压缩指针没有达到原本节省空间的效果。
关闭了压缩指针,JVM还是会进行内存对齐。此外,内存对齐不仅存在于对象与对象之间,也存在于对象中的字段之间。如JVM要求long字段、double字段,以及非压缩指针状态下的引用字段地址为8的倍数。
字段内存对齐的原因之一,让字段只出现在同一CPU地缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的。
字段重排列
字段重排列,是指JVM重新分配字段的先后顺序,以达到内存对齐的目的。JVM中有三种排列方法(对应JVM选项-XX:FieldsAllocationStyle,默认值是1)。遵循两个规则:
- 如果一个字段占据C个字节,那么该字段的偏移量需要对齐至NC。这里偏移量指的是字段地址与对象的起始地址差值。
以long类为例,它仅有一个long类型(8字节)的实例字段。在使用了压缩指针的64位虚拟机中,尽管对象头的大小为12个字节,该long类型字段的偏移量也只能是16,而中间空着的4个字节便会被浪费掉。
- 子类所继承字段的偏移量,需要与父类对应字段的偏移量保持一致。
在具体实现中,JVM还会对齐子类字段的起始位置。对于使用了压缩指针的64位虚拟机,子类第一个字段需要对齐至4N;而对于关闭了压缩指针的64位虚拟机,子类第一个字段则需要对齐至8N。
虚共享
Java8引入了一个新的注释@Contended,用来解决对象字段之间的虚共享(false sharing)问题。这个注释也会影响到字段的排列。
假设两个线程分别访问同一对象中不同的volatile字段,逻辑上它们并没有共享内容,因此不需要同步。然而,如果这两个字段恰好在同一个缓存行中,那么对这些字段的写操作会导致缓存行的写回,也就造成了实质上的共享。
JVM会让不同的@Contended字段处于独立的缓存行中,会导致大量的空间被浪费掉。
1 | /** |
总结
常见的new语句会被编译为new指令,以及对构造器的调用。每个类的构造器皆会直接或间接调用父类的构造器,并且在同一个实例中初始化相应的字段。
JVM引入了压缩指针的概念,将原本的64位指针压缩成32位。压缩指针要求JVM堆中对象的起始地址要对齐至8的倍数。JVM还会对每个类的字段进行重排列,使得字段也能够内存对齐。