第 7 章:虚拟机类加载机制

本文最后更新于:1 年前

类加载机制:
Java 虚拟机把数据从 Class 文件并非特指文件,是二进制字节流加载到内存,并对数据进行验证转换解析初始化,最终形成可以被虚拟机直接使用的 Java 类型类或接口

类的生命周期:

  • 在 Java 语言里面,类型的加载连接初始化过程都是动态的在程序运行期间完成的
    • 优:为 Java 应用提供了极高的扩展性灵活性Java 支持动态扩展的语言特性依赖动态加载和动态连接:例如可以通过 Java 预置的或自定义类加载器,让某个本地的应用程序在运行时从网络或其他地方上加载一个二进制流作为其程序代码的一部分
    • 【引入问题】
      • 让 Java 语言进行提前编译会面临额外的困难。
      • 类加载时稍微增加一些性能开销。
  • 类加载过程是按顺序开始的但不是按顺序进行或完成的
  • 解析阶段可以在初始化阶段之后再开始,以支持 Java 语言的运行时绑定也称为动态绑定或晚期绑定
  • 【主动引用】《Java 虚拟机规范》严格规定了有且只有六种情况必须立即对类或接口接口只有第 3 种情况区别较大进行初始化加载、验证、准备需要在此之前开始
    • 遇到 new、getstatic、putstatic、invokestatic 字节码指令时,如果类没有进行过初始化,就需要先初始化
    • 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,就需要先初始化
    • 初始化类的时候,如果其父类没有进行过初始化,就需要先初始化其父类接口在初始化时不要求其未使用到的父接口完成了初始化
    • 当虚拟机启动时,用户需要指定一个要执行的主类包含 main() 方法的那个类,虚拟机会先初始化这个主类。
    • 当使用动态语言支持时,在某些条件下,如果类没有进行过初始化,就需要先初始化
    • 当一个接口中定义了默认方法被 default 关键字修饰的接口方法时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化
  • 【被动引用】不会触发初始化的引用:
    • 通过子类引用父类的静态字段,不会导致子类初始化只有直接定义这个静态字段的类才会被初始化
    • 通过数组定义来引用类,不会触发此类的初始化触发的是虚拟机自动生成的类,这个类包装了数组元素的访问
    • 不会触发定义常量的类的初始化常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类

类加载的过程:

 描述与虚拟机实现的内存布局相关性
符号引用一组符号包含了该方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息来描述所引用的目标。无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。
各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的符号引用的字面量形式明确定义在《Java 虚拟机规范》的 Class 文件格式中
直接引用可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄直接相关,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在
  • 加载:
    • 加载阶段,Java 虚拟机需要完成三件事:
      1. 通过一个类的全限定名来获取定义此类的二进制字节流没有指明二进制字节流必须得从某个 Class 文件中获取
      2. 将 Java 虚拟机外部的二进制字节流静态存储结构按照虚拟机所设定的格式存储在方法区运行时数据结构之中。
      3. Java 堆内存中实例化一个 java.lang.Class 类的对象这个对象将作为程序访问方法区中的类型数据的外部接口
    • 非数组类型的加载既可以使用 Java 虚拟机里内置的启动类加载器来完成,也可以由用户自定义的类加载器去完成。
    • 数组类本身不通过类加载器创建,它是由 Java 虚拟机直接在内存中动态构造出来的,但数组类的元素类型数组去掉所有维度的类型最终还是要靠类加载器来完成加载
      • 数组类的创建规则:
        • 如果数组的组件类型数组去掉一个维度的类型是引用类型,就递归加载过程,数组将被标识在加载该组件类型类加载器类名称空间上。
        • 如果数组的组件类型不是引用类型,数组将被标记为与启动类加载器关联。
        • 数组类的可访问性与它的组件类型可访问性一致如果组件类型不是引用类型,它的数组类的可访问性将默认为 public
  • 验证:通过验证之后,字节流才被允许进入 Java 虚拟机内存的方法区中进行存储。
    • 四个阶段的检验动作:
      1. 文件格式验证基于字节流:验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。
      2. 元数据验证基于方法区的存储结构:对字节码描述信息类的元数据信息进行语义分析,以保证其描述的信息符合《Java 语言规范》的要求。
      3. 字节码验证基于方法区的存储结构:通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。
        在第二阶段对元数据信息中的数据类型校验完毕以后,本阶段就要对类的方法体 Class 文件中的 Code 属性进行校验分析。
        • 【引入问题】分析过程复杂导致执行时间长。
          【解决问题】把尽可能多的校验辅助措施挪到 Javac 编译器里进行在方法体 Code 属性的属性表中增加 StackMapTable,将类型推导转变为类型检查
          • 【引入问题】StackMapTable 属性也存在错误或被篡改的可能。
      4. 符号引用验证基于方法区的存储结构:对类自身以外常量池中的各种符号引用的各类信息进行匹配性校验。
        这个校验行为发生在解析阶段的虚拟机将符号引用转化为直接引用的时候。
    • 如果程序运行的全部代码都已经被反复使用验证过,可以关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
  • 准备:为静态变量分配内存并设置初始值数据类型的零值。只有常量才会在准备阶段就直接赋值。实例变量的内存会在对象实例化时随着对象一起分配在 Java 堆中。
  • 解析:Java 虚拟机将常量池内的符号引用替换为直接引用的过程。
    • 解析阶段发生时间:
      • 执行用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析
      • 其他情况,虚拟机可以根据需要来自行判断,决定是在类被类加载器 加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。
    • 虚拟机实现可以对第一次解析的结果进行缓存除 invokedynamic 指令以外,invokedynamic 指令的目的本来就是用于动态语言支持
    • 类或接口的解析
    • 字段解析
    • 方法解析
    • 接口方法解析
  • 初始化:初始化阶段就是执行类构造器 <clinit>() 方法的过程。
    • <clinit>() 方法:
      • <clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块 static{} 中的语句合并产生的如果没有静态语句块,也没有对变量的赋值操作,编译器可以不生成 <clinit>() 方法
        在静态语句块中,只能访问到定义在静态语句块之前的变量,定义在它之后的变量,可以赋值,但是不能访问。
      • Java 虚拟机会保证在子类的 <clinit>() 方法执行之前,父类的 <clinit>() 方法已经执行完毕父类中定义的静态语句块要优先于子类的变量赋值操作
      • 接口与类一样都会生成 <clinit>() 方法接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,但执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法。
      • 如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的 <clinit>() 方法,其他线程都需要阻塞等待多线程加锁同步

类加载器:

  • 实现让应用程序自己决定如何去获取所需的类通过一个类的全限定名来获取描述该类的二进制字节流的代码被称为类加载器
  • 比较两个类是否相等包括类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法、使用 instanceof 关键字做对象所属关系判定,只有在这两个类是由同一个类加载器 加载的前提下才有意义。

类加载器的双亲委派模型:

  • 描述了各种类加载器之间的层次关系。

  • 是 Java 设计者们推荐给开发者的一种类加载器实现的最佳实践。

  • 解决了各个类加载器协作时基础类型的一致性问题比如同一个 java.lang.Object 类

    • 【引入问题】无法处理父类加载器请求子类加载器完成类加载的情况比如 JNDI 对资源资源进行查找和集中管理
      【解决问题】线程上下文类加载器:逆向使用类加载器
  • java.lang.ClassLoader[1] 中实现双亲委派模型的代码:

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
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = 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.
long t1 = System.nanoTime();
c = findClass(name);

// this is the defining class loader; record the stats
PerfCounter.getParentDelegationTime().addTime(t1 - t0);
PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

Java 的类加载架构:

  • 三层类加载器:
    • JDK 9 之前:
      • 启动类加载器 Bootstrap Class Loader:负责加载存放在 %JAVA_HOME%\lib 或指定路径中的类库 Java 虚拟机能够按照文件名识别的到虚拟机的内存中。
        用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器去处理,直接使用 null 代替即可。
      • 扩展类加载器 Extension Class Loader:负责加载存放在 %JAVA_HOME%\lib\ext 或指定路径中的所有类库。
      • 应用程序类加载器 Application Class Loader:负责加载用户类路径中的所有类库。
      • 还可以加入自定义的类加载器
    • JDK 9 及之后:
      • 扩展类加载器平台类加载器取代。
      • 修改了类加载器的继承关系。
  • 双亲委派模型
    • JDK 9 之前:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求它的搜索范围中没有找到所需的类时,子类加载器才会尝试自己去完成加载
    • JDK 9 及之后:当平台类加载器应用程序类加载器收到类加载请求,在委派给父类加载器 加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的类加载器完成加载

代码热替换、模块热部署:
【IBM】OSGi 通过自定义类加载架构每一个程序模块 Bundle 都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换实现模块化热部署。在 OSGi 环境下,类加载器不再使用双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构。

JDK 9 为了实现模块化的关键目标可配置的封装隔离机制而做出的调整:

  • 【存在的问题】JDK 9 之前基于类路径来查找依赖的可靠性问题:如果类路径中缺失了运行时依赖的类型,只能等程序运行到发生该类型的加载链接时才会报出运行的异常。
    【解决问题】在启动时验证需要启用模块化封装,模块才可以声明对其他模块的显式依赖应用程序开发阶段设定好的依赖关系在运行期是否完备,如果缺失就直接启动失败。
  • 【存在的问题】类路径上跨 JAR 文件的 public 类型的可访问性问题。
    【解决问题】JDK 9 中的 public 类型不再意味着程序的所有地方的代码都可以随意访问到它们,模块提供了更精细的可访问性控制。
  • 为了兼容传统的类路径查找机制,JDK 9 提出了模块路径与类路径对应:某个类库到底是模块还是传统的 JAR 包,只取决于它存放在哪种路径上。
  • 【引入问题】不支持多版本号的概念和版本选择功能:如果同一个模块发行了多个不同的版本,那只能由开发者在编译打包时人工选择好正确版本的模块来保证依赖的正确性

参考


第 7 章:虚拟机类加载机制
https://weichao.io/218f6c813af3/
作者
魏超
发布于
2022年11月29日
更新于
2023年2月14日
许可协议