《Java 虚拟机原理与性能调优》2.2 类加载过程:加载、链接、初始化与双亲委派

class 文件变成可用的类要经过加载、链接、初始化。本节用 -Xlog:class+load 与 jcmd VM.classloaders 的输出拆开这三步,实测 Class.forName 与 loadClass 的初始化差异、两个 URLClassLoader 加载同一 jar 的类身份冲突,并落到 libraryd 的 PluginClassLoader 与 ServiceLoader。

本节目标:分清「加载」「链接」「初始化」三件事,能解释同一个类为什么会有两个身份。
适用版本:JDK 25(Temurin 25.0.5);-Xlog:class+load 与 jcmd VM.classloaders 可用。

承接的问题

2.1 拆开了 Book.class 的字节,但字节躺在磁盘上不会自己变成可调用的类。libraryd 的插件机制要回答三个具体问题:

  • 插件 jar 里的类是什么时候被读进来的?读进来之后立刻执行静态初始化吗?
  • Class.forName("...") 和 ClassLoader.loadClass("...") 有什么区别,为什么 JDBC 驱动用前者、普通依赖注入用后者?
  • 同一个 jar 被两个插件加载器各加载一次,为什么 instanceof 会失败?

这三个问题都指向同一组阶段:加载(loading)、链接(linking)、初始化(initialization)。它们经常被混成一句「类加载」,但在排查问题时必须分开,因为每一步的失败方式和触发时机都不同。

加载、链接、初始化:三步不是一件事

加载做三件事:通过类加载器找到 class 文件的字节、把字节解析成方法区里的运行时结构、在堆里创建一个 Class 对象作为访问入口。链接分三步:验证(verification)检查字节码是否符合约束,准备(preparation)给静态字段分配内存并设零值,解析(resolution)把符号引用替换成直接引用。初始化执行 <clinit>,也就是静态字段的显式赋值和静态块。

三者的触发时机不同。加载可以由 loadClass 触发,初始化只在「首次主动使用」时发生:new、访问静态字段、调用静态方法、反射带 initialize=true、初始化子类等。

用 -Xlog:class+load=info 看加载:

java -Xlog:class+load=info:file=/tmp/vol4-a/logs/classload.log \
  -cp out com.plume.library.app.RuntimeProbe

本机日志共 889 行,节选:

[0.017s][info][class,load] java.lang.Object source: shared objects file
[0.036s][info][class,load] com.plume.library.app.RuntimeProbe source: file:/private/tmp/vol4-a/out/
[0.037s][info][class,load] com.plume.library.app.RuntimeSnapshot source: file:/private/tmp/vol4-a/out/
[0.720s][info][class,load] java.lang.invoke.LambdaForm$MH/0x00007ffe01047400 source: __JVM_LookupDefineClass__

三个 source 值得记住:

  • shared objects file 表示类来自 CDS 共享归档,不是每次从 jar 读。
  • file:/... 表示来自 classpath 目录或 jar。
  • __JVM_LookupDefineClass__ 表示由 JVM 自己定义,常见于 invokedynamic 生成的 lambda 形式。

加载发生在 loadClass 调用时,初始化不一定跟着发生。写一个带静态块的插件类:

package com.plume.library.plugin;

public final class InitPlugin {
    public static final int VERSION;

    static {
        System.out.println("InitPlugin.<clinit> executed");
        VERSION = 7;
    }
}

用三种方式加载它:

System.out.println("step1: loadClass");
Class<?> c1 = loader.loadClass(NAME);
System.out.println("step1 loaded=" + c1.getName());

System.out.println("step2: forName initialize=false");
Class<?> c2 = Class.forName(NAME, false, loader);
System.out.println("step2 same=" + (c1 == c2));

System.out.println("step3: forName initialize=true");
Class<?> c3 = Class.forName(NAME, true, loader);
System.out.println("step3 version=" + c3.getField("VERSION").getInt(null));

System.out.println("step4: forName initialize=true again");
Class.forName(NAME, true, loader);

本机输出:

step1: loadClass
step1 loaded=com.plume.library.plugin.InitPlugin
step2: forName initialize=false
step2 same=true
step3: forName initialize=true
InitPlugin.<clinit> executed
step3 version=7
step4: forName initialize=true again
step4 done

loadClass 和 forName(false) 都没有打印 <clinit>;forName(true) 打印一次;第四次再调用不再打印。这验证了两件事:加载与初始化分离;<clinit> 每个类加载器只执行一次。

同一份 -Xlog:class+load 里,InitPlugin 在 step1 就出现了:

[0.090s][info][class,load] com.plume.library.plugin.InitPlugin source: file:/private/tmp/vol4-a/plugin.jar

加载日志出现在静态块之前,顺序和上面的输出一致。

Class.forName 与 loadClass 的区别

两个 API 的差别可以压成一句话:Class.forName(name) 默认「加载 + 链接 + 初始化」,ClassLoader.loadClass(name) 默认只「加载」,链接和初始化推迟。

调用加载链接初始化常见用途
Class.forName(name)是是是JDBC 驱动注册、反射入口
Class.forName(name, false, loader)是是否只想拿到 Class 对象
loader.loadClass(name)是否否依赖注入、插件扫描
loader.loadClass(name, true)是是否需要提前暴露链接错误

Class.forName(name, false, loader) 仍会链接,只是不初始化;它适合「先检查类是否存在,但不想触发静态块副作用」的场景。loadClass 连链接都不做,解析是惰性的:第一次真正调用方法或访问字段时才解析,失败会以 NoClassDefFoundError 或 NoSuchMethodError 的形式出现在调用点。

JDBC 的 DriverManager 用 Class.forName 是为了触发驱动的静态块,把驱动实例注册进 DriverManager。现代 JDBC 4 之后可以省略这一步,因为 ServiceLoader 会从 META-INF/services 发现驱动。这也是为什么「不写 Class.forName 也能连数据库」——不是驱动变聪明了,是发现机制换了。

双亲委派与线程上下文类加载器

JVM 的加载器是一个树:

bootstrap
  └── platform
        └── app
              ├── libraryd-plugin-a
              └── libraryd-plugin-b

默认的双亲委派是「先问父亲,父亲找不到再自己找」。loadClass 的实现顺序是:已加载?→ 父加载器 → findClass。这样 java.lang.String 永远由 bootstrap 加载,应用无法用同名类替换核心类。

jcmd <pid> VM.classloaders verbose=true 能看到这棵树。本机加载了两个插件加载器后:

+-- <bootstrap>
      +-- "platform", jdk.internal.loader.ClassLoaders$PlatformClassLoader
            +-- "app", jdk.internal.loader.ClassLoaders$AppClassLoader
                  +-- "libraryd-plugin-a", java.net.URLClassLoader
                  +-- "libraryd-plugin-b", java.net.URLClassLoader

两个插件加载器的 Loader Klass 相同(都是 URLClassLoader),Loader Data 不同,说明它们是两个独立实例。Loader Data 是加载器内部状态,包含已加载类的表;两个实例各有各的表,所以同一个类名可以各存一份。

双亲委派有一个著名的破例:线程上下文类加载器(TCCL)。SPI 场景下,接口由 bootstrap 或 platform 加载,实现由应用加载;bootstrap 看不到应用的类,就需要 Thread.currentThread().getContextClassLoader() 帮忙。ServiceLoader.load(Plugin.class) 默认用 TCCL,原因就在这里。

线程池里跑任务时,TCCL 可能继承自创建线程的线程,而不是提交任务的线程。插件系统如果在池里加载实现,最好显式传加载器:

ServiceLoader<Plugin> plugins = ServiceLoader.load(Plugin.class, pluginLoader);

而不是依赖 ServiceLoader.load(Plugin.class) 去猜当前线程的 TCCL。

同一个类,两个身份:URLClassLoader 实验

类身份由「全限定名 + 定义它的加载器」共同决定。两个加载器各自加载同一个 jar,会得到两个不同的 Class 对象:

URL jar = new File(args[0]).toURI().toURL();
try (URLClassLoader a = new URLClassLoader("plugin-a", new URL[]{jar},
        ClassLoader.getPlatformClassLoader());
     URLClassLoader b = new URLClassLoader("plugin-b", new URL[]{jar},
        ClassLoader.getPlatformClassLoader())) {
    Class<?> ca = Class.forName("com.plume.library.plugin.FinePlugin", true, a);
    Class<?> cb = Class.forName("com.plume.library.plugin.FinePlugin", true, b);
    System.out.println("sameClass=" + (ca == cb));
    System.out.println("loaderA=" + ca.getClassLoader().getName());
    System.out.println("loaderB=" + cb.getClassLoader().getName());
    Object instanceA = ca.getDeclaredConstructor().newInstance();
    System.out.println("isInstanceA=" + ca.isInstance(instanceA));
    System.out.println("isInstanceB=" + cb.isInstance(instanceA));
    try {
        cb.cast(instanceA);
    } catch (ClassCastException e) {
        System.out.println("cast=" + e.getMessage());
    }
}

本机输出:

sameClass=false
loaderA=plugin-a
loaderB=plugin-b
isInstanceA=true
isInstanceB=false
cast=ClassCastException: Cannot cast com.plume.library.plugin.FinePlugin to com.plume.library.plugin.FinePlugin

ClassCastException 的这句话看起来像 bug:同一个类名,为什么不能转?因为 JVM 比较的不是名字,而是「名字 + 定义加载器」。ca 的定义加载器是 plugin-a,cb 的是 plugin-b,它们是两个不同的运行时类型。instanceof、checkcast、方法签名里的参数类型,全都按这个身份判断。

这也解释了为什么插件系统要把「接口」放在父加载器、把「实现」放在子加载器:接口只有一份身份,实现可以有多份。如果接口也被每个插件加载器各加载一次,那么 Plugin 接口本身就会分裂,ServiceLoader 和依赖注入都会失败。

ServiceLoader:用接口解耦,不用类身份

ServiceLoader 的约定是:接口在父加载器,实现类在插件 jar,jar 里有 META-INF/services/<接口全限定名> 文件,内容是实现类名。

接口:

package com.plume.library.plugin;

public interface Plugin {
    String id();
    int priority();
}

实现与描述文件:

package com.plume.library.plugin;

public final class FineRulePlugin implements Plugin {
    @Override
    public String id() {
        return "fine-rule-v2";
    }

    @Override
    public int priority() {
        return 20;
    }
}
META-INF/services/com.plume.library.plugin.Plugin
com.plume.library.plugin.FineRulePlugin

加载:

try (URLClassLoader loader = new URLClassLoader("libraryd-plugins",
        new URL[]{jar}, ServiceLab.class.getClassLoader())) {
    ServiceLoader<Plugin> plugins = ServiceLoader.load(Plugin.class, loader);
    int count = 0;
    for (Plugin plugin : plugins) {
        count++;
        System.out.println("plugin=" + plugin.id()
                + " priority=" + plugin.priority()
                + " loader=" + plugin.getClass().getClassLoader().getName());
    }
    System.out.println("count=" + count);
}

本机输出:

plugin=fine-rule-v2 priority=20 loader=libraryd-plugins
count=1

loader=libraryd-plugins 确认实现类由插件加载器定义,接口 Plugin 由父加载器定义。两边看到的是同一个接口身份,所以 Plugin 类型的引用可以直接指向实现实例。

在 libraryd 里落地

PluginClassLoader 的策略要写清楚:哪些包 child-first,哪些必须 parent-first。libraryd 的约定是 API 包 com.plume.library.plugin 由父加载器提供,实现包 com.plume.library.plugin.impl 由插件自己提供:

package com.plume.library.app;

import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Path;

public final class PluginClassLoader extends URLClassLoader {

    private static final String IMPL_PREFIX = "com.plume.library.plugin.impl.";

    static {
        ClassLoader.registerAsParallelCapable();
    }

    public PluginClassLoader(String name, Path jar, ClassLoader parent)
            throws MalformedURLException {
        super(name, new URL[]{jar.toUri().toURL()}, parent);
    }

    @Override
    protected Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException {
        synchronized (getClassLoadingLock(name)) {
            Class<?> loaded = findLoadedClass(name);
            if (loaded == null && name.startsWith(IMPL_PREFIX)) {
                try {
                    loaded = findClass(name);
                } catch (ClassNotFoundException ignored) {
                    // 回退到父加载器
                }
            }
            if (loaded == null) {
                loaded = super.loadClass(name, false);
            }
            if (resolve) {
                resolveClass(loaded);
            }
            return loaded;
        }
    }
}

三个细节:

  • 只对 IMPL_PREFIX 做 child-first。API 包仍走父加载器,保证接口身份唯一。
  • registerAsParallelCapable() 让同一个加载器的不同类可以并行加载,避免插件启动时互相阻塞。
  • getClassLoadingLock(name) 按类名加锁,不能用 synchronized (this) 锁整个加载器。

装配时用 ServiceLoader.load(Plugin.class, pluginClassLoader),并按 priority() 排序。插件加载失败不要让整个服务起不来:记录插件名、异常和跳过的类,继续加载下一个。插件系统的边界是「一个插件坏了不影响其他插件」,类加载器隔离正是为这个目标服务的。

常见错误与边界

错误一:用 Class.forName 做存在性检查。 默认会初始化,静态块里的副作用(注册驱动、启动线程、读配置)会提前执行。只想检查类是否存在,用 Class.forName(name, false, loader) 或 loader.loadClass(name)。

错误二:把 loadClass 当成「已经可用」。 loadClass 不链接、不初始化,静态字段可能还是零值。首次主动使用时才触发 <clinit>,如果静态块抛异常,会得到 ExceptionInInitializerError,而不是加载时的错误。

错误三:两个加载器各加载接口。 接口如果落在 child-first 的范围里,plugin-a 和 plugin-b 会各自定义一份 Plugin,ServiceLoader 用父加载器的接口去匹配时会找不到实现。API 包必须 parent-first 且只由父加载器定义。

错误四:在线程池里依赖 TCCL。 池线程的 TCCL 不一定是提交任务的线程的 TCCL。插件加载显式传 ClassLoader,不要用无参的 ServiceLoader.load。

边界一:类身份不等于类名。 日志里看到两个同名的 FinePlugin,不代表它们是同一个类型。排查 ClassCastException 时先打印 getClass().getClassLoader(),再比较类名。

边界二:卸载类很难。 一个类要被卸载,它的定义加载器必须不可达,且没有存活的 Class 对象、实例和静态引用。插件热替换失败,多数是某个线程或缓存还握着旧加载器。

小结

  • 加载、链接、初始化是三件事;loadClass 只保证加载,Class.forName 默认走到初始化。
  • -Xlog:class+load=info 能看到类来自 CDS、classpath 还是 JVM 自己定义。
  • <clinit> 每个类加载器只执行一次;forName(false) 不会触发它。
  • 双亲委派先问父加载器;SPI 通过线程上下文类加载器打破这棵树。
  • 类身份 = 全限定名 + 定义加载器;两个加载器加载同一 jar 会得到两个不同的 Class。
  • PluginClassLoader 只对实现包 child-first,API 包保持 parent-first;ServiceLoader 显式传加载器。

类加载解决了「类从哪来」,2.3 节进入「方法怎么执行」:栈帧、局部变量表、操作数栈,以及五种调用指令的分工。

阅读导航:上一节:2.1 类文件结构:常量池、方法表与属性 · 下一节:2.3 字节码执行模型:栈帧、局部变量表与操作数栈 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发