从 1 开始学 JVM 系列 | JVM 类加载器(一)

从 1 开始学 JVM 系列

类加载器,对于很多人来说并不陌生。我自己第一次听到这个概念时觉得有点“高大上”,觉得只有深入 JDK 源码才会触碰到 ClassLoader,平时都是传闻中的东西。

今天,就让我们一起来探索一下这”传闻“中的类加载器,看看它是何方神圣。

类生命周期

在正式聊类加载器之前,我们先正本清源,看看类的生命周期是什么样的。

为了方便后续解读,下面我贴了一张图展示了类的生命周期的 7 个步骤。

image

对于前 5 步,简单来说就是加载、链接、初始化,这是一个类最关键的加载步骤。

对照着上图,我们逐一来解释一下。

  1. 加载(Loading):找 Class 文件
  2. 验证(Verification):验证格式、依赖
  3. 准备(Preparation):静态字段、方法表
  4. 解析(Resolution):符号解析为引用
  5. 初始化(Initialization):构造器、静态变量赋值、静态代码块
  6. 使用(Using)
  7. 卸载(Unloading)

1.加载

所谓的加载,就是查找字节流,并根据字节流创建类的过程

  • 对于数组类,它没有对应的字节流,是由 Java 虚拟机直接生成的。
  • 对于其他的类,Java 虚拟机需要借助类加载器来完成查找字节流的过程。

以盖房子为例,Jack 想要要盖个房子,按照流程他要先找个建筑师,跟他说想要设计一个房型,比如说“一房一厅两卫”。这里的房型就相当于类,而建筑师就相当于类加载器。

启动类加载器

建筑界有许多的建筑师,他们等级分明,但都有着共同的祖师爷,叫「启动类加载器(boot class loader)」。由于启动类加载器是由 C++ 实现的,没有对应的 Java 对象,因此在 Java 中只能用 null 来指代。

// jdk中 BootstrapClass 是 native 实现
private native Class<?> findBootstrapClass(String name);

但是,祖师爷不喜欢像 Jack 这样的小角色来打扰他,所以谁也没有祖师爷的联系方式,也就相当于 null 指代。

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

双亲委派模型

建筑师界有个潜规则:接到单子后自己不能着手干,得先给师傅过过目。师傅不接手的情况下,才能自己来。即等级高的师傅有优先选择权。

在 Java 虚拟机中,这个潜规则就是「双亲委派模型」。每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到请求的类时,这个类加载器才会尝试去加载。

加载器类型

加载器类型(Java 9 之前) 作用 加载路径
启动类加载器 负责加载最为基础、最为重要的类 比如存放在 JRE 的 lib 目录下 jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)
扩展类加载器 (extension class loader) 父类加载器是启动类加载器。它负责加载相对次要、但又通用的类 比如存放在 JRE 的 lib/ext 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类)
应用类加载器 (application class loader) 父类加载器则是扩展类加载器。它负责加载应用程序路径下的类。 默认情况下,应用程序中包含的类便是由应用类加载器加载的 这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径。

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

除了由 Java 核心类库提供的类加载器外,我们还可以加入「自定义类加载器」,实现特殊的加载方式。

举个例子,我们可以对 class 文件进行加密,加载时再利用自定义类加载器对其解密。

类加载器的命名空间

除了加载功能之外,类加载器还提供了「命名空间」的作用。

打个比方,假设建筑界不讲版权,如果某个人剽窃了另一个建筑师的设计作品,只要你标上自己的名字,这两个房型就是不同的。

在 Java 虚拟机中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。

image

在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。

2.链接

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

    1. 「验证」阶段的目的,在于确保被加载类能够满足 Java 虚拟机的约束条件

    这就好比 Jack 需要将设计好的房型提交给市政部门审核。只有当审核通过,才能继续下面的建造工作。

    通常而言,Java 编译器生成的类文件必然满足 Java 虚拟机的约束条件。

  • 2.「准备」阶段的目的,则是为被加载类的静态字段分配内存。Java 代码中对静态字段的具体初始化,则会在稍后的初始化阶段中进行。

    过了这个阶段,算是盖好了毛坯房。虽然结构已经完整,但没有装修之前不能住人。

    除了分配内存外,部分 Java 虚拟机会在此阶段构造其他跟类层次相关的数据结构,比如说用来实现虚方法的动态绑定的方法表。

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

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

  • 3.「解析」阶段的目的,正是将这些符号引用解析成为实际引用

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

    • 符号引用就好比“Jack 的房子”这种说法,不管它存在不存在,我们可以用这种说法指代 Jack 的房子。

    • 实际引用则好比实际的通讯地址,如果我们想要与 Jack 通信,则需要启动盖房子的过程。

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

3.初始化

静态字段的赋值

Java 中如果要初始化一个静态字段,我们可以在声明时直接赋值,也可以在静态代码块中对其赋值。

  • 如果直接赋值的静态字段被 final 所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被 Java 编译器标记成「常量值(ConstantValue)」,其初始化直接由 Java 虚拟机完成
  • 除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为 < clinit >

image

初始化

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

只有当初始化完成之后,类才正式成为可执行的状态。

在盖房子的例子中,相当于房子装修好了,Jack 可以真正拎包入住了。

那么,类的初始化何时会被触发呢?

JVM 规范枚举了下述多种触发情况:

  1. 当虚拟机启动时,初始化用户指定的主类,就是启动执行的 main 方法所在的类;

  2. 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;

  3. 当遇到调用静态方法的指令时,初始化该静态方法所在的类;

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

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

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

    和继承类似(5、6 条都是面向对象)

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

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

    和反射类似(7、8 条都是反射相关)

// 单例延迟初始化例子
public class Singleton {
  private Singleton() {}
  private static class LazyHolder {
    static final Singleton INSTANCE = new Singleton();
  }
  public static Singleton getInstance() {
    return LazyHolder.INSTANCE;
  }
}

只有当调用 Singleton.getInstance 时,程序才会访问 LazyHolder.INSTANCE,才会触发对 LazyHolder 的初始化(对应第 4 种情况),继而新建一个 Singleton 的实例。

由于类的初始化线程安全,并且仅被执行一次,因此程序可以确保多线程环境下有且仅有一个 Singleton 实例。

那么,什么时候不会初始化,但可能会加载?

  1. 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。

  2. 定义对象数组,不会触发该类的初始化。

    直到 new 才触发

  3. 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。

    常量不是变量

  4. 通过类名获取 Class 对象,不会触发类的初始化,Hello.class 不会让 Hello 类初始化。

  5. 通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。Class.forName (“jvm.Hello”)默认会加载 Hello 类。

  6. 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作(加载了,但不初始化)。

流程概览

为了方便查看,我画了一张流程图演示上面的步骤。

image

END
------------

如果你觉得有用,欢迎关注 「小尹探世界」 微信公众号,希望我们一起打造一个有知识、有温度、有趣点、有价值的频道,探索技术之外的广袤世界。

类的唯一性
posted @ 2021-09-17 08:17  Alan-Yin  阅读(409)  评论(0编辑  收藏  举报