JVM学习笔记8:Java对象的内存布局

对象创建方式

  • 6种方法创建对象,但是不同的方法对于实例字段的初始化是不同的:
    • 直接复制已有的数据到实例化字段:Object.clone 方法和反序列化
    • 没有初始化实例字段:Unsafe.allocateInstance
    • 通过调用构造器来初始化实例字段: new 语句和反射机制
  • 创建过程,以new为例:下面的代码本质还是mallco+init
1
2
3
4
5
// Foo foo = new Foo(); 编译而成的字节码
0 new Foo
3 dup
4 invokespecial Foo()
7 astore_1
  • 如果一个类没有定义任何构造器的话, Java 编译器会自动添加一个无参数的构造器。
1
2
3
4
5
// Foo 类构造器会调用其父类 Object 的构造器
public Foo();
0 aload_0 [this]
1 invokespecial java.lang.Object() [8]
4 return

子类的构造器需要调用父类的构造器。如果父类存在无参数构造器的话,该调用可以是隐式的,也就是说 Java 编译器会自动添加对父类构造器的调用。但是,如果父类没有无参数构造器,那么子类的构造器则需要显式地调用父类带参数的构造器。

显式调用又可分为两种,一是直接使用“super”关键字调用父类构造器,二是使用“this”关键字调用同一个类中的其他构造器。无论是直接的显式调用,还是间接的显式调用,都需要作为构造器的第一条语句,以便优先初始化继承而来的父类字段。(不过这可以通过调用其他生成参数的方法,或者字节码注入来绕开。)

总而言之,当我们调用一个构造器时,它将优先调用父类的构造器,直至 Object 类。这些构造器的调用者皆为同一对象,也就是通过 new 指令新建而来的对象。

  • 总结:通过 new 指令新建出来的对象,它的内存其实涵盖了所有父类中的实例字段。也就是说,虽然子类无法访问父类的私有实例字段,或者子类的实例字段隐藏了父类的同名实例字段,但是子类的实例还是会为这些父类实例字段分配内存的。
  • PS:似乎刷新了C# new中获得的知识,一直以为父类私有字段不会占有内存。

压缩指针

  • 在 Java 虚拟机中,每个 Java 对象都有一个对象头(object header),这个由标记字段和类型指针所构成。其中,标记字段用以存储 Java 虚拟机有关该对象的运行数据,如哈希码、GC 信息以及锁信息,而类型指针则指向该对象的类。
  • PS:锁信息就是C#中的同步块索引,类型指针就是CLR的类类型(类型指针)。

在 64 位的 Java 虚拟机中,对象头的标记字段占 64 位,而类型指针又占了 64 位。也就是说,每一个 Java 对象在内存中的额外开销就是 16 个字节。以 Integer 类为例,它仅有一个 int 类型的私有字段,占 4 个字节。

  • 为了尽量较少对象的内存使用量,64 位 Java 虚拟机引入了压缩指针的概念(对应虚拟机选项 -XX:+UseCompressedOops,默认开启),将堆中原本 64 位的 Java 对象指针压缩成 32 位的。这样一来,对象头中的类型指针也会被压缩成 32 位,使得对象头的大小从 16 字节降至 12 字节。标记阻断
  • PS:对象头中只有类型指针被压缩,但是标记头没有被压缩,所以是8+4。
  • 被压缩的指针的寻址是以当前指针x2的方式来实现的,因此每个对象的地址都是偶数,进而引发了内存对齐 。对应虚拟机选项 -XX:ObjectAlignmentInBytes,默认值为 8。
  • 默认情况下,Java 虚拟机堆中对象的起始地址需要对齐至 8 的倍数。如果一个对象用不到 8N 个字节,那么空白的那部分空间就浪费掉了。这些浪费掉的空间我们称之为对象间的填充(padding),padding的概念在多数语言中都有。

字段重排列

  • 这是JVM为实现内存对齐而做出的策略
  • 对应 Java 虚拟机选项 -XX:FieldsAllocationStyle,默认值为 1。
  • 如果一个字段占据 C 个字节,那么该字段的偏移量需要对齐至 NC。这里偏移量指的是字段地址与对象的起始地址差值。
    • 以 long 类为例,它仅有一个 long 类型的实例字段。在使用了压缩指针的 64 位虚拟机中,尽管对象头的大小为 12 个字节,该 long 类型字段的偏移量也只能是 16(2*8),而中间空着的 4 个字节便会被浪费掉。
  • 子类所继承字段的偏移量,需要与父类对应字段的偏移量保持一致。
    • 对于使用了压缩指针的 64 位虚拟机,子类第一个字段需要对齐至 4N;而对于关闭了压缩指针的 64 位虚拟机,子类第一个字段则需要对齐至 8N。
    • 实际上就是对应的32位和64位。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class A {
long l;
int i;
}

class B extends A {
long l;
int i;
}

=========================

# 启用压缩指针时,B 类的字段分布
B object internals:
OFFSET SIZE TYPE DESCRIPTION
0 4 (object header)
4 4 (object header)
8 4 (object header)
12 4 int A.i 0
16 8 long A.l 0
24 8 long B.l 0
32 4 int B.i 0
36 4 (loss due to the next object alignment)


# 关闭压缩指针时,B 类的字段分布
B object internals:
OFFSET SIZE TYPE DESCRIPTION
0 4 (object header)
4 4 (object header)
8 4 (object header)
12 4 (object header)
16 8 long A.l
24 4 int A.i
28 4 (alignment/padding gap)
32 8 long B.l
40 4 int B.i
44 4 (loss due to the next object alignment)

伪共享、缓存行填充和CPU缓存

  • 推荐看一下这篇文章《JAVA 拾遗 — CPU Cache 与缓存行
  • 伪共享指的是多个线程同时读写同一个缓存行的不同变量时导致的 CPU 缓存失效,造成的结果是访问效率降低。
  • Java 8 引入了一个新的注释 @Contended,用来解决对象字段之间的虚共享(false sharing)问题。它本质上还是对内存进行填充,从而避免数据出现在同一cache line中,需要开启-XX:-RestrictContended=false。
# Java
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×