专栏 编程工程

1.2.5 Java 性能与调优 · 类加载机制与 Tomcat 打破双亲委派

类加载机制 + 打破双亲委派的 3 个真实场景。

1. 类加载 5 阶段

JVM 把 class 从字节码加载到内存并完成可用化,严格按 加载 → 验证 → 准备 → 解析 → 初始化 五阶段顺序推进,中间阶段允许交叉(主要是解析可在初始化之后),整体由 JLS §12.4 规定。

阶段 输入 主要动作 输出
Loading .class 字节流 通过类名读字节流、生成 Class 对象 方法区 Class 实例 + 堆 Class 对象
Verification Class 文件结构 文件格式 / 元数据 / 字节码 / 符号引用校验 通过校验的安全 Class
Preparation 类变量表 为 static 变量分配内存并赋零值 零值化的类变量
Resolution 常量池符号引用 把符号引用替换为直接引用 已解析的运行时引用
Initialization 类变量 + 静态块 执行 <clinit>():static 赋值 + static {} 类完成初始化,可被使用

初始化触发时机(JLS §12.4.1): new / getstatic / putstatic / invokestatic 这 4 条字节码指令,反射调用,子类初始化触发父类初始化,主类 main。被动引用(如子类引用父类 static 字段)不会触发子类 <clinit>,这是面试常考点。<clinit>() 由编译器自动收集类变量赋值与 static {} 合并生成,线程安全由 JVM 保证,但写法上要避免循环依赖。

2. 双亲委派模型(Parent Delegation Model)

JVM 自带 3 层 ClassLoader,加载请求自底向上委派、类查找自顶向下执行,目的是保证核心类由启动类加载器统一加载,防止被同名类覆盖。

加载器 实现类(JDK 8) 负责加载路径 父加载器
Bootstrap ClassLoader C++ 实现,getClassLoader() 返回 null $JAVA_HOME/lib/rt.jar、resources.jar 等核心类 无
Extension ClassLoader sun.misc.Launcher$ExtClassLoader $JAVA_HOME/lib/ext/*.jar Bootstrap
Application ClassLoader sun.misc.Launcher$AppClassLoader -classpath / -cp 指定的类与 jar Extension
自定义 ClassLoader 继承 ClassLoader 用户自定义路径 通常为 AppClassLoader

委派逻辑在 ClassLoader.loadClass():

protected Class<?> loadClass(String name, boolean resolve) {
    synchronized (getClassLoadingLock(name)) {
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try { c = parent.loadClass(name, false); }
            catch (ClassNotFoundException) { /* parent 找不到 */ }
            if (c == null) c = findClass(name); // 自己找
        }
        if (resolve) resolveClass(c);
        return c;
    }
}

父类无法加载时才由自己 findClass() 兜底——这就是”双亲委派”。但严格意义上它不是强制约束,而是一种推荐实现,任何自定义加载器都可以打破(下文展开)。注意 JDK 7+ 加入了并行类加载(getClassLoadingLock 用 ConcurrentHashMap),避免加载死锁。

3. 打破双亲委派的 3 个场景

Java 生态中至少有 3 个主流场景必须反向委派:基础类型由 Bootstrap 加载,但 SPI 实现放在 ClassPath 下,加载器必须反向”问儿子”。

场景 问题 解决方案
JDBC SPI DriverManager 在 rt.jar,由 Bootstrap 加载,但 mysql-connector-java 的 Driver 实现在 ClassPath DriverManager 用 Thread.currentThread().getContextClassLoader() 拿到 AppClassLoader 加载 Driver
Tomcat WebAppClassLoader 多个 WebApp 各自依赖 spring/日志版本,不能让 AppClassLoader 统一加载 自定义 WebappClassLoader,先自己 WEB-INF/lib 找,再委派 parent
OSGi 同一模块多版本共存(Equinox / Felix),需要按模块动态切换 Bundle ClassLoader 采用网状委派图,每个 bundle 互相 import/export

关键 API: Thread.setContextClassLoader() 是 SPI 机制的核心——Bootstrap 类需要”借用”应用加载器加载实现类,而自己又没有 AppClassLoader 引用,所以把加载器存在线程上下文里。JDBC 4.0 之后还引入了 ServiceLoader 机制,通过 META-INF/services/ 文件按 SPI 名自动发现实现类,本质也是同一套反向加载思路。

调研依据:Tomcat 9.x 源码 org.apache.catalina.loader.WebappClassLoaderBase#loadClass 显式注释了”Cache the class loader lock,防止递归 loadClass 死锁”,并在委派前优先 findClass() 自己目录。

4. Tomcat 类加载实战:为什么 WebApp 之间能类隔离

Tomcat 同时部署 AppA(Spring 5) 和 AppB(Spring 6),两者 spring-core jar 互不污染,关键是 WebappClassLoaderBase 重写了 loadClass() 默认顺序。

顺序 检查位置 目的
1 JVM 缓存 (findLoadedClass) 避免重复加载
2 本 WebApp 的 /WEB-INF/classes 与 /WEB-INF/lib 类隔离核心:各 App 自己的类优先
3 System class (Bootstrap / Ext / AppClassLoader) 共享 JRE 与 Tomcat 公共类,如 servlet-api
4 Common ClassLoader (shared/lib) Tomcat 共享 jar,如 el-api
5 父 AppClassLoader 兜底 加载第三方库最后兜底

典型故障排查: 当 App 报 ClassNotFoundException: org.apache.xerces...,先把对应 jar 放到 WEB-INF/lib/ 而非 tomcat/lib/,避免污染其他 App。Tomcat 6 之前默认先委托父加载器,导致 App 间互相覆盖,Tomcat 6+ 改为先自己再父才解决。

反之,如果你希望 jar 被所有 App 共享(比如统一日志组件),放到 $CATALINA_HOME/lib/ 即可——它由 Common ClassLoader 加载,所有 WebApp 都能看到。另外 Tomcat 提供 <Loader delegate="true"/> 配置项,在 context.xml 里可强制回到”传统双亲委派”,用于兼容老应用,但会牺牲隔离性。

5. Java 9+ 模块化系统(JPMS)对类加载的影响

Java 9 引入 Jigsaw / JPMS,把”jar 路径即模块”换成显式 module 声明,类加载器结构也由”线性三层”变为”模块化平台”。

维度 Java 8 Java 9+
加载路径 rt.jar / tools.jar 一个 fat jar jrt-fs 虚拟文件系统,按 java.base / java.sql 等模块切片
类可见性 public = 全局可见 模块未导出(exports)的包外部不可访问
ClassLoader 三层(bootstrap/ext/app)+ 自定义 平台类加载器(PlatformClassLoader) 替代 ExtClassLoader,负责 java.sql / java.xml 等非核心模块
反射限制 无 强封装:未 open 的模块默认不允许 setAccessible(true)

核心类加载变化: ClassLoader.getSystemClassLoader() 在 JDK 9+ 不再直接返回 AppClassLoader,而是返回 PlatformClassLoader 的子类 AppClassLoader;Bootstrap 加载 java.base / java.lang,Platform 加载 java.sql / java.logging,Application 加载 classpath 上的所有 named module / unnamed module。模块路径(--module-path)与 classpath 共存,前者走模块图,后者走 unnamed module 平铺。

实战影响: 框架如 Spring Boot 3 强制要求 Java 17+,运行在 JPMS 之上,遇到 IllegalAccessError 多半是模块未导出或未 open——加 --add-opens java.base/java.lang=ALL-UNNAMED 即可。这是模块化最常见的排错命令,记住。JPMS 至今在业务项目落地有限,主要是库作者要解决”如何在模块化平台下暴露 SPI”,而大多数应用仍跑在 classpath(unnamed module)上以避免复杂声明。

说明 · 本站内容均为学习笔记与经验总结,所有菜谱与技法请结合实际食材、季节与个人口味灵活调整。涉及生食、营养与健康的内容仅供参考,特殊体质或疾病请咨询专业营养师/医生。