For the complete documentation index, see llms.txt. This page is also available as Markdown.

JVM 如何加载类

本文转载自 深入拆解JVM

我们知道,一个java程序,从写完到运行需要经过几个过程。从JVM视角来看,执行 Java 代码首先需要将它编译而成的 class 文件加载到 JVM 中。加载后的 Java 类会被存放于方法区(Method Area)中。实际运行时,虚拟机会执行方法 区内的代码。

那么,JVM到底是如何加载Java Class的?

从 class 文件到内存中的类,按先后顺 序需要经过加载、链接以及初始化三大步骤。

Java 语言的类型可以分为两大类:基本类型(primitive types)和引用类型 (reference types).Java 将引用类型细分为四种:类、接口、数组类和泛型参数。泛型参数在编译过程会被擦除,数组类是由 JVM直接生成的,其他两种则有对应的字节流。字节流就是常见的Java编译器生成的 class文件。

加载

加载,是指查找字节流,并且据此创建类的过程。前面提到,对于数组类来说,它并没有对应的 字节流,而是由 JVM直接生成的。对于其他的类来说,JVM则需要借助类加载器 来完成查找字节流的过程。

以盖房子为例,村里的 小明 要盖个房子,那么按照流程他得先找个建筑师,跟他说想要设计一个房型,比如说“一室、一厅、一卫”。你或许已经听出来了,这里的房型相当于类,而建筑师,就相当于类加载器。

村里有许多建筑师,他们等级森严,但有着共同的祖师爷,叫启动类加载器(boot class loader)。启动类加载器是由 C++ 实现的,没有对应的 Java 对象,因此在 Java 中只能用 null 来指代。换句话说,祖师爷不喜欢像 小明 这样的小角色来打扰他,所以谁也没有祖师爷的 联系方式。

除了启动类加载器之外,其他的类加载器都是 java.lang.ClassLoader 的子类,因此有对应的 Java 对象。这些类加载器需要先由另一个类加载器,比如说启动类加载器,加载至 JVM 中,方能执行类加载。

村里的建筑师有一个潜规则,就是接到单子自己不能着手干,得先给师傅过过目。师傅不接手的情况下,才能自己来。在 JVM中,这个潜规则有个特别的名字,叫双亲委派模型。每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。

在 Java 9 之前,启动类加载器负责加载最为基础、最为重要的类,比如存放在 JRE 的 lib 目录 下 jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)。除了启动类加载器之外, 另外两个重要的类加载器是扩展类加载器(extension class loader)和应用类加载器 (application class loader),均由 Java 核心类库提供。

扩展类加载器的父类加载器是启动类加载器。它负责加载相对次要、但又通用的类,比如存放在 JRE 的 lib/ext 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类)。

应用类加载器的父类加载器则是扩展类加载器。它负责加载应用程序路径下的类。(这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。

Java 9 引入了模块系统,并且略微更改了上述的类加载器1。扩展类加载器被改名为平台类加载 器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动 类加载器加载之外,其他的模块均由平台类加载器所加载。

除了由 Java 核心类库提供的类加载器外,我们还可以加入自定义的类加载器,来实现特殊的加载方式。举例来说,我们可以对 class 文件进行加密,加载时再利用自定义的类加载器对其解密。

除了加载功能之外,类加载器还提供了命名空间的作用。这个很好理解,打个比方,咱们这个村不讲究版权,如果你剽窃了另一个建筑师的设计作品,那么只要你标上自己的名字,这两个房型就是不同的。

在 JVM中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节 流,经由不同的类加载器加载,也会得到两个不同的类。在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。

链接

链接,是指将创建成的类合并至 JVM中,使之能够执行的过程。它可分为验证、准备以及解析三个阶段。

验证阶段的目的,在于确保被加载类能够满足 JVM的约束条件。这就好比 小明 需要将 设计好的房型提交给市政部门审核。只有当审核通过,才能继续下面的建造工作。

通常而言,Java 编译器生成的类文件必然满足 JVM的约束条件。因此,这部分我留到讲 解字节码注入时再详细介绍。

准备阶段的目的,则是为被加载类的静态字段分配内存。Java 代码中对静态字段的具体初始 化,则会在稍后的初始化阶段中进行。过了这个阶段,咱们算是盖好了毛坯房。虽然结构已经完 整,但是在没有装修之前是不能住人的。 除了分配内存外,部分 JVM还会在此阶段构造其他跟类层次相关的数据结构,比如说用 来实现虚方法的动态绑定的方法表。

在 class 文件被加载至 JVM之前,这个类无法知道其他类及其方法、字段所对应的具体 地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java 编译器会生 成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。

举例来说,对于一个方法调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接收参数类型以及返回值类型的符号引用,来指代所要调用的方法。

解析阶段的目的,正是将这些符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以 及初始化。)

如果将这段话放在盖房子的语境下,那么符号引用就好比“小明 的房子”这种说法,不管它存 在不存在,我们都可以用这种说法来指代 小明 的房子。实际引用则好比实际的通讯地址,如果 我们想要与 小明 通信,则需要启动盖房子的过程。

JVM规范并没有要求在链接过程中完成解析。它仅规定了:如果某些字节码使用了符号 引用,那么在执行这些字节码之前,需要完成对这些符号引用的解析。

初始化

在 Java 代码中,如果要初始化一个静态字段,我们可以在声明时直接赋值,也可以在静态代码 块中对其赋值。 如果直接赋值的静态字段被 final 所修饰,并且它的类型是基本类型或字符串时,那么该字段便 会被 Java 编译器标记成常量值(ConstantValue),其初始化直接由 JVM完成。除此 之外的直接赋值操作,以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为。

类加载的最后一步是初始化,便是为标记为常量值的字段赋值,以及执行方法的过程。Java 虚 拟机会通过加锁来确保类的方法仅被执行一次。

只有当初始化完成之后,类才正式成为可执行的状态。这放在我们盖房子的例子中就是,只有当 房子装修过后,小明 才能真正地住进去。

那么,类的初始化何时会被触发呢?JVM 规范枚举了下述多种触发情况:

  1. 当虚拟机启动时,初始化用户指定的主类;

  2. 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类; 3. 当遇到调用静态方法的指令时,初始化该静态方法所在的类;

  3. 当遇到访问静态字段的指令时,初始化该静态字段所在的类;

  4. 子类的初始化会触发父类的初始化;

  5. 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触 发该接口的初始化;

  6. 使用反射 API 对某个类进行反射调用时,初始化这个类;

  7. 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。

这段代码是在著名的单例延迟初始化例子中,只有当调用 Singleton.getInstance 时,程序才会访问 LazyHolder.INSTANCE,才会触发对 LazyHolder 的初始化(对应第 4 种情况),继而新建一个 Singleton 的实例。 由于类初始化是线程安全的,并且仅被执行一次,因此程序可以确保多线程环境下有且仅有一个 Singleton 实例。

Last updated