Java虚拟机之对象探秘

前篇《Java虚拟机之内存区域》做了一个提纲式的总结,这里将“对象探秘”部分做一个稍稍的展开,包括创建对象的基本流程、对象的内存布局、Class & Klass & KlassKlass浅析以及实例数据重排序等内容。

创建对象的基本流程

Java里创建对象通常有五种方式,new,Class类调用newInstance方法,Constructor类调用newInstance方法,clone方法以及反序列化,后四种反编译后可知使用的是invokevirtual指令来做方法调用,而第一种使用的是new和invokespecial指令。一般的,我们都是用new来创建对象,这里以new为例分析创建对象的基本流程,其大致如下:

  • 检查指令参数能否定位符号引用;
  • 检查符号引用代表的类是否已经被加载、解析和初始化过,没有就要先执行这些过程(《深》P210);
  • 分配内存(CAS / TLAB);
  • 初始化为零值(不包括对象头);
  • 对对象头进行必要设置;
  • 通常,执行方法(由invokespecial决定);

类的初始化自有其它篇章进行介绍,这里需要知道的是,在遇到new等字节码指令时,需要先触发类的初始化。在类加载的连接过程中,准备阶段也会为类变量设置初始化零值,注意区别和类比。

普通字段的初始化器会在每个构造方法中被执行,而且是在写进构造方法内初始化内容执行前执行,但这一切初始化过程发生之前,会先在构造方法中调用父类的方法。

对象内存布局简析

对象在内存中的布局包括三个部分:对象头、实例数据和对齐填充。

为了很好的研究这一部分,采用实验的方式来进行,这里用到了一个小工具jol,本人的Java环境为jdk1.8.0_171,64-bit HotSpot VM。

首先看看官方提供的JOLSample_01_Basic代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class JOLSample_01_Basic {
/*
* This sample showcases the basic field layout.
* You can see a few notable things here:
* a) how much the object header consumes;
* b) how fields are laid out;
* c) how the external alignment beefs up the object size
*/
public static void main(String[] args) throws Exception {
out.println(VM.current().details());
out.println(ClassLayout.parseClass(A.class).toPrintable());
}
public static class A {
boolean f;
}
}

这个例子的作用注释已经说明清楚,这段代码的输出为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Running 64-bit HotSpot VM.
# Using compressed oop with 3-bit shift.
# Using compressed klass with 3-bit shift.
# WARNING | Compressed references base/shifts are guessed by the experiment!
# WARNING | Therefore, computed addresses are just guesses, and ARE NOT RELIABLE.
# WARNING | Make sure to attach Serviceability Agent to get the reliable addresses.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
org.openjdk.jol.samples.JOLSample_01_Basic$A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 1 boolean A.f N/A
13 3 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 3 bytes external = 3 bytes total

前篇已经总结过,这里再提一下:对象头(object header)分为两个部分:一部分称为Mark Word,用于存储对象自身的运行时数据;另一部分是类型指针:用来确定对象是哪个类的实例。(当然,在数组里,对象头还记录了数组大小)

对以上输出结果做一个简要说明:

Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]对应:[Oop(Ordinary Object Pointer), boolean, byte, char, short, int, float, long, double]大小。

OFFSET是基于首地址偏移量,SIZE是大小,因为本人采用的Java虚拟机是64位,那么object header中Mark Word占据64位,也就是8个单位的大小,类型指针占据4个单位大小,所以一共占据12个单位。instance data占据一个单位大小,由于对象大小必须是8字节的整数倍,所以还要对齐填充,此时填充三个单位。

对象内存布局深入

首先贴出RednaxelaFX的PPT中的相关章节:

01

图1

02

图2

03

图3

04

图4

虽然图中的有些描述并不适用较新的Java版本——实际上这些图依据的是JDK6(或之前)的HotSpot VM,但大体来说,我们仍然可以从中有所收获。

图1描述了Mark Word存储了哪些信息,最后一句话提到了并非一次性记录所有信息;

图2中提到的PermGen在Java8里面已经被删除掉了(见文末参考链接),但通过图中的讲解,我们还是能get到有用的信息。

比如,Java对象实例中对象头存储的klass并不是Java语言级别的那个java.lang.Class类的实例。接下来就重点写写这方面的内容。

Class & Klass & KlassKlass

常常能听到有人问,对象的Class实例到底存在哪里。回答这个问题之前,先要搞清楚,想要问的真的是java.lang.Class实例吗?

我们继续欣赏RednaxelaFX的分享:

05

图5

如果是第一次看到这张图是不是想感慨Java果然是面向对象语言:instanceOopDesc –> instanceKlass –> instanceKlassKlass –> KlassKlass。(后三个归为klassOopDesc包装层,包装在GC管理的klassOopDesc对象中)

RednaxelaFX的解释:

HotSpot VM里,Klass其实是用于描述能被GC的对象的类型信息的元数据对象。在JDK8之前的HotSpot VM里,类元数据存在由GC管理的PermGen区里。这些xxxKlass对象(例如instanceKlass的实例)自身也是被GC管理的,所以也需要有Klass对象去描述它们,叫做xxxKlassKlass。然后它们又…所以就有了KlassKlass这个终极的描述xxxKlassKlass对象的东西。

而:

从JDK8开始,既然元数据不由GC直接管理了,Klass这系对象就都不需要再被KlassKlass所描述,所以KlassKlass就全去除了。

看到这里可能会问,为什么元数据不由GC直接管理了,Klass就不需要KlassKlass描述了呢?

RednaxelaFX在留言区回答了热心网友:

因为HotSpot的GC框架通过Klass来获得被GC管理的对象的结构信息;在Metaspace里内存管理是人工写特化的代码做的而不是GC自动做的,就不需要这个KlassKlass结构来描述Klass了。

关于Klass所在的Metaspace的回收,RednaxelaFX也点到:

Metaspace有特殊的收集逻辑,跟GC可以联动但是不直接由GC管理

回到本小节起初提到的问题,也许一开始,我们并没有区分InstanceKlass和java.lang.Class,这两个是不同的东西。java.lang.Class对象从来都是存储在普通的Java堆中。那么它们彼此有什么关系呢?

每个Java对象的对象头里,_klass字段会指向一个VM内部用来记录类的元数据用的InstanceKlass对象;InsanceKlass里有个_java_mirror字段,指向该类所对应的Java镜像——java.lang.Class实例。HotSpot VM会给Class对象注入一个隐藏字段“klass”,用于指回到其对应的InstanceKlass对象。这样,klass与mirror之间就有双向引用,可以来回导航。java.lang.Class实例并不负责记录真正的类元数据,而只是对VM内部的InstanceKlass对象的一个包装供Java的反射访问用。

对象实例数据重排序

我们继续看图3中的内容,“宽度相同的字段总是相邻分配”?显然宽度相同的字段肯定不一定按顺序声明初始化,那么如果要满足引号中给出的要求,则需要重排序。下面本人写一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class CumTest02 {
public static void main(String[] args) throws Exception {
out.println(VM.current().details());
out.println(ClassLayout.parseClass(A.class).toPrintable());
}
public static class A {
int i01;
int i02;
long l;
int i03;
}
}

首先按照一般逻辑分析对象的内存分布情况:

  • Mark Word:八个单位;
  • 类型指针:四个单位;
  • i01:四个单位;
  • i02:四个单位;
  • l:八个单位;
  • i03:四个单位;

布局应该是这样的:

重排序01

实际输出结果如下:

1
2
3
4
5
6
7
8
9
org.openjdk.jol.samples.CumTest02$A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 int A.i01 N/A
16 8 long A.l N/A
24 4 int A.i02 N/A
28 4 int A.i03 N/A
Instance size: 32 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

没有对齐填充,实例的大小也只是32字节,也就是说,产生了重排序,布局见下图:

重排序02

图中l在i02和i03之前,遵照的规则(排序的先后顺序)和Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]列出的顺序相反。i01在l之前是因为已经排好一部分后,剩下的空间能容纳size满足的实例数据,尽管这个数据并不是后续数据中最大的。

对基类是否生成对象的判断

创建派生类对象的过程中,基类会生成对象吗?

首先这个问题可以通过实验轻松验证,但是此问题是在本人看图3想到的,自然就用图3的逻辑稍微判断一下。

在图3中,有这么一句:“笼统的说,基类声明的实例字段会出现在派生类声明的实例字段之前。” 所以基类字段的数据是会在派生类对象中存储一份的,想象一下,如果每一个派生类生成实例时,都需要基类创建一个对象,那么字段等数据就会既存储在基类又存储在派生类中,造成资源浪费,在如今这个追求性能优化极致的时代,一款商业级虚拟机怎么会犯这种让人不能忍受的“错误”?

所以基类并没有生成对象,只是通过super()调用了构造器。

为了判断是否理解了本篇涉及的一些内容,这里举个例子,看看输出是多少:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Base {
private String baseName = "base";
public Base() {
callName();
}
public void callName() {
System.out.println(baseName);
}
static class Sub extends Base {
private static String baseName = "sub";// 删去static后运行再观察结果
public void callName() {
System.out.println(baseName);
}
}
public static void main(String[] args) {
Base b = new Sub();
}
}

这个小问题,涉及到对象的创建流程,涉及到基类是否生成对象等等,注意观察注解部分更改后的输出结果。

注:设置VM options参数为-XX:+TraceClassLoading,可以追踪类的加载过程。

鸣谢

看过《深入理解Java虚拟机》的读者对RednaxelaFX这个名字不会感到陌生,这篇小文引用了很多他在某平台分享的内容,得益于这些分享,本人对对象内存的布局有了更清晰的理解和认识,在此由衷感谢。

参考

RednaxelaFX回答01

RednaxelaFX回答02

Java8内存模型—永久代(PermGen)和元空间(Metaspace):通过实验印证永久代和元空间在不同Java版本下存在与否