Bootstrap

JDK类加载器剖析

0.前言

我之所以深入研究 Java 类加载器,是为了解决一个奇怪的问题。流行出版物,也就是人们所认为的 Java 世界的灯塔,充斥着关于这个主题的相互矛盾和过时的信息。这种矛盾引发了我的调查 — — 在 Java 类加载器的迷宫中寻求清晰的答案。

作为一名 Java 开发人员,您可能遇到过ClassNotFoundException一些NoClassDefFoundError神秘的消息,它们会暂时中断您的编码流程。旨在阐明这些问题的在线资源往往反而增加了混乱。

让我们一起深入研究,消除复杂性。以下是我们将要解释的全貌:
在这里插入图片描述

附言:
Java SE 平台 API、它们的实现类和由平台类加载器或其祖先定义的 JDK 特定运行时类。
一些常见的类加载器的例子:org.apache.catalina.loader.WebappClassLoader、org.springframework.boot.loader.LaunchedURLClassLoader。
在java字节码中,JVM使用人类可读的符号引用,例如“java/lang/System.out:Ljava/io/PrintStream;”来表示字段、方法或类。
在解析阶段,JVM 将这些符号引用替换为直接引用实际内存地址。
这意味着对于system.out.println方法调用,jvm将用system.out文件的实际内存位置替换符号引用。
new、getstatic、putstatic、invokestatic。

1.声明

在我们进一步了解类加载器的机制之前,有必要强调一个重要的细节:

没有“通用”的 Java 虚拟机设计。

JVM由 Oracle Corporation指定,概述了任何 JVM 应具有哪些组件和行为。但是,此规范并未规定实现这些组件的单一方法。因此,我们发现了多种独特的 JVM 实现 — 例如HotSpot/OpenJDK、Eclipse OpenJ9或相对炒作的GraalVM(基于 OpenJDK)。这些实现均遵循 JVM 规范,但可能存在各种差异,包括性能特征、垃圾收集策略以及(您可能猜到的)类加载细节。

要记住的另一点是:

Java 虚拟机是平台相关的。

Windows 操作系统的 JVM 与 Linux 机器的 JVM 并不相同。“但是等一下,”您可能会说,“我以为 Java 就是一次编写,随处运行 — 平台独立性!”绝对正确。但是,Java 的平台独立性并不意味着 JVM 也是平台独立的。事实恰恰相反。

大多数关于这个主题的文章在描述 Java 时都没有给出具体的版本,这实际上会导致误解,因为 JVM 会随着每个版本而发展和变化。现在是 2023 年夏天,Java 世界正在期待版本 21,但在它发布之前,我们将专注于 Java 20,依靠Oracle 的 JVM 规范本身和Oracle Java SE 文档以简化操作。

考虑到这一点,让我们重新开始对 Java ClassLoader 系统的探索。

2.从底层开始

简而言之,当您运行应用程序时,JVM 会将必要的类加载到内存中,验证字节码,分配必要的资源,最后通过将字节码转换为主机可以理解的机器语言指令来执行代码。

但是JVM 加载到底意味着什么?Java 程序由类和接口组成,以人类可读的 Java 代码编写。要在机器上运行此代码,需要将其转换为机器可理解的字节码。此字节码存储在.class文件中,JVM 可以读取和执行这些文件。

因此,当我们谈论“加载类”时,我们指的是在磁盘上查找适当的 .class 文件 、读取其内容并将其带入 JVM 的运行时环境的过程,JVM 的运行时环境是计算机内存中专用于运行应用程序的特定部分。

或者,如果你愿意的话,可以使用 Oracle 中“加载”的更正式的定义:

加载是指查找具有特定名称的类或接口的二进制形式的过程,可能通过动态计算来实现,但更典型的是通过检索 Java 编译器先前从源代码计算出的二进制表示形式,并从该二进制形式构造一个Class对象来表示该类或接口。

3.进一步解释

实际上,ClassLoader 系统的作用不仅仅是查找类 - 它还通过强制执行 Java 运行时的二进制结构和命名空间规则来确保 Java 应用程序的完整性和安全性。同时,它还提供了从各种来源加载类的灵活性 - 不仅是本地文件系统,还包括通过网络、数据库,甚至是动态生成的类。让我们深入研究一下,分解一下步骤。

3.1. 加载——初始阶段

当 ClassLoader 负责定位特定类时,该过程就开始了。这可以由 JVM 本身启动,也可以由代码中的命令触发。本质上,ClassLoader 的工作是获取完全限定的类名(如java.lang.String)并从磁盘上的位置检索相应的类文件(如String.class)到 JVM 的内存中。

加载子系统并不是一个单独的动作,而是一个层级接力。每个 ClassLoader(父类加载器和子类加载器)都协作运行,传递责任接力棒,直到加载正确的类。

指导此协调类加载过程的基本原则是:

  • 可见性:子 ClassLoader 可以看到其父级加载的类,但反之则不然,从而确保了封装性;
  • 唯一性:父类加载的类不会再被子类加载,提高效率;
  • 委托层次结构:应用程序类加载器将类加载请求向上传递给平台类加载器和引导类加载器。如果它们找不到该类,请求将沿委托链向下传递;

现在让我们深入了解每个 ClassLoader。

3.1.1引导类加载器

Bootstrap ClassLoader 是该家族中最老的成员,它负责加载JVM 所需的<JAVA_HOME>/jmods文件夹中的核心 Java 库(例如java.lang.、java.util.​​等)。查看该图可以发现,其他 ClassLoader 是用 Java 编写的( java.lang.ClassLoader的对象 ),这意味着它们也需要加载到 JVM 中 — 这也是 Bootstrap ClassLoader 承担的任务。

还值得注意的是,许多资源将 Bootstrap ClassLoader 描述为其余类加载器的“父类”。这表示逻辑继承而不是直接 Java 继承,因为 Bootstrap ClassLoader是用本机代码编写的。以下代码行可以轻松证实这一点:

jshell> System.out.println(java.lang.ClassLoader.class.getClassLoader());
null

Bootstrap ClassLoader 也是Oracle 规范中唯一明确描述的ClassLoader 。其余的定义称为“用户定义”,由特定的 VM 供应商自行决定。

3.1.2.平台类加载器

在我看来,是最有争议的。

Java SE 20 文档说明如下:

平台类加载器负责加载平台类。平台类包括 Java SE 平台 API、其实现类以及由平台类加载器或其祖先定义的 JDK特定的运行时类。平台类加载器可用作实例的父类ClassLoader。

但是平台类和Bootstrap ClassLoader 加载的核心类有什么区别呢?让我们尝试观察它本质上加载了什么:

jshell> ClassLoader.getPlatformClassLoader().getDefinedPackages();
$1 ==> Package[0] { } // empty

事实证明,在一个完全空的 Java 程序中 — 什么都没有!现在,让我们尝试显式使用某个标准包中的类:

jshell> java.sql.Connection.class.getClassLoader()
$2 ==> jdk.internal.loader.ClassLoaders$PlatformClassLoader@27fa135a

jshell> ClassLoader.getPlatformClassLoader().getDefinedPackages()
$3 ==> Package[1] { package java.sql }

简单地说,Bootstrap 加载启动 JVM所需的核心运行时类,而平台加载开发人员可能需要的系统模块的公共类型。

在此背景下,值得一提的是,许多来源(例如Wikipedia、Baeldung)经常将平台类加载器称为扩展类加载器。然而,这并不完全准确。更正确的说法是,平台类加载器已经取代了Java 8 及更早版本中使用的扩展类加载器。这一变化伴随着模块系统 (JEP-261)的引入而来:

扩展类加载器不再是 的实例URLClassLoader,而是内部类的实例。它不再通过扩展机制加载类,该机制已被JEP 220删除。但是,它确实定义了选定的 Java SE 和 JDK 模块,有关详细信息,请参见下文。在其新角色中,此加载器称为平台类加载器,可通过新ClassLoader::getPlatformClassLoader 方法使用,并且它将是 Java SE 平台 API 规范所必需的。

3.1.3.应用类加载器

应用程序类加载器(也称为系统类加载器)可以说是日常 Java 开发环境中最常见的加载器。在 Java SE 20 中,它仍然保留了其传统的角色和功能。

此类加载器负责从已设置的类路径加载所有类。这些类可能来自目录、JAR 文件或类路径中指定的其他来源。Java 应用程序启动时,大多数用户定义的代码都在此处加载。

public class MediumTeller {

    public static void main(String[] args) {
        // jdk.internal.loader.ClassLoaders$AppClassLoader@251a69d7
        System.out.print(MediumTeller.class.getClassLoader());
    }
}

从类加载器层次结构的角度来看,应用程序类加载器是平台类加载器的逻辑子类加载器。这意味着,当加载某个类时,如果应用程序类加载器找不到该类,则请求将向上委托给平台类加载器,如果需要,则进一步委托给引导程序,以确保委托机制。

除了我们讨论过的三个主要类加载器之外,您还可以直接在代码中创建自己的用户定义类加载器。此功能提供了一种确保应用程序独立性的途径,由类加载器委托模型实现。Tomcat 等 Web 应用程序服务器利用这种方法来确保不同的 Web 应用程序和企业解决方案可以独立运行,即使它们托管在同一台服务器上。我们不会关注这一点,因为关于自定义创建主题已经有足够多的指南了。

值得一提的是,每个类加载器都维护自己的命名空间,用于记录已加载的类。当类加载器负责加载类时,它首先会查阅此命名空间,搜索完全限定类名 (FQCN) 以确定该类是否已加载。有趣的是,即使一个类与另一个类共享相同的 FQCN,如果它们存在于不同的命名空间中,它们仍被视为不同的类。如果类位于不同的命名空间中,则意味着它是由不同的类加载器加载的,从而增强了应用程序不同部分之间的自主性和分离性。

加载阶段的结果是 JVM 中类或接口类型的二进制表示。但是,此时类尚未准备好使用。

3.2.链接——填充空隙

链接阶段涉及几个复杂的步骤,以确保程序顺利执行。此阶段将加载的类或接口作为输入,并执行基本任务以验证代码的完整性、准备执行代码并解决其可能存在的任何依赖关系。
一旦类被加载,它就会s通过一个称为“链接”的阶段。此阶段涉及一系列步骤:

3.2.1.确认

此阶段对于维护 Java 运行时环境的稳健性至关重要。它检查类或接口的字节码以确保其结构正确性、与 JVM 的兼容性以及验证它是由合法的编译器生成的。

在 Java 程序可以通过网络传输并可能由恶意编译器生成的世界中,此过程变得至关重要。它检查符号表中的一致性、最终方法或类是否被不正确地覆盖、访问控制关键字的正确性、参数的准确数量和类型、正确的堆栈操作等等。

最后,如果验证检测到任何异常,它会抛出一个java.lang.VerifyError,导致一个java.lang.LinkageError。

3.2.2.准备

在此步骤中,JVM为类或接口的静态变量分配内存,并使用其默认值初始化它们。

此阶段不执行任何用户定义的初始化代码。

如果类或接口有实例字段,则在处理整个类层次结构的静态字段后,也会为这些字段分配内存并赋予默认值。此准备步骤为程序的执行奠定了基础,从而实现了高效的运行时性能。

由于链接涉及新数据结构的分配,因此可能会失败OutOfMemoryError。

3.2.3.决议

在这里,类或接口中的任何符号引用(指向其他类或接口的逻辑引用)都被替换为其实际的内存位置。这种从符号引用到直接引用的转换(通常称为动态链接)可确保类或接口的所有依赖项在运行时可用。

有趣的是,此步骤可以“懒惰”地执行,即仅当执行带有符号引用的语句时才执行。大多数 JVM 都使用这种方法,它可以节省资源,因为它可以防止不必要地加载可能永远不会调用的类或接口。

如果无法找到符号引用所指的类,则会引发java.lang.ClassDefNotFound或异常。java.lang.ClassNotFound。

3.3.初始化

在这里会执行每个被加载的类或接口的初始化逻辑(例如调用某个类的构造函数),由于JVM是多线程的,所以类或接口的初始化必须进行同步,防止被多个线程同时初始化,保证线程安全。

JVM 调用特殊方法(静态块和变量赋值的字节码版本),将所有静态变量设置为其指定的初始值。此时,该类终于可以使用了。

就这样:应用程序类已被找到、链接、初始化,现在可以集成到JVM中。JVM 现在退居幕后,将舞台留给您的应用程序。这些类充满了功能,并以错综复杂的网络相互连接,准备为您的应用程序注入活力。现在可以创建和操作类,并调用方法并设置由应用程序逻辑定义的变量。

;