类加载子系统

上文介绍了JVM 运行时数据区,上上文介绍了字节码文件结构,本节就介绍一下,怎么将字节码文件加载到运行时数据区中的,更具体一点,加载到元空间的。

JVM通过类加载子系统(Class Loading Subsystem)来完成这一过程。

参见 JVM规范

JVM 启动时,第一件事就是找到并加载包含 main 方法的那个初始类。这个初始类可以由引导类加载器 (Bootstrap Class Loader,负责加载 JDK 核心类库)加载,也可以由用户自定义的类加载器加载。

找到类之后,JVM 并不会立刻执行代码,而是必须严格按照规范进行准备:链接 (Linking)初始化 (Initialization)

只有当初始类被成功链接和初始化后,JVM 才会调用 main 方法。从这一刻起,Java 程序的业务逻辑才真正开始运行。main 方法是整个应用程序的"发动机"。

在执行 main 方法的过程中,如果遇到 new 关键字、调用静态方法或访问静态字段等操作,JVM 会触发按需加载

The Java Virtual Machine starts up by creating an initial class or interface using the bootstrap class loader (§5.3.1) or a user-defined class loader (§5.3.2). The Java Virtual Machine then links the initial class or interface, initializes it, and invokes the public static method void main(String\[\]). The invocation of this method drives all further execution. Execution of the Java Virtual Machine instructions constituting the main method may cause linking (and consequently creation) of additional classes and interfaces, as well as invocation of additional methods.

这段规范描绘了一条清晰的执行主线:找初始类 -> 准备类(加载到元空间) -> 启动 main 方法 -> 在运行中按需触发更多类的加载

类加载器

创建一个类或接口,本质上是在 JVM 的方法区(Method Area) 中构建其特定于实现的内部表示。当然 Java 8+ HotSpot 虚拟机对方法区的物理实现是元空间(Metaspace)

Creation of a class or interface C denoted by the name N consists of the construction of an implementation-specific internal representation of C in the method area of the Java Virtual Machine (§2.5.4).

类加载器 (Class Loader)是负责加载类的工具,它通过读取类的二进制数据流并将其转换为类对象。

每个类加载器都维护一个类的引用集合,即它加载的所有类的 Class 对象。每个 Class 对象都可以通过 getClassLoader() 方法获取到加载该类的类加载器。

java 复制代码
ClassLoader classLoader = MyClass.class.getClassLoader();

这意味着 Class 对象和类加载器之间存在 双向关联 :类加载器知道它加载了哪些类,而类的 Class 对象知道它是由哪个类加载器加载的。

类加载器的类型

从 JVM 的角度来看,类加载器主要分为两类:由 C++ 实现的启动类加载器(Bootstrap ClassLoader),以及由 Java 实现并继承自 java.lang.ClassLoader 的所有其他加载器(User-Defined ClassLoader)。

站在开发者的角度,通常分为以下四类:

启动类加载器(Bootstrap ClassLoader)

启动类加载器(Bootstrap ClassLoader)是整个 Java 类加载体系中最顶层、最核心的类加载器。它是 JVM 启动时最先创建的加载器,也是整个双亲委派模型的根基。

  • 非 Java 实现 :由 C++ 实现,是 JVM 内核的一部分,并不属于 Java 类库,由于它是 JVM 的内部实现,Java 代码无法直接引用它。String.class.getClassLoader() == null

  • 加载范围 :负责加载 JDK 中的核心类库 (如 java.lang.*java.util.* 等)。

启动类加载器加载 jar 包路径由 sun.boot.class.path 属性指定,一般默认是 jre\lib ,可以通过启动参数 -Xbootclasspath 修改。

拓展类加载器(Extension ClassLoader)

扩展类加载器 (Extension ClassLoader)是双亲委派模型中的第二层级,它位于启动类加载器和应用类加载器之间,起到了承上启下的作用。

  • Java 实现 :用 Java 语言编写的,派生自 java.lang.ClassLoader,具体实现类为 sun.misc.Launcher$ExtClassLoader

  • 加载范围 :负责加载一些由 Java 官方或第三方提供的扩展库

拓展类加载器加载 jar 包路径由 java.ext.dirs 属性指定,一般默认是 jre/lib/ext ,可以通过启动参数 -Djava.ext.dirs 修改。

在 JDK 9 及以后的版本中,随着 Java 平台模块系统(JPMS)的引入,扩展类加载器(Extension Class Loader)已经被平台类加载器(Platform Class Loader) 所取代。

程序类加载器(Application ClassLoader)

程序类加载器也叫系统类加载器(System ClassLoader),是双亲委派模型中的第三层级,负责加载用户应用程序类路径下的类。

  • Java 实现 :用 Java 语言编写的,派生自 java.lang.ClassLoader,具体实现类为 sun.misc.Launcher$AppClassLoader

  • 加载范围 :默认加载路径就是所谓的用户 类路径(Classpath) 下的类库。

如果程序是通过 JAR 包启动的,并且没有指定类路径,JVM 会使用 JAR 文件中 META-INF/MANIFEST.MF 文件中的 Class-Path 属性来指定类路径,如果是以 class 文件启动的,那当前工作目录 . 会被加入到类路径中,当然也可以通过启动参数 -classpath-cp 指定其他类路径。

自定义类加载器

  • Java 实现 :开发者通过继承 ClassLoader 并重写 findClass() 方法来实现的自定义加载器。
java 复制代码
import java.io.*;

public class CustomClassLoader extends ClassLoader {
    // 定义自定义的类加载路径
    private final String classPath;

    public CustomClassLoader(String classPath) {
        this.classPath = classPath;
    }

    /**
     * 重写 findClass 方法,实现自定义类加载逻辑
     * 注意:这里没有重写 loadClass,因此默认遵循双亲委派机制
     */
    @Override
    protected Class<?> findClass(String className) throws ClassNotFoundException {
        // 将类名转换为文件路径(例如:com.example.HelloWorld -> com/example/HelloWorld.class)
        String fileName = className.replace('.', File.separatorChar) + ".class";
        File classFile = new File(classPath, fileName);

        try (InputStream is = new FileInputStream(classFile);
             ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
            
            // 1. 读取类文件的字节码
            byte[] buffer = new byte[1024];
            int bytesRead;
            while ((bytesRead = is.read(buffer)) != -1) {
                baos.write(buffer, 0, bytesRead);
            }
            
            // 2. 通过 defineClass 将字节码转换为 JVM 内部的 Class 对象
            byte[] classData = baos.toByteArray();
            return defineClass(className, classData, 0, classData.length);
            
        } catch (IOException e) {
            throw new ClassNotFoundException("Failed to load class: " + className, e);
        }
    }
}
  • 加载范围 :自定义类加载器因为重写了 findClass() 方法,所以可以从任何能够获取到字节流的地方加载类。(任意本地路径、网络传输、数据库...)

双亲委派模型

双亲委派模型(Parent Delegation Model)是 Java 类加载机制的核心工作模式。它的核心思想是:当一个类加载器收到加载请求时,它不会自己先去尝试加载,而是将这个请求委派给它的父类加载器去完成。

整个委派过程遵循"自顶向下委派,自底向上加载"的原则:

  1. 向上委派:类加载器收到请求后,首先检查该类是否已被加载。若未加载,则将请求向上委派给父类加载器。这个过程会逐级向上,直到最顶层的启动类加载器(Bootstrap ClassLoader)。
  2. 父加载器尝试 :父类加载器在自己的搜索范围内尝试加载。如果找到并成功加载,则直接返回 Class 对象;如果找不到(加载失败),则将请求退回给子类加载器。
  3. 子类加载器兜底 :子类加载器接收到父加载器退回的请求后,尝试在自己的路径下加载。如果依然失败,则抛出 ClassNotFoundException 异常。

这一机制主要通过 java.lang.ClassLoader 类的 loadClass() 方法来实现。其默认逻辑如下:

java 复制代码
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 1. 检查该类是否已经被加载过
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                // 2. 如果未加载,且存在父加载器,则向上委派
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    // 3. 如果没有父加载器,尝试使用启动类加载器加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 父加载器加载失败,抛出异常,进入下一步
            }
            // 4. 如果父加载器都无法加载,则由当前类加载器自己尝试加载
            if (c == null) {
                c = findClass(name);
            }
        }
        return c;
    }
}

这也是JVM 规范建议自定义加载器的时候不要重写 loadClass() 方法,而是重写 findClass() 方法。因为 loadClass() 中包含了双亲委派模型的完整逻辑。如果你重写了它,就必须自己重新实现一遍向上委派和向下回退的逻辑,极易出错。

双亲委派模型的设计主要为了解决两个核心问题:

  • 防止核心类被篡改 :Java 的核心类库(如 java.lang.String)只能由最顶层的启动类加载器加载。即使用户编写了一个恶意的同名类,应用程序类加载器也不会加载它,从而从根本上保护了 JVM 的安全。

  • 避免重复加载:在 JVM 内部,判断两个类是否相等必须同时满足以下两个条件:类的全限定名相同、加载这个类的类加载器是同一个实例。双亲委派模型可以确保同一个类在 JVM 中只存在一份字节码。

虽然双亲委派是默认规则,虽然双亲委派是默认规则,需要允许不同类加载器加载同名但不同实现的类。此时可以通过自定义类加载器并重写 loadClass() 方法来打破该模型,使加载器先尝试自己加载,失败后再委派给父加载器。

比如,Tomcat 就是同一个 JVM 进程 中运行多个独立的 Web 应用,为了保证了 A 应用和 B 应用即使引入了同一个第三方库的不同版本,在同一个 JVM 中也不会发生冲突,Tomcat 为每一个部署的 Web 应用创建了一个独立的 WebappClassLoader

类加载过程

Java 虚拟机规范中规定了类的生命周期,类从加载到卸载会经历7个阶段

  1. 加载(Loading):从磁盘或其他来源加载类字节码。
  2. 验证:确保字节码合法并符合 JVM 规范。
  3. 准备:为类的静态变量分配内存并设置默认值。
  4. 解析:将符号引用转化为直接引用。
  5. 初始化:执行类的静态初始化块和初始化静态变量。
  6. 使用:类被程序使用,进行实例化、方法调用等操作。
  7. 卸载:当类不再被使用时,由类加载器卸载,回收内存。

除去 使⽤卸载 ,就是 JVM 的类加载过程。这 5 个阶段⼀般是顺序发⽣的,但在动态绑定的情况下,解析阶段可以发⽣在初始化阶段之后。

加载阶段 Loading

加载(Loading) 是查找具有特定名称的类或接口类型的二进制表示形式(如 .class 文件),并从该二进制表示形式创建 类或接口的过程。

Loading is the process of finding the binary representation of a class or interface type with a particular name and creating a class or interface from that binary representation.

这里创建 的意思是,将 .class 文件的字节码读入方法区,并生成Class对象作为访问入口。

在加载阶段,JVM 要干三件事:

⨳ 获取类的二进制数据流

二进制流是怎么来的,JVM 不关心,只要能保证类的全限定类名唯一即可,可以是通过本地文件系统(如文件夹、JAR包..)读入一个后缀为.class的文件,可以是通过网络或数据库连接获取二进制数据流,也可以使用字节码操作工具(如 BytecodeASMCGLIB)在运行时动态生成字节码...

js 复制代码
ClassLoader classLoader = MyClass.class.getClassLoader();
InputStream inputStream = classLoader.getResourceAsStream("com/example/MyClass.class");

⨳ 解析二进制数据流为方法区内的数据结构

加载字节码后,JVM 会对二进制数据流进行解析,会按照 ClassFile 结构解析并加载到方法区。这一步是将字节流所代表的静态存储结构转化为⽅法区的运⾏时数据结构。

方法区存储的内容,上一节已经讲过了,这里就不重复了。

⨳ 创建 java.lang.Class 类的实例

加载完成后,JVM 会为每个加载的类在堆中创建一个 java.lang.Class 对象(java.lang.Class 类的构造方法是私有的,只有 JVM 能够创建),这个对象作为访问方法区中类信息的入口,通过 Class 对象,JVM 可以访问类的字段、方法、父类、接口等信息。

java 复制代码
Class<?> clazz = Class.forName("com.example.MyClass");
System.out.println("Class Name: " + clazz.getName());

这一阶段主要是通过类加载器(ClassLoader)完成,目的是将类的字节码转换为JVM内部可以使用的Class对象。

链接阶段 Linking

链接的本质是:把加载进来的类"融入"到 JVM 的运行时状态中,使其具备可执行的条件

Linking is the process of taking a class or interface and combining it into the run-time state of the Java Virtual Machine so that it can be executed.

验证、准备和解析这三个阶段统称为 链接(Linking)

验证 Verification

类加载到机器内存后,就开始链接操作,验证是链接操作的第一步。验证阶段主要是确保类的字节码符合 JVM 规范。

Verification ensures that the binary representation of a class or interface is structurally correct.

验证包括的内容很多,详见JVM规范中类文件的验证

这里简单介绍几条,比如

  • 文件格式检查,检查字节码的魔数、版本号等是否符合标准;
  • 元数据验证,验证类的继承关系、字段/方法访问权限等语义是否正确;
  • 字节码验证,分析代码逻辑是否合法(如操作数栈类型匹配、跳转指令有效性等),
  • ...

简而言之,验证就是 JVM 的安全检查机制,防止恶意或损坏的字节码危害虚拟机的稳定性和安全性。

准备 Preparation

准备 准备阶段做两件事: 为类或接口的静态字段 (static fields)分配内存空间,并将这些静态字段初始化为默认值

Preparation involves creating the static fields for a class or interface and initializing such fields to their default values.

静态字段 就是关键字 static 修饰的字段。

⨳ 静态变量的内存分配

静态变量是属于类本身的,而不是类的实例。它们在类加载时就会被分配内存,存储在方法区中。

实例变量 (非 static 修饰的字段)不会在准备阶段初始化,因为它们是属于对象实例的,会在对象被创建时,随着对象一起分配内存到 堆区

⨳ 静态变量的初始化

在准备阶段,JVM 会为静态变量赋予默认值。具体的默认值取决于字段的类型:

  • 数值类型 (如 int, long, float, double):默认为 00.0
  • 布尔类型boolean):默认为 falsefalse 在JVM 内部是 int 的 4 个字节 0 )。
  • 字符类型char):默认为 \u0000
  • 引用类型 (如 Object):默认为 nullnull 是一个无效的内存地址,通常也是 4 个字节或 8 个字节 0)。

如果字段具有 ConstantValue 属性,也就是说该字段是个常量,被 static final 修饰,那么则字段的值会在准备阶段显式赋值,而不是使用默认值。

java 复制代码
public class Example {
    public static final int NUM = 1; // 编译时常量,会直接赋值为1
}

虽然准备阶段会为静态变量分配内存并赋予默认值,但不执行任何 JVM 字节码,静态字段的默认值是由 JVM 自动赋予的,而不是通过代码显式设置的,不会执行任何显式的静态初始化块或静态字段的赋值操作。

初始化阶段才会执行实际的初始化操作,静态变量的值可能会被显式赋值或者由构造器代码进行初始化。

解析 Resolution

解析阶段就是将类的运行时常量池中的符号引用 转换为直接引用的过程。

Resolution is the process of dynamically determining one or more concrete values from a symbolic reference in the run-time constant pool. Initially, all symbolic references in the run-time constant pool are unresolved.

⨳ 符号引用 :以字符串形式描述引用的目标(如java/lang/Object)。

前文讲解的字节码常量池中,无论是代表类引用的 CONSTANT_Class_info,还是代表字段的 CONSTANT_Fieldref_info、代表方法的 CONSTANT_Methodref_info ... 都是使用 CONSTANT_Utf8_info Utf8字面量描述的,这些字面量就是所谓的符号引用。

⨳ 直接引用:指向目标的内存地址、偏移量或句柄。

通过直接引用可以直接定位到内存中的具体实体,根据引用目标的不同,直接引用可以表现为以下几种形式:

  • 引用类或接口 :是一个指向方法区中类对象 的指针。例如,new 指令解析类符号引用后,得到的直接引用就是指向该类元数据的指针。

  • 引用字段 :通常是一个偏移量(offset) ,表示该字段在类实例(或类变量区)中的内存偏移位置。通过这个偏移量,JVM 可以直接访问该字段的值。

  • 引用方法 :是一个指向方法代码入口的指针(或句柄)。通过它,JVM 可以直接跳转到方法的字节码开始执行。

  • 引用动态调用点(invokedynamic :是一个指向调用点(CallSite) 对象的句柄,该对象包含了目标方法的直接引用。

符号引用 是编译期产生的、与内存布局无关的逻辑描述,而直接引用是运行期解析产生的、与内存布局相关的物理定位。

相关字节码指令(如:newgetfield/putfieldgetstaticputstaticinvokevirtualinvokestatic...)执行时,都需要用到运行时常量池中的符号引用 ,所以这些指令在执行前必须确保其引用的符号已经被解析为直接引用

JVM 规范有两种解析策略:

  • 急切解析(Eager Resolution) :类加载时就完成所有符号引用的解析,链接阶段一次性全部解析。

  • 延迟解析(Lazy Resolution):符号引用保持未解析状态,直到某条指令首次使用时才解析。

绝大多数 JVM 实现(包括 HotSpot)都采用延迟解析策略。

链接完成后,类的结构已经在 JVM 内部就位,但还没有执行任何 Java 代码。

初始化阶段 Initialization

初始化阶段是类加载过程的最后一个 步骤,也是真正开始执行 Java 代码 的阶段。初始化阶段的重要工作是执行类或接口的<clinit>()方法(即类初始化方法)。

Initialization of a class or interface consists of executing the class or interface initialization method <clinit>

<clinit>() 方法由 Java 编译器根据类中的静态变量初始化和静态代码块自动生成,并由 JVM 在初始化阶段调用。如:

java 复制代码
public class Example {
    static int staticVar = 10; // 静态变量
    static {
        System.out.println("Static block executed");
        staticVar = 20; // 静态代码块修改静态变量
    }
}

生成的 <clinit>() 方法如下:

js 复制代码
 0 bipush 10
 2 putstatic #2 <com/cangoking/bytecodes/Example.staticVar>
 5 getstatic #3 <java/lang/System.out>
 8 ldc #4 <Static block executed>
10 invokevirtual #5 <java/io/PrintStream.println>
13 bipush 20
15 putstatic #2 <com/cangoking/bytecodes/Example.staticVar>
18 return

在这个例子中,Example 类有一个静态变量 staticVar 和一个静态代码块。类的初始化过程会如下进行:

  1. 静态字段的初始化 :对于每个静态字段,JVM 会在 clinit() 方法中插入相应的代码来初始化这些字段。如上,先执行静态字段的初始化,staticVar = 10

  2. 静态代码块的执行 :如果类中包含静态代码块(static {}),这些代码会在 clinit() 方法中按顺序执行。如上接着执行静态代码块的内容,输出 "Static block executed"staticVar = 20

  3. 父类优先 :如果类 A 继承了类 B,在初始化类 A 之前,JVM 会先初始化类 B。这意味着,父类的 <clinit>() 方法会在子类的 <clinit>() 方法之前被调用。

并非所有的类都会生成 <clinit>() 方法。以下情况,class 文件中将不会包含 <clinit>() 方法:

  • 没有声明任何静态变量或静态代码块的类
js 复制代码
class NoStatic {
    // 没有静态成员或静态代码块
}
  • 类中声明了静态变量,但没有静态初始化语句或静态代码块
js 复制代码
class StaticFieldOnly {
    static int a; // 没有显式赋值,默认值为 0
}
  • static final 基本数据类型的字段
js 复制代码
class Constants {
    static final int CONSTANT = 42; // 编译时常量
}

如果类中包含 static final 修饰的基本数据类型字段,并且这些字段的值是常量,因为这些字段的值是在编译时已知的,那么这些字段的初始化会在准备阶段使用其 ConstantValue 属性进行赋值,而任何引用该字段的地方,编译器都会直接使用这个常量值而不是字段名。

类的初始化也是按照 懒加载(lazy initialization) 原则进行的,即只有当类的某个静态成员被访问时,JVM 才会触发类的初始化过程。

在在多线程环境下,多个线程可能会同时访问一个类,并试图初始化它,这就涉及到 clinit() 方法的线程安全性。JVM 会为类的初始化加上一个锁,以 HotSpot JVM 为例,它使用了一种叫做 "类初始化锁"(Class Initialization Lock) 的东西,确保类的 clinit() 方法在任何时刻只会由一个线程执行。其他线程会在此过程中阻塞,直到类初始化完成,确保类的初始化只有一次。

截止到这,基本上都在元空间玩呢,堆里面有加载这个类的 ClassLoader 对象 ;可能有字符串对象 ,字符串常量池在堆里;有这个类的Class对象,只是它还没完成初始化,用户代码还不能用它。 但该类的实例对象 还没有 new 出来。

使用 Using 与 卸载 Unloading

类一旦被加载并初始化后,JVM就可以开始使用类中的方法和字段,这就是所谓的类的使用阶段。这个大家都懂吧。

类的卸载阶段是类生命周期的最后阶段,就是元空间的垃圾回收。这个前文也提到了,这里就不赘述了。

这7个阶段的理解对于深入掌握Java虚拟机的工作原理、调优JVM以及处理类加载相关的问题至关重要。

JDK 9 之后的类加载器

在 JDK 9 及以后,由于 模块化系统(Jigsaw)的引入,整个JDK都基于模块化进行构建,比如原来的 rt.jar 和 tools.jar 被拆分成数十个模块,模块更细粒化,天然地满足了可扩展的需求。

JDK 8 及以前

java 复制代码
Bootstrap ClassLoader(C++实现,null表示) 
↑ 父 
Extension ClassLoader(sun.misc.Launcher$ExtClassLoader) 
↑ 父 
Application ClassLoader(sun.misc.Launcher$AppClassLoader)
↑ 父 
自定义 ClassLoader

JDK 9 及以后:

java 复制代码
Bootstrap ClassLoader(Java实现,null表示) 
↑ 父 
Platform ClassLoader(jdk.internal.loader.ClassLoaders$PlatformClassLoader) 
↑ 父 
Application ClassLoader(jdk.internal.loader.ClassLoaders$AppClassLoader) 
↑ 父 
自定义 ClassLoader

JDK 9 之后,核心类库rt.jar 拆成了数十个 JMOD 模块,Bootstrap 只负责最核心的 java.base 模块,Bootstrap 由 Javajdk.internal.loader.BootClassLoader)实现。

扩展机制由模块系统取代,lib/ext 机制被废弃,扩展类加载器 也变成了 平台类加载器。

双亲委派模型也从"严格向上"变成了"模块感知",也就是说,JDK 9+ 的类加载器在委派前会先查模块映射表 ,如果目标类已经归属于某个模块,就直接跳过去,不再一层层向上托举。

总的来说,JDK 9 的类加载器变化,本质上是为模块系统服务:启动类加载器瘦身只负责核心模块,平台类加载器接管去特权化的平台模块,应用类加载器兼容传统类路径,委派模型从"盲目向上"升级为"模块感知、精准直达"。

相关推荐
youtootech9 小时前
HarmonyOS《柚兔学伴》项目实战08-关系型数据库入门——待办事项持久化
jvm·数据库·华为·harmonyos
weixin_727535629 小时前
JVM 常见八股面试问答:深入底层原理
jvm·面试·职场和发展
Java技术实战12 小时前
一文通解:数据结构之 栈
java·jvm·数据结构·
天选之子1231 天前
DuckDB学习
jvm·学习
春卷同学1 天前
032-关系型数据库在记账应用中的建模与查询优化
java·jvm·数据库
orient2 天前
JVM 调优实战:从监控告警到参数配置,一套完整方法论
jvm
orient2 天前
大白话 JVM:对象到底存在哪?GC 又是怎么运行的?
jvm
炸薯条!2 天前
从零开始学C++ (内存管理)
java·jvm·c++
凤山老林2 天前
从“无法创建本地线程“异常说起:深度解析线程栈与JVM线程优化指南
java·开发语言·jvm