探秘 JVM:类加载机制

JVM 的类加载机制描述了类数据从字节码文件加载到内存,并对其进行校验、解析、初始化,并最终成为能够直接被 JVM 使用的 java 数据类型的过程。

类的整个生命周期可以分为 加载、连接(验证、准备、解析)、初始化、使用、卸载 5 个阶段(加载、连接和初始化构成了类加载全过程),其中连接又可以细分为验证、准备、解析 3 个阶段。如下图所示:

image

上述步骤除解析步骤外,对于同一个类而言各个阶段的执行按照图示箭头所指的顺序逐步执行(相邻阶段的结束和开始不严格按照先后顺序,可能存在重叠)。解析阶段在一些情况下会在初始化之后进行,主要是为了支持 java 语言的动态绑定机制。

JVM 规范并未明确触发加载机制的时机,但是规定执行初始化操作 有且只有 6 种 情况(此前一定执行了对相应类的加载、验证,以及准备操作),如下:

  1. 遇到 newgetstaticputstaticinvokestatic 指令,即 new 一个对象,亦或是读写类的静态字段(被 final 修饰的除外),亦或是调用一个类的静态方法。
  2. 利用反射机制对类进行反射调用。
  3. 当初始化一个类时,如果其父类没有被初始化,则先初始化其父类。
  4. 虚拟机启动时,用户需要指定一个驱动类(包含 main 方法的类),JVM 会先初始化这个类。
  5. 使用 JDK 7 的动态语言支持,如果一个 MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic 和 REF_newInvokeSpecial 四种类型的方法句柄,且该方法句柄对应的类没有进行过初始化。
  6. 如果一个类实现的接口中定义了 default 方法,当初始化该类时会触发初始化该接口。

上述 6 种场景称为对一个类型的主动引用,除此之外所有的引用类型的方式都不会触发初始化操作,称为被动引用。

注意: 上述第 3 点对于接口而言并不适用,初始化一个接口或类并不要求其实现的接口全部都完成了初始化,只有在接口的非常量字段被使用时才会初始化该接口,这也是单独规定第 6 条的原因。

关于对象的创建,这里再引申一点,在 java 中显式创建一个对象的方式主要分为 5 种,包括:new 一个对象;克隆机制;反射机制;反序列化机制;调用 Unsafe#allocateInstance 方法。

除了显式创建对象外,虚拟机还会隐式的创建对象,比如创建类的 Class 引用,创建常量对象等。其中,克隆机制和反序列化机制通过直接复制已有的数据来初始化新建对象的实例字段;方法 Unsafe#allocateInstance 则没有初始化实例字段;而 new 语句和反射机制则是通过调用构造器来初始化实例字段。

类加载过程

加载、连接(验证、准备和解析),以及初始化构成了类加载的全过程。

加载

当我们编写的代码被编译成字节码文件之后,虚拟机必须将其装载到内存中才能执行,而加载通俗的说就是将静态字节码二进制流文件加载到内存中的过程,该阶段主要完成 3 件事情:

  1. 通过一个类的全限定名获取该类的二进制字节流,具体从哪里获取没有要求,可以是文件系统、运行时生成,也可以来自网络等;
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
  3. 在内存中生成一个代表该类的 Class 对象,作为方法区中该类各种数据的访问入口。

注意: 一个类的 Class 引用虽然是一个对象,但却存储在方法区,而不是堆内存中。

在加载阶段,对于非数组类型而言,可以通过 JVM 内置的类加载器或用户自定义的类加载器去加载二进制字节流。对于数组类型而言,情况则有所不同, 数组类本身并不通过类加载器创建,而是由 JVM 直接在内存中动态构造 ,但是数组类的元素类型还是需要由类加载器完成加载。

连接

连接阶段可以进一步分为 验证、准备,以及解析 三个阶段,下面就各阶段所执行的工作进行介绍。

验证

验证阶段主要是验证字节码文件中包含的信息是否符合 JVM 规范,并且不会危害 JVM 运行安全,主要从文件格式、元数据、字节码,以及符号引用 4 个层面进行验证,如果验证不通过则抛出 VerifyError 异常。

  1. 文件格式验证 :该阶段主要验证字节流是否符合字节码文件的格式规范,同时保证能够被当前版本的虚拟机处理。这一阶段基于字节流进行验证,是整个验证过程的第一阶段,验证通过之后字节流才能够进入方法区中进行储存,后面三个阶段的验证都是基于方法区中的数据展开验证。
  2. 元数据验证 :该阶段主要是对字节码描述的信息进行语义分析,保证这些消息不违反 java 语言规范,主要是对类的元数据信息进行语义校验。
  3. 字节码验证 :该阶段主要是对类的方法实现进行校验分析,通过数据流分析和控制流分析以确定程序实现是合法且符合逻辑的。这一阶段的验证相当复杂,所以在 JDK 6 之后引入了 StackMapTable 属性,该属性描述了方法体所有基本块开始时本地变量表和操作数栈应有的状态,从而不再需要根据推导来验证这些状态的合法性,只要检查 StackMapTable 即可。
  4. 符号引用验证 :该阶段发生在虚拟机将符号引用转换化为直接引用的时候,这个动作发生在解析阶段,主要是校验引用自身和被引用方的正确性,以保证解析阶段能够正常执行。

验证阶段相对比较耗时,是非常重要却不必要的过程,如果所运行的全部代码都已经被反复使用和验证过,那么可以使用 -Xverify:none 参数关闭大部分的类验证策略,从而缩短虚拟机的加载过程。

准备

准备阶段主要是为类变量(即 static 变量)分配内存并初始化为相应的类型默认值。需要注意的是这里的操作只针对类变量,实例变量会在对象实例化时随着对象一起被分配到堆内存中。此外,这里的赋值是赋类型默认值,我们在代码中显式指定的初始值将会在调用类初始化方法 <clinit> 时(即初始化阶段)完成赋值,但是对于静态常量(被 static final 修饰)在这一步已经具备了真实值。

1
2
3
private static int a = 123;
private static final int b = 456;
private static final int c = 123 + 456;

例如上述代码中的两个变量,在当前阶段 a 对应的值是 0,而 b 对应的值是 456,因为 a 是一个类变量,在本阶段会为其分配内存空间,并初始化为类型默认值(int 类型的默认值为 0),而真正赋值为 123 要等到初始化阶段。然而,b 就不一样,因为 b 是一个静态常量,编译器会在编译阶段就将其赋值为 456,对于 c 也同样如此,编译器在编译阶段就会将其赋值为 579。

解析

解析阶段的目的是将常量池中的符号引用替换为直接引用,主要针对 类或接口、字段、类方法、接口方法、方法类型、方法句柄,以及调用点限定符 7 类符号引用进行解析。

符号引用 以一组符号描述所引用的目标对象,可以是任何形式的字面量,只要使用时能够无歧义定位目标即可。符号引用与虚拟机实现内存布局无关,不同虚拟机接受的符号引用是相同的(因为符号引用是字节码层面定义的),引用的目标也不一定已经加载到内存。在字节码文件中以 CONSTANT_Class_info、CONSTANT_Fieldref_info,以及 CONSTANT_Methodref_info 等类型的常量形式出现。 直接引用 可以是直接指向目标的指针、相对偏移量,或是一个能间接定位目标的句柄。直接引用与虚拟机内存布局相关,不同虚拟机翻译出来的直接引用一般不同,且其引用的目标一定存在于内存。

  • 类或接口符号引用解析

假设当前代码所处的类为 A,现在需要把符号引用 N 解析为对应的类或接口 B 的直接引用,整个解析过程如下:

  1. 如果 B 不是数组类型,那么虚拟机会将代表 N 的全限定名传递给 A 的类加载器去加载整个类 B,加载过程中由于元数据验证、字节码验证的需要,可能会触发其他的类加载操作,一旦出现任何异常则解析阶段失败;
  2. 如果 B 是数组类型,并且数组的元素类型为对象(eg. [Ljava/lang/Integer),则依据 1 中的规则加载数据元素类型,否则由虚拟机生成一个代表此数组维度和元素的数组对象;
  3. 验证 A 是否具备对 B 的访问权限,如果不具备则抛 IllegalAccessError 异常。
  • 字段解析

字段符号解析需要先对字段所属的类或接口的符号引用进行解析,假设这个类或接口是 B,那么字段的解析过程:

  1. 如果 B 本身包含的 简单名称和字段描述符 都与目标相匹配的字段,则返回该字段的直接引用;
  2. 否则,如果 B 实现了接口,将会按照继承关系从下往上递归搜索各个接口,如果发现了相应字段则返回该字段的直接引用;
  3. 否则,如果具备父类(Object 类除外),则递归搜索父类,如果发现了相应字段则返回该字段的直接引用;
  4. 否则,查找失败,抛 NoSuchFieldError

上述过程中对于查找到的字段会进行权限验证,如果不具备访问权限,则抛 IllegalAccessError。

  • 类方法解析

类方法解析也需要先对方法所属的类(不包括接口,类方法和接口方法的解析是分开的)的符号引用进行解析,假设这个类是 B,那么方法的解析过程:

  1. 在 B 中查找是否有 简单名称和描述符 都与目标方法匹配的方法,如果有的话则返回该方法的直接引用;
  2. 否则,在父类中递归查找;
  3. 否则,在 B 实现的接口中递归查找,如果找到说明 B 是一个抽象类,抛 AbstractMethodError;
  4. 否则,查找失败,抛 NoSuchMethodError。

上述过程中对于查找到的方法会进行权限验证,如果不具备访问权限则抛 IllegalAccessError。

  • 接口方法解析

接口方法也需要先对方法所属的接口(不包括类)的符号引用进行解析,假设这个接口是 B,那么方法的解析过程:

  1. 在 B 中查找是否有 简单名称和描述符 都与目标方法匹配的方法,如果有的话则返回该方法的直接引用;
  2. 否则,在父接口中递归查找;
  3. 否则,查找失败,抛 NoSuchMethodError;

接口方法的访问权限都是 public,所以不存在访问权限问题。

初始化

初始化阶段主要是执行类或接口初始化方法 <clinit>,该方法由编译器自动收集类中所有类变量赋值动作和静态语句块(static{...})中的语句合并而成( 如果没有定义类变量和静态语句块,可以不用生成 <clinit> 方法 ),组织顺序按照在源文件中定义的顺序,例如:

1
2
3
4
5
6
7
8
9
10
public class ClinitMethod {

private static int a = 1;

static {
b = 2;
}

private static int b;
}

编译器会自动收集类变量赋值语句和静态代码块生成对应的类初始化方法,编译后的字节码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static {};
descriptor: ()V
flags: ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: iconst_1
1: putstatic #2 // Field a:I
4: iconst_2
5: putstatic #3 // Field b:I
8: return
LineNumberTable:
line 9: 0
line 12: 4
line 13: 8

类变量在 <clinit> 方法中的组织顺序遵循在代码中定义的顺序,需要注意的是 在静态代码块中只能访问定义在静态语句块之前的变量,定义在之后的变量在静态语句块中可以赋值但不能访问 。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ClinitMethod {

private static int a = 1;

static {
System.out.println(a); // OK
b = 2;
System.out.println(b); // ERROR
System.out.println(ClinitMethod.b); // OK
}

private static int b;
}

类初始化方法 <clinit> 不同于构造方法 <init>,不需要在 <clinit> 中隐式或者显式调用父类的初始化方法,JVM 可以保证在执行子类的 <clinit> 方法之前完成对父类的 <clinit> 方法的执行, 因此父类的静态代码块要先于子类执行

接口中虽然不允许使用静态代码块,但是仍然存在变量初始化的赋值操作,所以也会生成 <clinit> 方法。然而,与类不同的是, 接口中的 <clinit> 方法在执行时不要求父接口的 <clinit> 方法执行完毕,只有当使用到一个接口的变量时才会触发执行 <clinit> 方法

方法 <clinit> 是线程安全的,可以将其视为一个同步的方法,当一个线程执行该方法时其它线程会被阻塞,所以一般不推荐在其中编写比较耗时的代码逻辑。

类加载器

类加载器(Class Loader)的作用是用来获取一个全限定类名对应的字节流,对于任何一个类都需要由加载它的类加载器和这个类本身一同确立其在 JVM 中的唯一性。下面的示例演示了对于同一个类,使用自定义类加载器和虚拟机内置类加载器加载之后的场景:

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
ClassLoader loader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
InputStream is = this.getClass().getResourceAsStream(name.substring(name.lastIndexOf(".") + 1) + ".class");
if (null == is) {
return super.loadClass(name);
}
byte[] buffer = new byte[is.available()];
is.read(buffer);
return super.defineClass(name, buffer, 0, buffer.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
};

final String className = ClassLoading.class.getName();

Object obj1 = loader.loadClass(className).newInstance();
System.out.println(obj1.getClass().getName());

ClassLoader classLoader = ClassLoader.getSystemClassLoader();
Object obj2 = classLoader.loadClass(className).newInstance();
Object obj3 = classLoader.loadClass(className).newInstance();
System.out.println("obj2.class equals obj1.class: " + obj2.getClass().equals(obj1.getClass())); // true
System.out.println("obj2.class equals obj3.class: " + obj2.getClass().equals(obj3.getClass())); // false

运行结果:

1
2
3
org.zhenchao.jvm.ClassLoading
obj2.class equals obj1.class: false
obj2.class equals obj3.class: true

可以看到针对同一个类,使用自定义类加载器和应用程序类加载器加载得到的对象所指向的 Class 引用并不是同一个,也就是说在方法区中存在两个不同的 ClassLoading 类的 Class 引用。这证明了在 JVM 中唯一确定一个类,除了类的全限定名之外,还需要考虑加载该类的类加载器。

三层类加载器

站在开发者的角度来看,JVM 类加载器可以分为三层进行组织,由上到下依次是启动类加载器(Bootstrap Class Loader)、扩展类加载器(Extension Class Loader),以及应用程序类加载器(Application Class Loader)。

  • 启动类加载器

启动类加载器负责加载 ${JAVA_HOME}/lib 目录下,或者 -Xbootclasspath 参数指定路径下,且能够被 JVM 识别的类库(仅按照文件名进行匹配)。该类加载器采用 C++ 语言编写,且无法被 java 程序直接引用,如果在编写自定义类加载器时希望指定启动类加载器为父类加载器,直接利用 null 值代替即可。

  • 扩展类加载器

扩展类加载器由 sun.misc.Launcher$ExtClassLoader 类实现,采用 java 语言编写,负责加载 ${JAVA_HOME}/lib/ext 目录下,或者被 java.ext.dirs 系统变量所指定路径中的所有类库。

  • 应用程序类加载器

应用程序类加载器由 sun.misc.Launcher$AppClassLoader 类实现,采用 java 语言编写,上述例子中 ClassLoader#getSystenClassLoader 返回的即是该加载器(所以也叫系统类加载器),负责加载用户类路径(ClassPath)所指定的类库。

双亲委派机制

JVM 中类加载器关系如下图所示,各个类加载器之间是一种父子关系,但是这种父子关系不是以继承来实现,而是使用了组合。

image

当一个类需要被加载时,JVM 采用双亲委派机制为该类寻找合适的类加载器。因为类加载器采用的是装饰模式,即子类加载器会包装父类加载器,所以子类的加载器的功能要比父类更强。除了系统内置的三个类加载器以外,还允许用户自定义类加载器。然而,我们不能保证所有用户都是善意的(比如用户自定义了一个 java.lang.String 类,如果能够覆盖 JDK 自带的 String 类则是一件非常危险的事情)。

为了保证安全性,JVM 会尽量采用上层类加载器去加载类(因为上层加载器相对下层类加载器更加安全),所以当要加载一个类时,当前类加载器就会首先委托父类加载器尝试加载,如果父类加载器有能力加载则会继续向上委托,直到某一个类加载器的父类加载器不能加载时即开始执行真正加载操作。

所有的类加载器中除了启动类加载器是由 C++ 语言实现外,其余的类加载器均由 java 语言实现。下面从源码层面来看一下双亲委派机制的实现:

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
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (this.getClassLoadingLock(name)) { // 同步加锁
// 校验输入的类名,并检查类是否已经被加载过
Class<?> c = this.findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
// 委托给父类加载器进行加载
c = parent.loadClass(name, false);
} else {
// 父类加载器为 null,说明是启动类加载器
c = this.findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found from the non-null parent class loader
}

// 父类加载器加载失败,使用当前类加载器进行加载
if (c == null) {
// If still not found, then invoke findClass in order to find the class.
// 模板方法,由子类实现
c = this.findClass(name);
}
}

// 如果需要,执行解析操作
if (resolve) {
this.resolveClass(c);
}
return c;
}
}

双亲委派机制的规则本来也不复杂,所以源码实现上也比较简单,具体的执行过程如上述代码注释。

我们看到 ClassLoader#loadClass 方法在双亲加载失败时会执行 ClassLoader#findClass 逻辑,这是一个模板方法,也是我们自定义类加载器时推荐覆盖实现的方法。下面的示例自定义了一个类加载器 MyClassLoader:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class MyClassLoader extends ClassLoader {

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] bytes = FileUtils.readFileToByteArray(FileUtils.getFile(
String.format("/home/work/bin/%s.class", name.replaceAll("\\.", File.separator))));
return super.defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException("class not found : " + name, e);
}
}

public static void main(String[] args) throws Exception {
MyClassLoader loader = new MyClassLoader();
Class clazz = loader.loadClass("org.zhenchao.jvm.User");
Object obj = clazz.newInstance(); // 这里如果强转会抛 ClassNotFoundException,因为两个类对应的 Class 对象不一致
System.out.println(obj.getClass().getClassLoader().toString());
}

}

MyClassLoader 的逻辑就是尝试加载我们指定的类。通过覆盖实现 ClassLoader#findClass 方法,我们定义了从本地文件系统加载 User 类字节码文件,并调用 ClassLoader#defineClass 方法由字节数组解析获取类的 Class 对象,从而完成了自定义类加载逻辑。

这里我们加载的 class 文件位于 /home/work/bin 目录下,由前面的讲解我们知道启动类加载器会加载 ${JAVA_HOME}/lib 路径或 -Xbootstrapclasspath 参数指定路径下的类;扩展类加载器会加载 ${JAVA_HOME}/lib/ext 路径下或 java.ext.dirs 指定路径下的类;应用程序类加载器则会加载用户类路径(ClassPath)下的类。一般来说,即使我们自定义了类加载器加载 User 类,但是按照双亲委派原则该类还是会被应用程序类加载器所加载,因为 User 类位于用户类路径下。为了让 MyClassLoader 能够加载该类,这里特意将 User.class 放置在了一个系统类加载器找不到的地方,也就达到了我们的目的。

双亲委派机制虽然是 JVM 默认的,但却不是强制的,所以我们可以破坏该机制。典型破坏双亲委派机制的应用场景就是 Tomcat 的类加载机制,这个我们留到后面再说。下面我们自定义一个 HackClassLoader 类加载器来破坏这一机制,我们需要做的就是覆盖 ClassLoader#loadClass 方法。HackClassLoader 类加载器的实现中会判定当前类的全限定名,如果是 org.zhenchao.jvm 包下面的类就采用 HackClassLoader 类加载器进行加载,否则继续执行双亲委派。具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class HackClassLoader extends ClassLoader {

@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
if (null != name && name.startsWith("org.zhenchao.jvm")) {
try {
File file = FileUtils.getFile(name.replaceAll("\\.", File.separator) + ".class");
byte[] bytes = FileUtils.readFileToByteArray(file);
return super.defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException("class not found : " + name, e);
}
}
return super.loadClass(name, resolve);
}

public static void main(String[] args) throws Exception {
Class clazz = Class.forName("org.zhenchao.jvm.User", true, new HackClassLoader());
Object obj = clazz.newInstance();
System.out.println(obj.getClass().getClassLoader().toString());
}
}

所以,通过覆盖实现 ClassLoader#loadClass 方法是很容易实现对于双亲委派机制的破坏的,有时候这种破坏是有意为之的,而有时候则是不小心造成的错误。为了避免错误的覆盖 ClassLoader#loadClass 方法而破坏双亲委派机制,JDK 提供了 ClassLoader#findClass 方法供我们选择。如果仅仅是希望改变虚拟机检索字节码文件的方式,则完全可以通过覆盖 ClassLoader#findClass 方法予以实现。

最后我们一起来了解一下 Tomcat 对于双亲委派机制的“破坏”。Tomcat 为什么要自定义类加载器呢,我觉得主要有两方面的原因:

  1. 一个 tomcat 实例下可以运行多个 web 应用,需要保证应用之间的依赖不相互干扰。
  2. 提供自动装载的能力,当 WEB-INF/classesWEB-INF/lib 目录下的类发生变化时,WEB 应用程序可以重新载入这些类,而不需要重启服务器。Tomcat 通过设置一个单独的线程来不断检查这些类的时间戳,以便能够及时载入。

关于 tomcat 的类加载机制这里先不展开探讨,留待以后用专门的文章进行说明。

类成员初始化顺序

类成员初始化的基本原则是 先静态,后非静态,先父类,后子类 。初始化顺序可以基本概括为:

  1. 父类静态变量 > 父类静态代码块;
  2. 子类静态变量 > 子类静态代码块;
  3. main 函数;
  4. 父类实例变量 > 父类构造代码块 > 父类构造方法;
  5. 子类实例变量 > 子类构造代码块 > 子类构造方法。

下面定义了一个父类 Parent 和一个子类 Child:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
public class Parent {

public static int spa = 11;

static {
System.out.println("parent static code block");
System.out.println("spa = " + spa);
System.out.println("spb = " + Parent.spb);
}

public static int spb = 12;

static {
System.out.println("spb = " + spb);
}

public int pa = 13;
public int pb = 14;

{
System.out.println("parent code block");
System.out.println("pa = " + pa);
System.out.println("pb = " + pb);
}

public Parent() {
System.out.println("parent constructor");
this.foo();
}

public void foo() {
System.out.println("parent foo");
System.out.println("spa: " + spa);
System.out.println("spb: " + spb);
System.out.println("pa: " + pa);
System.out.println("pb: " + pb);
}
}

public class Child extends Parent {

public static int sca = 21;

static {
System.out.println("child static code block");
System.out.println("sca = " + sca);
System.out.println("scb = " + Child.scb);
}

public static int scb = 22;

static {
System.out.println("scb = " + scb);
}

public int ca = 23;
public int cb = 24;

{
System.out.println("child code block");
System.out.println("ca = " + ca);
System.out.println("cb = " + cb);
}

public Child() {
System.out.println("child constructor");
}

public static void main(String[] args) {
System.out.println("main method");
Child child = new Child();
}

@Override
public void foo() {
System.out.println("child foo");
System.out.println("sca: " + sca);
System.out.println("scb: " + scb);
System.out.println("ca: " + ca);
System.out.println("cb: " + cb);
}

}

以上述代码为例,类中成员的初始化顺序如下:

  1. 当首次创建某个类对象的时候,或者该类的静态字段或静态方法首次被访问时,JVM 需要查找该类的路径以定位该类的字节码文件;
  2. 载入父类的字节码文件(创建对应的 Class 对象),初始化父类 Parent 中的静态字段,同时执行父类中静态代码块 static{...},顺序按照从上到下执行;
  3. 载入子类的字节码文件(创建对应的 Class 对象),初始化子类 Child 中的静态字段,同时执行子类中的静态代码块 static{...},顺序按照从上到下执行;
  4. 进入 main 方法(因为接下去要创建对象,而 main 方法是入口,这也是为什么 main 需要用 static 修饰);
  5. 使用 new 操作符创建对象,首先在堆上为待创建的对象分配足够的存储空间,这块存储空间会被清零,所以自动将类中所有字段设置成对应的类型默认值;
  6. 对 Parent 类中的非静态字段和构造代码块按照从上到下进行初始化(执行构造代码块 {...},将属性设为用户指定的值),每次创建子类对象都会执行一次父类的非静态字段和构造代码块;
  7. 执行 Parent 类的构造方法,这里到底执行父类的哪一个构造方法取决于子类的具体调用;
  8. 对 Child 类中的非静态字段和构造代码块按照从上到下进行初始化(执行构造代码块 {...},将属性设为用户指定的值);
  9. 执行 Child 类的构造方法。

需要注意的几点:

  1. 静态字段和代码块的初始化只在类首次被加载时执行一次,静态字段的初始化不必担心非法向前引用,因为会导致编译报错。
  2. 如果某个方法被子类覆盖了,那么在父类的构造方法中调用这个方法的时候会去调用子类中的这个方法,如果这个方法使用了子类的成员变量,由于这时子类的成员变量还未来得及初始化,就会出现向前引用的问题。所以不要在构造方法中调用可以让子类覆盖的方法,以避免发生向前引用,在构造方法中应该只调用声明为 private 或者 final 的方法。
  3. 如果一个 final 字段在声明的时候进行了初始化,且初始化值是常量或常量表达式,那么该字段在所属类字节码文件还未被加载的时候就已经完成了初始化,如果赋值延迟到构造方法中则另当别论。

参考

  1. Java 虚拟机规范(Java SE 8 版)
  2. 深入理解 java 虚拟机(第 2 版)
  3. 深入理解 java 虚拟机(第 3 版)