Back
Featured image of post JVM 学习笔记 12 - 类加载机制

JVM 学习笔记 12 - 类加载机制

导航页

上篇文章讲解了 JVM 字节码,本节将会介绍 JVM 类加载机制。

类加载机制是 JVM 的运行时动态加载机制。会增加一定的运行时开销,但提供了极高的拓展性和灵活性。比如,IDEA、Eclipse 等 IDE 的插件系统,都是基于此实现的。

类加载的时机

一个类型从被加载到 JVM 内从中,到卸载出内存为止,一共会经历几个阶段:

  • 加载(Loading)
  • 链接(Linking)
    • 验证(Verification)
    • 准备(Preparation)
    • 解析(Resolution)
  • 初始化(Initialization)
  • 使用(Using)
  • 卸载(Unloading)

其中,加载、验证、准备、初始化、卸载,这五个阶段的开始顺序是决定了的。但解析阶段则不一定,也可以在初始化之后再开始,这是为了运行时绑定的特性。这里是说开始,基于性能考虑和实际情况,这些阶段通常都是交叉进行的。

关于什么时候要开始类加载中的「加载」阶段,《JVM 规范》并未强制要求。但对于「初始化」阶段,《JVM 规范》要求了几种情况前必须完成:

  • 遇到 new getstatic putstatic invokestatic 指令,应该先初始化。常见场景有
    • new 关键字创建对象时
    • 读取或设置一个类型的静态字段时(public final static String 这种已经在常量池的静态字段除外)
    • 调用一个类型的静态方法时
  • 使用反射(具体而言为使用了 java.lang.reflect)时
  • 若类有父类,应该先初始化其的父类
  • 包含 main() 方法的类,应当首先初始化
  • JDK 7 后,java.lang.invoke.MethodHandle 实例最后解析结果若为 REF_getStatic REF_putStatic REF_invokeStatic REF_newInvokeSpecial 四种类型的句柄,且对应类没有初始化,应当先初始化
  • 当接口使用了 JDK 8 新加入的 default 时,应当先初始化对应的接口

《JVM 规范》对以上六种情况的限定词是「有且仅有」。这六种场景称为主动引用。除此之外的所有引用类型方式,都不会触发初始化,称为被动引用。

类加载过程

类加载过程,即指加载、验证、准备、解析、初始化这五步。不包含使用和卸载。

加载

「加载」是「类加载」中的一个阶段,注意不要混淆了。

JVM 在加载阶段需要完成三件事:

  1. 通过全限定类名(Qualified Class Name)获取二进制字节流
  2. 将字节流转换成方法区的运行时数据结构
  3. 生成对应的 java.lang.Class,作为方法区数据的访问入口

《JVM 规范》的要求比较抽象,并不涉及具体如何加载,零活度相当大。例如第一条,并没有限定从何处获取二进制字节流,因此使用者可以从多种方式加载类文件:

  • 从 ZIP 压缩包中获取,比如 JAR、EAR、WAR 格式
  • 从网络中获取,比如 Web Applet
  • 运行时计算生成,比如动态代理。
    • java.lang.reflect.Proxy 就是典型例子,可以为特定接口生成形式为 *$Proxy 的代理类
    • ASM、Javassit、CGLIB 各种字节码生成框架更是数不胜数
  • 由其他文件生成,比如 JSP
  • 从数据库中读取
  • 从加密文件中获取

加载阶段是开发者可控性最强的阶段。既可以使用 JVM 内置的引导类加载器,也可以由用户自定义的类加载器完成。可以根据自己的想法来赋予应用程序获取运行代码的动态性。

但需要注意一个特例,数组类。数组类不通过类加载器创建,而是 JVM 直接在内存中动态构造出来的。一个数组类的加载,遵循以下规则:

  • 如果数组的组件类型(Component Type,去掉一个维度的类型,比如 [Ljava.lang.String 的组件类型就是 Ljava.lang.String)是引用类型,那就递归调用加载过程去加载该组件类型。数组 C 将被表示在该类加载器的类命名空间上。(类型和类加载器一起才能确定唯一性)
  • 若不是引用类型(如 [I / int[] 的组件类型为 int),JVM 将会把数组 C 与引导类加载器关联
  • 数组类的可访问性和组件类型的可访问性一致。若组件类型不是引用类型,可访问性将默认为 public

加载结束后,字节流就会按照具体 JVM 实现存储在方法区(该过程完全自定义,不受约束)。之后,则需要实例化一个对应的 java.lang.Class 对象,将其作为访问方法区的外部接口。

当然,加载和链接阶段是交叉进行的。加载阶段尚未完成时,链接阶段可能就开始了。

验证

Java 是相对安全的编程语言。Java 无法做到访问数组边界,跳到不存在的代码行之类的事情。但 Class 文件并不一定是由 Java 源码编译而来。让一只猴子随机敲打 0 或 1,总有一天也能敲出正常运行的 Hello World。所以 JVM 不能对类文件完全信任,可能存在错误或有恶意企图的字节码流,从而导致整个系统受到攻击或崩溃。

验证阶段很重要,决定了 JVM 是否能承受恶意代码攻击。早期 JVM,运行时验证的工作量很大,耗费了很多尊荣。但规范却相当模糊和笼统。仅仅列举了一些静态和结构化的约束,比如若不符合类文件格式,应当抛出一个 java.lang.VerifyError 等。

直到 2011 年《JVM 规范》SE 7 版本出世,验证阶段才被详细描述(从不到 10 页增加到 130 页)。

验证阶段大致分为四个阶段:文件格式验证、元数据验证、字节码验证、符号引用验证。

文件格式验证

这一阶段主要验证字节流是否符合类文件规范,并且是接受的版本。包括一下验证点:

  • 0xCAFEBABE 开头
  • 主、次版本号在 JVM 接受范围内
  • 常量类型受支持(检查常量 tag)
  • 常量索引值应当存在且符合类型
  • CONSTANT_Utf8_info 型的常量应当符合 UTF-8 编码

以上内容只是沧海一粟,实际的验证点比列举的多得多。该阶段主要保证字节流能正确加载到方法区。只有该阶段会读取并操作字节流。

元数据验证

根据字节码描述分析语义,保证类文件符合《JVM 规范》,可以进行的验证包括:

  • java.lang.Object 应都有父类
  • 继承应该合法,不可继承 final
  • 非抽象类应该实现父类或接口中要求的所有方法
  • 类中的字段不可与父类产生矛盾

字节码验证

这是最复杂的一个阶段。主要是验证方法体(Code 属性),保证代码不会危害虚拟机安全:

  • 保证数据类型规范,不能出现需要 long 类型,却放了一个 int 的情况
  • 保证跳转指令只能在方法体内跳转
  • 保证类型转换有效,不可把一个对象赋给子类数据类型,甚至是毫不相关的数据类型

就算类文件通过了这一步骤,也不能保证它是安全的。和停机问题(Halting Problem)类似,通过程序检查程序,是不能做到绝对准确无误的。

由于这一阶段的高度复杂性,在 JDK 6 以后,Javac 和 JVM 联合优化了这一过程。将尽可能多的验证所需措施,挪到编译时。新增了 StackMapTable 属性,理论上只需要检查该属性的合法性即可。节省了大量的验证时间。

符号引用验证

该阶段检查类对外部符号引用的可访问性:

  • 全限定类名应当有对应的类
  • 应当存在对应的字段名称、类型等
  • 可访问性修饰符(private protected public <package>)是否允许当前类访问

若该验证失败,JVM 会抛出 java.lang.IncompatibleClassChangeError 异常。典型的有:java.lang.IllegalAccessErrorjava.lang.NoSuchFieldErrorjava.lang.NoSuchMethodError 等。

准备

准备阶段即为类中定义的静态变量分配内存,并设置初始值的阶段。从概念上讲,这些内存应当都分配到方法区中。

首先,此时分配的仅包括类变量,不包括实例变量。另外,这里说的初始值是指默认的零值。和定义局部变量默认的零值一致。而在代码中定义的值,会在 <clinit>() 方法中调用 putstatic 完成。

解析

解析阶段,JVM 会将符号引用转为直接引用,也就是将 CONSTANT_Class_info CONSTANT_Fieldref_info CONSTANT_Methodref_info 等类型常量解析的过程。

  • 符号引用(Symbolic References):以一组符号来描述引用的目标,可以是任何形式的字面量,只要能无歧义的定位到目标即可。
  • 直接引用(Direct References):可以直接指向目标的指针、相对偏移量或句柄。同一个符号引用,常常在不同的虚拟机实现中,对应不同的直接引用。

《JVM 规范》只要求了在哪些指令之前需要解析,所以虚拟机实现可以根据需要,是 lazyload 或在加载时就解析完。

类似的,对方法和字段的访问,也会在解析阶段中检查可访问性。其中的约束规则已是常识,就不再赘述了。

对同一个符号引用请求多次是很常见的,除了 invokedynamic 以外,JVM 都会对第一次解析结果缓存。避免重复创建。invokedynamic 本来就是用来运行时动态创建的,所以自然不需要缓存。

解析动作主要针对类/接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符 7 类符号引用,分别位于常量池 CONSTANT_Class_info CONSTANT_Fieldref_info CONSTANT_Methodref_info CONSTANT_InterfaceMethodref_info CONSTANT_MethodType_info CONSTANT_MethodHandle_info CONSTANT_Dynamic_infoCONSTANT_InvokeDynamic_info 8 种类型。下面将介绍前 4 种。而后面 4 种是为了动态语言,所以会在讲 invokedynamic 再涉及。

类或接口解析

若当前代码处于类 \(D\),需要把一个未解析的符号引用 \(N\) 解析为一个类或接口 \(C\) 的直接引用。那么 JVM 需要经历以下步骤:

  1. \(C\) 不是数组类型,那么 JVM 会将 \(N\) 的全限定类名传递给 \(D\) 的类加载器去加载 \(C\)。在加载过程中,需要递归加载其他类,一旦加载过程中出现了任何异常,解析都会宣告失败。
  2. \(C\) 是数组类型,并且数组的元素类型是对象,也就是 \(N\) 的描述符类似 [Ljava.lang.Integer,将会按照第一点的规则加载数组元素类型。需要加载的就是 java.lang.Integer 接着由虚拟机生成一个代表该数组维度和元素的数组对象。
  3. 若两步都无一场,那么 \(C\) 已经称为了一个有效的类或接口了,但在解析完成前,还需要验证 \(D\) 是否具备 \(C\) 的访问权限。如果发现不具备访问权限,将抛出 java.lang.IllegalAccessError
  • 额外的,若在 JDK 9 之后,还需要检查模块化访问权限。若 \(D\)\(C\) 的访问权限,需要满足以下规则之一:
    • \(C\) public,和 \(D\) 处于一个模块。
    • \(C\) public,不与 \(D\) 处于一个模块,但是 \(C\) 所处模块允许访问 \(D\) 的模块访问
    • \(C\) 不是 public,但和 \(D\) 处于一个包中

字段解析

遇到一个未解析过的字段符号引用,首先会解析字段表 class_index 索引项对应的 CONSTANT_Class_info 类型常量。如果成功解析,将该字段所属的类/接口用 \(C\) 表示,后续操作为:

  1. \(C\) 包含了简单名称和字段描述符都与目标相匹配的字段,则返回该字段的直接引用,查找结束。
  2. 否则,如果 \(C\) 中实现了接口,则会按照继承关系从上到下搜索接口,对各个接口执行第一步。
  3. 否则,如果 \(C\) 继承了父类,则会按照继承关系从上到下搜索父类,对各个父类执行第一步。
  4. 否则,查找失败,抛出 java.lang.NoSuchFieldError

方法解析

和字段解析类似,需要先查找 class_index 索引项对应的方法所属类或接口的符号引用,若解析成功,我们同样用 \(C\) 表示,后续操作为:

  1. 由于类文件和接口的方法符号引用是分开的,所以如果在类中发现 class_index 的索引是接口的话,会直接抛出 java.lang.IncompatibleClassChangeError 异常。
  2. 之后,会在 \(C\) 中查找是否有简单名称和描述符都与目标匹配的方法,如有则返回对应的直接应用,查找结束。
  3. 否则,就在父类中查找,若有则返回这个方法的直接引用,查找结束。
  4. 否则,在 \(C\) 实现的接口列表及它们的父接口之中递归查找,是否有简单名称和描述符都与目标匹配的方法,如果存在匹配的方法,说明 \(C\) 为抽象类,抛出 java.lang.AbstractMethodError 并结束
  5. 否则,查找失败,抛出 java.lang.NoSuchFieldError

最后,如果查找过程返回了直接引用,将会验证权限,如果未通过,则抛出 java.lang.IllegalAccessError 异常。

接口方法解析

同样也需要先解析 class_index 所指的常量,如果解析成功,用 \(C\) 表示该接口,并按照以下步骤检索:

  1. 与类解析相反,若在 class_index 中发现 \(C\) 是类而非接口,抛出 java.lang.IncompatibleClassChangeError 异常。
  2. 否则,在 \(C\) 中查找是否有简单名称和描述符都与目标匹配的方法,若有则返回,查找结束。
  3. 否则,递归查询接口,直到 java.lang.Object 中为止,若有匹配的返回直接引用,查找结束
  4. 对于规则 3,由于接口允许多重继承,所以若父接口有存在名称和描述符都相同的方法,那么会返回其中任意一个并结束查找。《JVM 规范》并未继续限定应当返回哪个。但很多编译器会按照更严格的约束,拒绝编译这种代码,来避免不确定性。
  5. 否则,宣告方法查找失败,抛出 java.lang.NoSuchMethodError

JDK 9 之前接口不需要检查可访问性,因为必须是 public 的,但模块化系统之后就需要检查了。若不能访问,将会抛出 java.lang.IllegalAccessError

初始化

这是类加载的最后一个步骤。此时 JVM 才真正开始运行类中的代码。

在准备阶段时,静态字段已经赋了一次零值,此时 JVM 会调用编译器生成的 <clinit>() 方法,赋为代码中的初值。

  • <clinit>() 方法,是由编译器收集所有类变量的赋值动作和 static {} 块合并生成的。编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问之前的变量,而不能访问之后的。但却允许赋值。
public class Test {
  static {
    i = 0;
    System.out.println(i); // 将会报错, 提示「非法向前引用」
  }
  static int i = 1;
}
  • <clinit>()<init>() 方法不同,它不需要显式调用父类构造器,因为 JVM 会保证父类先被加载。因此,JVM 中第一个执行 <clinit>() 方法的类型一定是 java.lang.Object
  • 由于 <clinit>() 方法先执行,也就意味着父类中定义的静态语句块,会比子类先执行。以下代码将会输出 2:
static class Parent {
  public static int A = 1;
  static {
    A = 2;
  }
}

static class Sub extends Parent {
  public static int B = A;
}

static class Main {
  public static void main(String[] args) {
    System.out.println(Sub.B);
  }
}
  • <clinit>() 方法对于类或接口不是必须的,如果没有 static {} 或静态变量,那么编译器可以不生成它。
  • 接口虽然不运行静态语句块,但仍然有变量初始化的复制操作,因此也有 <clinit>() 方法。但接口和类不同,不需要先执行父接口的 <clinit>() 方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。对于接口的实现类也是这样的。
  • JVM 保证了 <clinit>() 的线程安全,若多个线程同时初始化一个类,只有一个线程会执行该方法,其他线程则需要阻塞等待初始化完成。所以应当避免在 static {} 块中调用耗时操作。

类加载器

类加载器(Class Loader)是 JVM 平台的一大优势。最初用于 Java Applet 基础,然而如今大浪淘沙,浏览器上的 Applet 虽然淘汰了,但类加载器在类层次划分、OSGi、热部署、代码加密等领域却大放异彩。

类与类加载器

类加载器虽然只用于实现类的加载操作,但对程序的影响却不止步于此。

对于任意一个类,要判断它的唯一性,必须将类型本身和类加载器一起比较。也就是说,比较两个类是否「相等」,必须在是一个类加载器的前提下进行;即使两个类来源于同一个类文件,被同一个 JVM 加载,但只要类加载器不同,那这两个类就必定不相等。

这里的相等,包括 Class 对象的 equals() isAssignableFrom() isInstance() 方法,也包括了 instanceof 方法做从属关系判断等各种情况。如果没有注意到类加载器的影响。在某些情况下可能会产生令人困惑的结果。以下代码演示了不同类加载器的影响:

package com.example.test

import java.io.IOException

fun main() {
  Class.forName("com.example.test.Test")
  val test = Test()
  val any = TestClassLoader.loadClass("com.example.test.Test").getDeclaredConstructor().apply {
    this.isAccessible = true
  }.newInstance()
  println(any is Test)
  println(test is Test)
}

class Test {
  companion object {
    @JvmStatic
    val static = kotlin.run {
      println("Loaded")
    }
  }
}

object TestClassLoader : ClassLoader() {
  override fun loadClass(name: String): Class<*> {
    try {
      val fileName = name.substring(name.lastIndexOf('.') + 1) + ".class"
      val input = javaClass.getResourceAsStream(fileName) ?: return super.loadClass(name)
      val b = ByteArray(input.available())
      input.read(b)
      return defineClass(name, b, 0, b.size)
    } catch (e: IOException) {
      throw ClassNotFoundException(name)
    }
  }
}

输出:

Loaded
Loaded
false
true

双亲委派模型

从 JVM 视角来看,只有两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),该类加载器由 JVM 实现(具体而言,HotSpot VM 的启动类加载器使用 C++ 实现),是虚拟机自身的一部分;而其他所有的类加载器,在编译后都是字节码形式,独立存在于虚拟机外部,并全都继承自抽象类 java.lang.ClassLoader

在开发视角,类加载器自然应该被更细致得划分。自 JDK 1.2 依赖,Java 一直保持着三层类加载器、双亲委派的类加载结构,尽管在 JDK 9 模块化系统出现后,出现了一些调整,但主体结构还是没有改变的。

本节内容针对 JDK 8 之前的 JVM 介绍。

对于这个时期的应用,多数会使用 3 个系统提供的类加载器来加载:

  • 启动类加载器(Bootstrap Class Loader):该类加载器负责加载存在 $JAVA_HOME/lib 目录,或者被 -Xbootclasspath 参数指定的路径中存放的,而且 JVM 能够识别的(按照文件名识别,如 rt.jar tools.jar)类库加载到 JVM 内存中。启动类加载器无法被 Java 程序引用,用户在编写自定义类加载器时,如果要把加载请求委派给引导类加载器处理,直接使用 null 代替即可,以下代码摘自 java.lang.ClassLoader.getClassLoader(),展示了 null 值代表引导类加载器的通用规则:
// Returns the class's class loader, or null if none.
static ClassLoader getClassLoader(Class<?> caller) {
  // This can be null if the VM is requesting it
  if (caller == null) {
    return null;
  }
  // Circumvent security check since this is package-private
  return caller.getClassLoader0();
}
  • 拓展类加载器(Extension Class Loader):负责加载 $JAVA_HOME/lib/ext 中的文件。在 JDK 9 中被删除

  • 应用程序类加载器(Application Class Loader):也叫系统加载器(System Class Loader):用于加载用户类路径上所有的类库,开发者同样也可以使用该类加载器。如果程序中没有定义过该加载器,一般情况下这就是默认的类加载器。

JDK 9 之前的类加载都是由着三个类加载器互相配合来完成的,如果有必要,还可以增加自定义的类加载器来进行拓展,典型的就是加载磁盘之外的类文件,或者通过类加载器实现类隔离、重载等功能。

类加载器之间的层次关系使用双亲委托模型(Parents Delegation Model),它要求除了顶层的类加载器之外,其余的类都应当有自己的父加载器(不是指继承关系,是指代理上的父类)。

该模型的工作流程是:如果一个收到了类加载请求,它首先不会尝试自己加载,而是将请求委派给父类。每一个层次的加载器都是如此。因此所有的加载请求最终都应该传送到最顶层的启动类加载器中。只有当父加载器反馈自己无法完成加载请求(搜索范围中没有找到需要的类)时,子加载器才会尝试自己加载。

该流程有一个显而易见的好处,就是具备了一种带有优先级的层次关系。例如类 java.lang.Object 无论哪个类加载器要加载这个类,最终都是委托给启动类加载器,因此能够保证 java.lang.Object 的同一性。反之,若没有使用双亲委派模型,都由自己加载的话,可能就会出现多个不同的 Object 类,最基础的行为也就无从保证,应用程序会变得一片混乱。

该模型的实现其实很简单:

protected 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) {
      // 说明父类无法完成加载请求
    }

    if (c == null) {
      // 父类无法加载时,再调用本身的 findClass 方法加载
      c = findClass(name);
    }
  }
  if (resolve) {
    resolveClass(c);
  }
  return c;
}

值得一提,双亲委派模型并不是一个强制的标准,而是推荐的做法。所以后来根据需求,双亲委派模型会被破坏许多次。

破坏双亲委派模型

第一次破坏

第一次「被破坏」发生在双亲委派模型出现之前——即 JDK 1.2 面世以前。由于双亲委派模型 JDK 1.2 才被加入,但类加载器的概念和抽象类 java.lang.ClassLoader 在 Java 第一个版本中就已存在,面对已经存在的用户自定义类加载器。Java 设计者在引入双亲委派模型时,不得不做出一些妥协,为了兼容这些代码,无法再以技术手段避免 loadClass() 被子类覆盖的可能。只能添加一个新的 protected 方法 findClass(),并引导用户编写类加载逻辑时,尽可能去重写新方法,而不是更改 loadClass()。上节我们已经分析过 loadClass()。按照其中的逻辑,如果父类加载失败,会自动调用自己的 findClass() 方法来完成加载。

第二次破坏

这次是由于自身模型的缺陷导致的。双亲委派很好地解决了,各个类加载器协作时基础类型的一致性问题。基础类型往往总是作为被用户代码继承、调用的 API 存在,但程序设计往往没有绝对不变的规则。

但如果有基础类型需要调回用户的代码,怎么办?

一个典型的例子是 JNDI 服务,它是很基础的 Java 服务,由启动类加载器加载。但 JNDI 的目的是为了查找资源并集中管理,需要通过 SPI 接口(Service Provider Interface)调用其他应用程序代码。然而问题来了,启动类加载器是绝不可能认识、加载这些代码的。

为了解决这个问题。Java 设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context Class Loader)。这个类可以通过 java.lang.Thread 类的 setContextClassLoader() 方法设置,若创建线程时未被设置,它将会从父线程中继承一个,如果全局范围都未设置,那么默认就是应用程序类加载器。

如此一来,JNDI 就可以使用该加载器去加载所需的 SPI 服务代码,是一种父类加载器请求子类加载器完成加载的行为。实际上是打破了双亲委派模型,逆向使用类加载器。Java 中涉及 SPI 的加载都采用这种方式完成,例如 JNDI、JDBC、JCE、JAXB、JBI 等。

当 SPI 提供者多于一个时,代码就只能根据提供者的类型硬编码判定。所以在 JDK 6 中,新增了 java.lang.ServiceLoader 类,以 META-INF/services 中的配置信息,加上责任连模式,才算给 SPI 加载提供了相对合理的方式。

第三次破坏

为了满足代码热替换、模块热部署等需求。双亲委派模型第三次被破坏了。

用户希望应用程序像电脑外设一样,接上鼠标、U 盘,无需重启即可使用。

Java 急需模块化系统。OSGi 就是由 IBM 主导的模块化系统。它实现类热加载的关键,就是自定义的类加载器机制。每一个程序模块(OSGi 中称作 Bundle)都有一个类加载器,当需要替换 Bundle,就把 Bundle 连同类加载器一起替换掉,实现热替换。OSGi 不再使用双亲委派模型推荐的树状结构,而是更加复杂的网状结构。OSGi 按以下顺序进行类搜索:

  1. java.* 委派给父类加载器
  2. 否则,将委派列表名单内的类,委派给父类加载器
  3. 否则,将 Import 列表中的类,委派给 Export 这个类的 Bundle 的类加载器
  4. 否则,查找当前 Bundle 的 ClassPath,用自己的类加载器加载
  5. 否则,查找类是否在自己的 Fragment Bundle 中,如果在,则委派给 Fragment Bundle 的类加载器
  6. 查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载
  7. 否则,类查找失败

上面的查找顺序中,只有开头两点符合双亲委派模型,其余的类查找都是在平级的类加载器中进行的。

Java 模块化系统

JDK 9 中引入的 Java 模块化系统(JPMS,Java Platform Module System)实现了模块化的关键目标——可配置的封装隔离机制,JVM 也对类加载架构做了相应调整。现在 Java 的模块定义还包括以下内容:

  • 依赖其他模块的列表
  • 导出包的列表,即其他模块可以使用的列表
  • 开放包的列表,即其他模块可反射访问模块的列表
  • 使用的服务列表
  • 提供服务的实现列表

可配置的封装机制,首先要解决 JDK 9 之前基于类路径来查找依赖的可靠性问题。此前,如果类路径中缺失了运行时依赖,那只能在运行时才能爆出异常。而如果使用了 JPMS 封装,那么就可以显式指定一来,能够在启动时就能验证依赖关系,很大程度上减少了由于类型依赖而引发的运行时异常。

可配置的封装隔离机制还解决了原来类路径上跨 JAR 的 public 修饰符访问类型。JDK 9 中的 public 修饰符不再意味着所有地方的代码都可以访问,模块提供了更精细的可访问性控制,必须明确声明其中哪一些 public 类型可以被其他模块访问。

兼容性

JDK 9 提出了类路径(ClassPath)和模块路径(ModulePath)的概念。简单的来说,某个库是模块还是传统的 JAR 包,只取决于它存放于哪种路径上。只要是放在类路径上的 JAR 文件,无论其中是否包含模块化信息(module-info.class 文件),都会被当作传统的 JAR 来对待;相应地,只要放在模块化路径上的 JAR 文件,即使没有使用 JMOD 后缀,甚至不包含模块化信息,也会被当作模块来看待。

通过以下规则,可以保证老版本的 JAR 不经修改运行在高版本 JVM 中:

  • 所有类路径下的 JAR 文件和资源文件,都视作自动打包在匿名模块(Unnamed Module)里,几乎没有任何隔离,它可以看到和使用类路径上的所有包、JDK 系统模块中所有的导出包,以及模块路径中导出的包。

  • 模块路径下的具名模块,只能访问到它依赖定义中列明的模块和包,匿名模块里所有内容,对于具名模块都是不可见的。即具名模块看不见传统 JAR 包的内容

  • 如果把一个传统 JAR 包放到模块路径,它就会变成一个自动模块(Automatic Module)。尽管不包含模块化信息,但自动模块将自动依赖整个模块路径中的所有模块,因此可以访问到所有模块导出的包,自动模块也默认导出自己所有的包

此外,JPMS 的目标仅仅是隔离包。不包含解决多版本冲突问题、替换 OSGi 等目标。Oracle 希望维持一个足够简单化的模块化系统。

模块化下的类加载器

首先,拓展类加载器被移除,平台类加载器取而代之。这是一个很正常的变动,因为 JDK 已经全部基于模块化构建了,其中的 Java 类库自然就满足了可拓展需求。$JAVA_HOME/lib/ext 目录就不再必要了。同时 $JAVA_HOME/lib/jre 也被取消了,因为随时都可以通过组合构建出程序运行所需的 JRE 来,假设我们只使用 java.base 模块中的类型,那么随时可以通过以下命令打包出一个 JRE:

jlink -p $JAVA_HOME/jmods --add-modules java.base --output jre

其次,平台类加载器和应用程序都不再派生自 java.net.URLClassLoader,如果有程序直接依赖了这种关系,那么在高版本的 JDK 中很有可能会崩溃。现在启动类加载器、平台类加载器、应用程序加载器全部继承于 jdk.internal.loader.BuiltinClassLoader,并实现了新模块化架构下的加载逻辑。

另外,现在 JDK 的 Java 代码中也有 BootClassLoader 存在,是 JVM 和 Java 类库共同协作实现的。

以上,就是类加载相关的内容了。

comments powered by Disqus