0%

Java对象的内存布局

新建对象方式

  • new语句
  • 反射机制
  • Object.clone方法
  • 反序列化
  • Unsafe.allocateInstance方法

new语句和反射机制,通过调用构造器来初始化实例字段。
Object.clone方法和反序列化,通过直接复制已有的数据来初始化新建对象的实例字段。
Unsafe.allocateInstance方法没有初始化实例字段。


以new语句为例,它编译而成的字节码将包含用来请求内存的new指令,以及用来调用构造器的invokespecial指令。

1
2
3
4
5
6
7
8
9
10
11
//Foo foo = new Foo();//编译而成的字节码
0 new Foo
3 dup
4 invokespecial Foo()
7 astore_1

//Foo类构造器会调用父类Object的构造器
public Foo();
o aload_0 [this]
1 invokespecial java.lang.Object() [8]
4 return

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
2
3
4
5
6
7
8
/**
* 虚拟机选项-XX:-RestrictContended。
* Java9以上版本,使用javac编译时需要添加--add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAME。
*/

//JOL工具,打印工程中的类的字段分布情况
curl -L -O http://central.maven.org/maven2/org/openjdk/jol/jol-cli/0.9/jol-cli-0.9-full.jar
java -cp jol-cli-0.9-full.jar org.openjdk.jol.Main internals java.lang.String

总结

常见的new语句会被编译为new指令,以及对构造器的调用。每个类的构造器皆会直接或间接调用父类的构造器,并且在同一个实例中初始化相应的字段。

JVM引入了压缩指针的概念,将原本的64位指针压缩成32位。压缩指针要求JVM堆中对象的起始地址要对齐至8的倍数。JVM还会对每个类的字段进行重排列,使得字段也能够内存对齐。