[9] 类加载子系统
[9] 类加载子系统

1. 类加载的时机

1.1 类的生命周期

类从被加载到 JVM 内存开始,到卸载出内存为止,它的整个生命周期包括以下 7 个阶段:
  • 加载
  • 验证
  • 准备
  • 解析
  • 初始化
  • 使用
  • 卸载
验证、准备、解析 3 个阶段统称为连接
notion image
加载、验证、准备、初始化和卸载这 5 个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始(注意是「开始」,而不是「进行」或「完成」),而解析阶段则不一定:它在某些情况下可以在初始化后再开始,这是为了支持 Java 语言的运行时绑定。

1.2 类加载过程中「初始化」开始的时机

《Java虚拟机规范》没有强制约束类加载过程的第一阶段(即:加载)什么时候开始,但对于「初始化」阶段,有着严格的规定。有且仅有 5 种情况必须立即对类进行「初始化」:
  • 在遇到 newputstaticgetstaticinvokestatic 字节码指令时,如果类尚未初始化,则需要先触发其初始化。
  • 对类进行反射调用时,如果类还没有初始化,则需要先触发其初始化。
  • 初始化一个类时,如果其父类还没有初始化,则需要先初始化父类。
  • JVM 启动时,用于需要指定一个包含 main() 方法的主类,JVM 会先初始化这个主类。
  • 当使用 JDK 7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStaticREF_putStaticREF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类还没初始化,则需要先触发其初始化。
  • 当一个接口中定义了 JDK 8 新加入的默认方法时,如果这个接口的实现类发生了初始化,则也需要先将接口进行初始化。
这 5 种场景中的行为称为对一个类进行主动引用,除此之外,其它所有引用类的方式都不会触发初始化,称为被动引用

1.3 被动引用演示

Demo1
/**
 * 被动引用 Demo1:
 * 通过子类引用父类的静态字段,不会导致子类初始化。
 *
 * @author ylb
 *
 */
class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }

    public static int value = 123;
}

class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init!");
    }
}

public class NotInitialization {

    public static void main(String[] args) {
        System.out.println(SubClass.value);
        // SuperClass init!
    }

}
对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。

Demo2
/**
 * 被动引用 Demo2:
 * 通过数组定义来引用类,不会触发此类的初始化。
 *
 * @author ylb
 *
 */

public class NotInitialization {

    public static void main(String[] args) {
        SuperClass[] superClasses = new SuperClass[10];
    }

}
这段代码不会触发此类的初始化,但会触发 [L 全限定名 这个类的初始化,它由 JVM 自动生成,直接继承自 java.lang.Object,创建动作由字节码指令 newarray 触发。

Demo3
/**
 * 被动引用 Demo3:
 * 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
 *
 * @author ylb
 *
 */
class ConstClass {
    static {
        System.out.println("ConstClass init!");
    }

    public static final String HELLO_BINGO = "Hello Bingo";

}

public class NotInitialization {

    public static void main(String[] args) {
        System.out.println(ConstClass.HELLO_BINGO);
    }

}
编译通过之后,常量存储到 NotInitialization 类的常量池中,NotInitialization 的 Class 文件中并没有 ConstClass 类的符号引用入口,这两个类在编译成 Class 之后就没有任何联系了。

1.4 接口的加载过程

接口加载过程与类加载过程稍有不同:
当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,当真正用到父接口的时候才会初始化。

2. 类加载的过程

类加载过程包括 5 个阶段:加载、验证、准备、解析和初始化。

2.1 加载

加载的过程
「加载」是「类加载」过程的一个阶段,不能混淆这两个名词。在加载阶段,JVM 需要完成 3 件事:
  • 通过类的全限定名获取该类的二进制字节流。
    • 对于 Class 文件,JVM 没有指明要从哪里获取、怎样获取。除了直接从编译好的 .class 文件中读取,还有以下几种方式:
    • 从 zip 包中读取,如 jar、war 等
    • 从网络中获取,如 Applet
    • 通过动态代理技术生成代理类的二进制字节流
    • 由 JSP 文件生成对应的 Class 类
    • 从数据库中读取,如 有些中间件服务器可以选择把程序安装到数据库中来完成程序代码在集群间的分发
  • 将二进制字节流所代表的静态结构转化为方法区的运行时数据结构。
  • 在内存中创建一个代表该类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

非数组类」与「数组类」加载比较
  • 非数组类加载阶段可以使用系统提供的引导类加载器,也可以由用户自定义的类加载器完成,开发人员可以通过定义自己的类加载器控制字节流的获取方式(如重写一个类加载器的 loadClass() 方法)
  • 数组类本身不通过类加载器创建,它是由 JVM 直接创建的,再由类加载器创建数组中的元素类。

注意事项
  • 《Java虚拟机规范》未规定 Class 对象的存储位置,对于 HotSpot VM 而言,Class 对象比较特殊,它虽然是对象,但存放在方法区中。
  • 加载阶段与连接阶段的部分内容交叉进行,加载阶段尚未完成,连接阶段可能已经开始了。但这两个阶段的开始时间仍然保持着固定的先后顺序。

2.2 验证

验证的重要性
验证阶段确保 Class 文件的字节流中包含的信息符合当前 JVM 的要求,并且不会危害 JVM 自身的安全。

验证的过程
  • 文件格式验证:验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的 JVM 处理,验证点如下:
    • 是否以魔数 0XCAFEBABE 开头
    • 主次版本号是否在当前 JVM 处理范围内
    • 常量池是否有不被支持的常量类型
    • 指向常量的索引值是否指向了不存在的常量
    • CONSTANT_Utf8_info 型的常量是否有不符合 UTF8 编码的数据
    • ......
  • 元数据验证:对字节码描述信息进行语义分析,确保其符合 Java 语法规范。
  • 字节码验证:本阶段是验证过程中最复杂的一个阶段,是对方法体进行语义分析,保证方法在运行时不会出现危害 JVM 的事件。
  • 符号引用验证:本阶段发生在解析阶段,确保解析正常执行。

2.3 准备

准备阶段是正式为类变量(或称「静态成员变量」)分配内存并设置初始值的阶段。这些变量(不包括实例变量,实例变量会随着对象一起分配到 Java 堆中)所使用的内存都在方法区中进行分配。
初始值「通常情况下」是数据类型的零值(0, null...),假设一个类变量的定义为:
那么变量 value 在准备阶段过后的初始值为 0 而不是 123,因为这时候尚未开始执行任何 Java 方法。
notion image
存在「特殊情况」:如果类字段的字段属性表中存在 ConstantValue 属性(被 final 修饰的 static),那么在准备阶段 value 就会被初始化为 ConstantValue 属性所指定的值,假设上面类变量 value 的定义变为:
那么在准备阶段 JVM 会根据 ConstantValue 的设置将 value 赋值为 123

2.4 解析

解析阶段是 JVM 将常量池内的符号引用替换为直接引用的过程。
事实上,解析操作往往会伴随着 JVM 在执行完初始化之后再执行。
  • 符号引用就是一组符号来描述引用的目标。符号引用的字面量形式明确定义在《Java虚拟机规范》的 Class 文件格式中。
  • 直接引用就是直接指向目标的指针,相对偏移量或一个间接定位到目标的句柄。
解析动作主要针对类、接口、字段、类方法、接口方法或方法类型等。对应常量池中的 CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_info

2.5 初始化

类初始化阶段是类加载过程的最后一步,是执行类构造器 <clinit>() 方法的过程。
<clinit>() 方法不需要定义,是由 javac 编译器自动收集类中的所有类变量的赋值动作和静态语句块(static {} 块)中的语句合并而来的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。
静态语句块中只能访问定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但不能访问。如下方代码所示:
非法前向引用变量
如果没有类变量和静态代码块,也不会有 <clinit>()
<clinit>() 方法与类的构造函数(即在 JVM 视角中的实例构造器 <init>() 方法)不同,<clinit>() 方法不需要显式调用父类构造器,JVM 会保证在子类的 <clinit>() 方法执行之前,父类的 <clinit>() 方法已经执行完毕。
由于父类的 <clinit>() 方法先执行,意味着父类中定义的静态语句块要优先于子类的变量赋值操作。如下方代码所示:
<clinit>() 方法不是必需的,如果一个类没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成 <clinit>() 方法。
接口中不能使用静态代码块,但接口也需要通过 <clinit>() 方法为接口中定义的静态成员变量显式初始化。但接口与类不同,接口的 <clinit>() 方法不需要先执行父类的 <clinit>() 方法,只有当父接口中定义的变量使用时,父接口才会初始化。
JVM 会保证一个类的 <clinit>() 方法在多线程环境中被正确加锁、同步。如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit>() 方法。

3. 类加载器

类加载器只负责 Class 文件的加载,至于是否可以运行,则由执行引擎决定。
notion image
// 获取应用程序(系统)类加载器
ClassLoader appClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(appClassLoader);  // sum.misc.Launcher$AppClassLoader@18b4aac2

// 获取其上层父加载器:扩展类加载器
ClassLoader extClassLoader = appClassLoader.getParent();
System.out.println(extClassLoader);  // sum.misc.Launcher$ExtClassLoader@1540e19d

// 获取其上层父加载器:启动类加载器
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader);  // null,扩展类加载器和启动类加载器并无父子关系,只是委托关系。

// 对于用户程序自定义的类
ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
System.out.println(classLoader);  // sum.misc.Launcher$AppClassLoader@18b4aac2,应用程序(系统)类加载器是默认的类加载器。
获取类加载器代码样例

3.1 判断类是否「相等」

任意一个类,都由加载它的类加载器和这个类本身一同确立其在 JVM 中的唯一性,每一个类加载器,都有一个独立的类名称空间。
因此,比较两个类是否「相等」,只有在这两个类的全限定名一致且是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个 JVM 加载,只要加载它们的类加载器不同,那么这两个类就必定不相等。
这里的「相等」,包括代表类的 Class 对象的 equals() 方法、isInstance() 方法的返回结果,也包括使用 instanceof 关键字做对象所属关系判定等情况。

3.2 启动类加载器(Bootstrap ClassLoader)

站在 JVM 的角度来看,只存在两种不同的类加载器:
  • 一种是启动类加载器(Bootstrap ClassLoader,引导类加载器),这个类加载器使用 C++ 语言实现(HotSpot VM),是 JVM 自身的一部分。
  • 另外一种就是其他所有的类加载器,这些类加载器都由 Java 语言实现,独立存在于 JVM 外部,并且全都继承自抽象类 java.lang.ClassLoader
JVM 申请好内存空间后,会创建一个启动类加载器实例,负责加载 JVM 运行时所需的基本系统级别的类,如 java.lang.Stringjava.lang.Object 等等。
启动类加载器也负责将存放在 <JAVA_HOME>/lib 目录中的,并且能被 JVM 识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到 JVM 内存中。
  • rt.jar:Java 基础类库,也就是 Java Doc 里面看到的所有的类的 Class 文件。
  • tools.jar:是系统用来编译类的时候用到的,即执行 javac 的时候用到。
  • dt.jar:是关于运行环境的类库,主要是 Swing 包。
启动类加载器并不继承自 java.lang.ClassLoader,也没有父加载器。
启动类加载器还会去加载扩展类加载器应用程序类加载器。出于安全考虑,启动类加载器只会加载包名为 javajavaxsun 等开头的类。
URL[] urls = sum.misc.Launcher.getBootstrapClassPath().getURLs();
for (URL url : urls) {
    // ...
}
通过代码获取启动类加载器的加载路径
JVM 必须知道一个类型是由启动类加载器加载的,还是由自定义类加载器加载的。如果是自定义类加载器加载的,JVM 会将这个类加载器的一个引用作为类型信息的一部分,保存到方法区中

3.3 扩展类加载器(Extension ClassLoader)

扩展类加载器sun.misc.Launcher$ExtClassLoader 实现,派生于 ClassLoader 类,负责加载 <JAVA_HOME>/lib/extjava.ext.dirs 系统属性中所指定目录中的所有类库,开发者可以直接使用扩展类加载器。
一般我们都认为扩展类加载器的父类加载器是启动类加载器,但是其实他们之间并没有 Java 语言中的父子关系,只是在扩展类加载器找不到要加载的类的时候会去委托启动类加载器去加载。
通过如下代码可以知道扩展类加载器的父加载器为 null
ClassLoader.getSystemClassLoader().getParent().getParent();  // null

3.4 应用程序类加载器(Application ClassLoader)

应用程序类加载器sun.misc.Launcher$AppClassLoader 实现,派生于 ClassLoader 类,其父类加载器为扩展类加载器,由于这个类加载器是 ClassLoadergetSystemClassLoader() 方法的返回值,所以一般也称它为「系统类加载器」。它负责加载环境变量中用户类路径(classpath)或系统属性 java.class.path 下所指定的类库。
开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器,Java 应用的类都是由它来完成加载。

3.5 自定义类加载器(Custom ClassLoader)

概念上,将所有派生于抽象类 ClassLoader 的类加载器都划分为自定义类加载器

为什么要用自定义类加载器?
  • 隔离加载类:例如使中间件的 JAR 包与应用程序 JAR 包不冲突。
  • 修改类加载的方式:启动类加载器必须使用,其他可以根据需要自定义加载。
  • 扩展加载源。
  • 防止反编译:对字节码进行加密,自定义类加载器实现解密。

实现步骤
  1. 通过继承抽象类 java.lang.ClassLoader 类的方式,实现自己的类加载器。
  1. JDK 1.2 之前,继承并重写 loadClass() 方法。
  1. JDK 1.2 之后,建议把自定义的类加载逻辑写在 findClass() 方法中。

如果一个类是由自定义类加载器加载的,那么 JVM 还会将这个类加载器的一个引用作为类型信息的一部分,保存到方法区中

4. 双亲委派模型

4.1 什么是双亲委派模型

双亲委派模型是描述类加载器之间的层次关系。它要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。即把请求交由父类处理,它是异种任务委派模式。
父子关系一般不会以继承的关系实现,而是以组合关系来复用父加载器的代码

4.2 工作过程

  1. 如果一个类加载器收到了类加载请求,它并不会自己先去加载。而是把这个请求委托给父类的加载器去执行。
  1. 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将达到顶层的启动类加载器。
  1. 如果父类的加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务(找不到所需的类),子加载器才会尝试自己去加载,这就是双亲委派模型。
notion image
java.lang.ClassLoader 中的 loadClass() 方法中实现了该过程:
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    // 首先,检查请求的类是否已经被加载过了
    Class c = findLoadedClass(name);
    if (c == null) {
        try {
            if (parent != null) {
                c = parent.loadClass(name, false);
            } else {
                c = findBootstrapClassOrNull(name);
            }
        } catch (ClassNotFoundException e) {
            // 如果父类加载器抛出ClassNotFoundException
            // 说明父类加载器无法完成加载请求
        }
        if (c == null) {
            // 在父类加载器无法加载时
            // 再调用本身的findClass方法来进行类加载
            c = findClass(name);
        }
    }
    if (resolve) {
        resolveClass(c);
    }
    return c;
}
这段代码先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的 loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父类加载器加载失败, 抛出 ClassNotFoundException 异常的话,才调用自己的 findClass() 方法尝试进行加载。

4.3 为什么使用双亲委派模型

java.lang.Object 这些存放在 rt.jar 中的类,无论使用哪个类加载器加载,最终都会委派给最顶端的启动类加载器加载,从而才能使得不同加载器加载的 Object 类都是同一个。
相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object 的类,并放在 classpath 下,那么系统将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无法保证。

4.4 沙箱安全机制

保证对 Java 核心源代码的保护。

4.5 破坏双亲委派模型

在实际的应用中,双亲委派模型解决了 Java 基础类统一加载的问题,但确实存在着一定的缺席。
为什么要破坏双亲委派模型?
  • 历史原因(第一次破坏):兼容 JDK 1.2 以前的用户自定义类加载器代码。
  • 实现 SPI 机制(第二次破坏):JDK 中的基础类作为用户典型的 API 被调用,但是也存在需要被 API 调用用户的代码的情况,典型的如 JDBC。
  • 对程序「动态性」的追求(第三次破坏):热代码替换(Hot Swap)、模块化部署(Hot Deployment)等。
  • JDK 9 模块化构建(第四次破坏):在 JDK 9 中引入了模块化系统,扩展类加载器被平台类加载器替代,同时整个 JDK 都基于模块化进行构建。

SPI 机制
SPI(Service Provider Interface,服务提供者接口),主要是应用于厂商自定义组件或插件中,在 java.util.ServiceLoader 的文档中有详细的介绍。为了实现在模块装配的时候能不在程序里动态指明,这就需要一种服务发现机制。
我们系统里抽象的各个模块,往往有很多不同的实现方案,比如日志模块、xml 解析模块、jdbc 模块等方案。面向的对象的设计里,我们一般推荐模块之间基于接口编程,模块之间不对实现类进行硬编码。一旦代码里涉及具体的实现类,就违反了可拔插的原则,如果需要替换一种实现,就需要修改代码。
为了实现在模块装配的时候能不在程序里动态指明,Java SPI 就提供这样的一种服务发现机制:为某个接口寻找服务实现的机制。有点类似 IOC 的思想,就是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要。

线程上下文类加载器(Thread Context ClassLoader)
线程上下文类加载器(Thread Context ClassLoader)可以通过 java.lang.Thread 类的 setContextClassLoader() 方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
通过线程上下文类加载器,程序就可以使用这个线程上下文类加载器去加载所需的 SPI 服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,就如启动类加载器拿到了本该由应用程序类加载器加载的类,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但也是无可奈何的事情。
Java 中涉及 SPI 的加载基本上都采用这种方式来完成,例如 JNDI、 JDBC、JCE、JAXB 和 JBI 等。不过,当 SPI 的服务提供者多于一个的时候,代码就只能根据具体提供者的类型来硬编码判断,为了消除这种极不优雅的实现方式,在 JDK 6 时,JDK 提供了 java.util.ServiceLoader 类,以 META-INF/services 中的配置信息,辅以责任链模式,这才算是给 SPI 的加载提供了一种相对合理的解决方案。

如果不想破坏双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。而如果想破坏双亲委派模型则需要重写 loadClass() 方法或通过线程上下文类加载器。
典型的打破双亲委派模型的框架和中间件有 JDBC、Tomcat、OSGi 等。

4.6 JDK 9 后的类加载器委派关系

notion image
从 JDK 1.2 到 JDK 8 以来,类加载其遵循:三层类加载器架构和双亲委派模型,但在 JDK 9 中引入了模块化系统,同时类加载器不再遵循双亲委派模型,并发生了一些变化。
JDK 9 之前的类加载器继承架构
JDK 9 之前的类加载器继承架构
JDK 9 及以后的类加载器继承架构
JDK 9 及以后的类加载器继承架构
启动类加载器现在是在 JVM 内部和 Java 类库共同协作实现的类加载器,尽管在 JDK 9 有了 BootClassLoader 这样的 Java 类,但为了与之前的代码保持兼容,所有在获取启动类加载器的场景(譬如 Object.class.getClassLoader())中仍然会返回 null 来代替,而不会得到 BootClassLoader 的实例。
扩展类加载器(Extension ClassLoader)被平台类加载器(Platform ClassLoader)取代。同时整个 JDK 都基于模块化进行构建(其中原来的 rt.jartools.jar 被拆分成数十个 JMOD 文件),其中的 Java 类库已满足了可扩展的需求,所以无须再保留 <JAVA_HOME>\lib\ext 目录,之前使用这个目录或者 java.ext.dirs 系统变量来扩展 JDK 功能的机制已经没用存在的价值了。
平台类加载器和应用程序类加载器都不再派生自 java.net.URLClassLoader,如果有程序直接依赖了这种继承关系,或者依赖了 URLClassLoader 类的特定方法,那代码很可能会在 JDK 9 及更高版本的 JDK 中崩溃。现在启动类加载器、平台类加载器、应用程序类加载器全都继承于 jdk.internal.loader.BuiltinClassLoader,在 BuiltinClassLoader 中实现了新的模块化架构下类如何从模块中加载的逻辑,以及模块中资源可访问性的处理。
JDK 9 中同时取消了 <JAVA_HOME>\jre 目录,因为随时可以组合构建出程序运行所需的 JRE,举例:
jlink -p $JAVA_HOME/jmods --add-modules java.base --output jre
只使用 java.base 模块中的类型,组合 JRE。