《深入理解 Java 虚拟机》学习 -- 类加载机制

时间:2023-12-16 19:42:32

《深入理解 Java 虚拟机》学习 -- 类加载机制

1. 概述

虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。


2. 类加载的时机

2.1 类的生命周期:

加载 --> 连接(验证 --> 准备 --> 解析)--> 初始化 --> 使用 --> 卸载

其中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 语言的运行时绑定。

2.2 类的初始化情况

有且只有四种情况必须立即对类进行 “初始化”:

  • 遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化

    场景:

    1. 使用 new 关键字实例化对象
    2. 读取或设置一个类的静态字段(被 final 修饰、已在编译器把结果放入常量池的静态字段除外)
    3. 调用一个类的静态方法的时候
  • 使用 java.lang.reflect 包的方法对类进行反射调用时,如果类没有进行过初始化,则需要先触发其初始化

  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化

  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类


3. 类加载的过程

3.1 加载

在加载阶段,虚拟机需要完成以下三件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为方法区这些数据的访问入口

3.2 验证

会完成四个阶段的检验过程:

  1. 文件格式验证
  2. 元数据验证
  3. 字节码验证
  4. 符号引用验证

3.3 准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。

几个注意点:

  • 这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中(初始化阶段)。

  • 这里所说的初始值“通常情况”下是数据类型的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。

    举个例子:

    // 变量 value 在准备阶段过后的初始值为 0 而不是 123
    public static int value = 123;
  • 特殊情况:如果类字段的字段属性表中存在 ConstantValue 属性(即 final 常量),那在准备阶段变量 value 就会被初始化为 ConstantValue 属性所指定的值。

    举个例子:

    // 编译时 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 123
    public static final int value = 123;

3.4 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

概念说明:

  • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
  • 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现地内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。

3.5 初始化

类初始化阶段是类加载过程的最后一步,前面的类加载过程(准备阶段)中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。

初始化阶段是执行类构造器 <clinit>() 方法的过程

在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其他资源(如类成员变量 -- 除了类变量以外的变量都属于类成员变量)。

几个应该记住的概念:

  1. <clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{} 块)中的语句合并产生的(会优先执行 static{}

  2. 虚拟机会保证在子类的 <clinit>() 方法执行之前,父类的 <clinit>() 方法已经执行完毕。因此在虚拟机中第一个被执行的 <clinit>() 方法的类肯定是 java.lang.Object

  3. 由于父类的 <clinit>() 方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。

    举个例子,下面的代码中运行结果 字段 B 的值将会是 2 而不是 1。

    static class Parent {
    public static int A = 1;
    static {
    A = 2;
    }
    }
    static class Sub extends Parent {
    public static int B = A;
    }
    public static void main(String[] args) {
    System.out.println(Sub.B);
    }
  4. <clinit>() 方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 <clinit>() 方法。

  5. 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>() 方法。


4. 类加载器

4.1. 比较两个类是否 "相等"

只有在这两个类是由同一个类加载器加载的前提之下才有意义,否则,即使这两个类是来源于同一个 Class 文件,只要它们的类加载器不同,那这两个类就必定不相等。

4.2 类加载器

不同的类加载器(Java 虚拟机角度)
  1. 启动类加载器(C++ 实现):虚拟机自身一部分
  2. 所有其他的类加载器(Java 实现):独立于虚拟机外部,继承自抽象类 java.lang.ClassLoader
不同的类加载器(Java 开发人员角度)
  1. 启动类加载器:与上述一致

    负责将存放在 <JAVA_HOME>\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中,并且是虚拟机识别的类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用。

  2. 扩展类加载器:由 sun.misc.Launcher$ExtClassLoader 实现

    负责加载 <JAVA_HOME>\lin\ext 目录中的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器

  3. 应用程序类加载器:由 sun.misc.Launcher$AppClassLoader 来实现

    由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以一般也称它为系统类加载器。它负责加载用户路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应该程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。


4.3 双亲委派模型(重要)

上图:

《深入理解 Java 虚拟机》学习 -- 类加载机制

结构:

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合关系来复用父加载器的代码。

工作过程:

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

实现原理
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() 方法进行加载。

Java 中不符合双亲委派模型的例子
  1. 基础类需要调用回用户的代码

    • 引用上下文类加载器(Thread Context ClassLoader),这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoaser() 方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。

    • 因为父类加载器请求子类加载器去完成类加载的动作,实际上是逆向使用类加载器,不符合双亲委派模型。

    • 如:JDBC

  2. 程序动态性

    • 如代码热替换,模块热部署等。(其实就是模块化操作)

    • 这种模块化的模式标准我们称之为 "OSGi"。(自定义的类加载器机制的实现)

    • 在该标准下,每一个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换。

    • 在 OSGi 环境下,类加载器不再是双亲委派模型中的树状结果,而是进一步发展为网状结构。