Java · 类加载与双亲委派

.class 从字节流到可用对象: 生命周期五阶段 · 加载器层次 · 双亲委派的"为什么"与"怎么打破"

类的生命周期 (懒加载: 首次"主动使用"才初始化) 1 加载 字节流 → 方法区模板 2 验证 格式/语义/字节码安全 3 准备 static 变量给默认零值 4 解析 符号引用 → 直接引用 5 初始化 <clinit> 真正赋值+static 块 2-4 合称"链接" — 常见考点: static int a=1 在准备阶段是 0, 初始化阶段才变 1 (final 常量编译期即定) Bootstrap ClassLoader 核心库 rt.jar / java.base 模块 (C++ 实现) Platform ClassLoader (原 Extension) 平台扩展模块 (JDK 9+ 模块化) Application ClassLoader classpath / -jar 应用类 自定义 ClassLoader 网络/加密字节流/隔离容器/热部署 loadClass: 先一路委托到顶 逐级尝试: 找到→返回 找不到→交给子级 为什么双亲委派 安全: 伪造的 java.lang.String 永远轮不到自定义加载器 唯一: 同一个类一个加载器只加载一次 类的身份证 = 全限定名 + 加载器 instanceof 跨加载器即 false! 谁在打破它 SPI/JDBC: 核心接口要加载 厂商实现 → 线程上下文加载器 Tomcat: 每 webapp 独立加载器 互相隔离同名类 热部署/Agent: 重新加载替换 自定义: 重写 loadClass 或 findClass 生产第一现场: NoSuchMethodError / NoClassDefFoundError / ClassCastException 九成是同 GAV 多版本共存, 不同加载器/不同 jar 各拿一份 → mvn dependency:tree | grep 冲突件 → dependencyManagement 统一 fat-jar 场景: 解压看 BOOT-INF/lib 下实际打入的版本; ClassLoader 印证: clazz.getClassLoader() Legend 内置加载器层次 自定义/打破 生命周期 初始化

初始化的触发时机

  • • new / 读写静态字段 / 调静态方法
  • • 反射 / 子类初始化先初始化父类
  • • main 所在类 — 但被动的 final 常量读取不触发

双亲委派三价值

  • • 安全: 核心 API 不可能被顶层之外的加载器提供
  • • 唯一: 类身份 = 名字 + 加载器, 防重复加载
  • • 层次清晰: 越基础的类由越顶层的加载器负责

打破是特性不是漏洞

  • • SPI 需要子加载器 → 线程上下文加载器反向委托
  • • 容器需要隔离 → Tomcat 每个 app 一套加载器
  • • 热部署需要替换 → 新加载器重新 load 同名类

💡 一句话理解

类加载回答"字节码怎么变成 JVM 里的类": 五个生命周期阶段里只有初始化执行你的 static 赋值; 而双亲委派是一条"先问父亲"的加载规则 — 用"核心类永远由顶层提供"换来安全与唯一。生产中它露面的场合几乎只有一个: Jar 包多版本冲突引发的 NoSuchMethodError —— 解药是 dependency:tree 而不是重启。

🧠 必知必会 必考 & 必会

准备 vs 初始化
static int a = 1: 准备阶段 a=0(零值), 初始化阶段执行 <clinit> 才置 1; static final int A = 1 是 ConstantValue, 准备阶段就是 1 — 高频考点。
static int a = 1;          // 准备: a=0 零值; <clinit> 才置 1
static final int A = 1;    // ConstantValue: 准备阶段即 A=1
<clinit> 的线程安全
JVM 保证 <clinit> 在并发下只被执行一次(类初始化锁) — 这也是"静态内部类单例"天然线程安全的原理。但 static 块里做重活会阻塞所有触发初始化的线程。
static { loadFromDb(); }   // 全局串行加锁, 首访并发全阻塞
// 关键: 初始化锁保证 <clinit> 只跑一次 → 静态单例天然安全
类身份 = 名 + 加载器
两个加载器加载同名类, JVM 视为两个类: instanceof 为 false、ClassCastException。这是 Tomcat 隔离与"诡异转型异常"的根源。
Class<?> c1 = loaderA.loadClass("User");
Class<?> c2 = loaderB.loadClass("User");
c1 == c2;   // → false: 名字相同, 加载器不同 = 两个类
loadClass vs findClass
重写 loadClass = 破坏委派逻辑(慎); 重写 findClass = 保留委派, 只自定义"怎么拿到字节码"(推荐)。URLClassLoader 已覆盖大部分场景。
class MyCL extends URLClassLoader {
    protected Class<?> findClass(String name) { ... }
}   // 关键: 只重写 findClass = 委派骨架不动(推荐)
SPI 的反向难题
java.sql.DriverManager 在核心层, 却要加载 classpath 上的 MySQL 驱动 — 父加载器"看不见"子级类。解法: Thread.currentThread().setContextClassLoader 提前塞进去, 核心层反向借用。JDK 9+ 的 ServiceLoader 同思路。
// DriverManager 在核心层, 父加载器够不到应用 classpath:
ServiceLoader.load(Driver.class)  // 内部取 TCCL 反向委托给子级
懒加载的实际影响
类初始化是懒的且加锁: 冷启动时成千上万个 <clinit> 串行执行 — 这是"Java 启动慢"的重要成分, 也解释了为何要看类加载数(JVM CI/CRaC/AOT 都是冲它去的)。
java -verbose:class -jar app.jar | wc -l
# → 上万行: 每行一次加载, 部分 <clinit> 串行执行
// 关键: CDS / AOT / CRaC 优化的正是这串类初始化

🏭 生产实战 real world

场景 1 · NoSuchMethodError 三步定位

运行期才炸(编译期用的是另一个版本), 典型于传递依赖升级后:

// 1. 谁 import 了冲突类:
mvn dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind
# 2. 运行时实际加载的是哪个 jar (绝杀):
System.out.println(SomeClass.class.getProtectionDomain()
                   .getCodeSource().getLocation());
# 3. 统一版本: <dependencyManagement> 锁定 / maven-enforcer banneduplicates

场景 2 · 静态内部类单例 (类加载锁保平安)

public class Config {
    private static class Holder {
        static final Config INSTANCE = new Config();  // <clinit> 由 JVM 加锁执行一次
    }
    public static Config get() { return Holder.INSTANCE; }     // 首次调用才初始化 = 懒加载
}

场景 3 · 热加载配置/插件 (自定义加载器)

规则引擎脚本放 DB, 改完不重启:

URLClassLoader cl = new URLClassLoader(new URL[]{jarUrl},
                             RuleHolder.parentClassLoader);
Class<?> rule = cl.loadClass("com.x.RuleV2");   // 新加载器 → 全新 Class 对象
RuleHolder.swap(rule, cl);                        // 旧 loader 置空后可被 GC (防泄漏关键)

注意: 每代用独立加载器, 旧代必须断引用, 否则 Class 对象 + Metaspace 泄漏(报 OutOfMemoryError: Metaspace)。

场景 4 · ServiceLoader 加载 SPI 实现

自己框架的扩展点, 用 JDK 内置 SPI 机制(与 JDBC 同款):

// 1. 定义接口 (api 模块)
public interface PayProvider { PayResult pay(Order o); }
# 2. 实现方 jar 里声明 META-INF/services/com.x.PayProvider 文件, 内容为实现类全名
# 3. 框架侧一行加载所有实现
ServiceLoader.load(PayProvider.class)
             .forEach(p -> REGISTRY.put(p.type(), p));   // 自动发现, 零手写注册

场景 5 · 插件目录热扫描

规则引擎插件放 plugins/*.jar, 每个插件独立加载器实现隔离与替换:

URL[] jars = Files.list(dir).filter(p -> p.toString().endsWith(".jar"))
        .map(p -> { try { return p.toUri().toURL(); } catch ... })
        .toArray(URL[]::new);
URLClassLoader pluginCL = new URLClassLoader(jars, appCL);   // 父加载器=App: 可见公共接口
Class<?> cls = pluginCL.loadClass("com.plugin.Main");
REGISTRY.put(pluginName, cls);                                  // 换插件: 新 CL 加载, 旧引用置空

场景 6 · fat-jar 里读资源的正确姿势

Spring Boot 嵌套 jar 里 File 读不到, 必须走流:

// 坏: fat jar 内资源不是文件系统路径, 直接 OOM/FileNotFound
new File(getClass().getResource("/rules.json").toURI())
# 好: 流式读取, 任何打包形态都成立
try (InputStream in = getClass().getClassLoader()
        .getResourceAsStream("rules.json")) { ... }

场景 7 · 全公司版本统一: BOM 收口

类冲突的治本方案是在依赖管理层面收口, 而不是运行期躲:

<dependencyManagement>
  <dependencies>
    <dependency>                       // 平台 BOM 一次锁版本
      <groupId>com.x</groupId><artifactId>bom-all</artifactId>
      <version>2024.09</version><type>pom</type><scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>
# CI 加 maven-enforcer: banDuplicatePomDependencyVersions + dependencyConvergence

场景 8 · Java Agent 热修复(premain/agentmain)

线上紧急 bug 不重启热替换类字节码:

public class HotFixAgent {
    public static void agentmain(String args, Instrumentation inst)
            throws Exception {
        Class<?> target = Class.forName("com.x.RiskService");
        byte[] patched = Files.readAllBytes(Path.of("RiskService.class"));
        inst.redefineClasses(new ClassDefinition(target, patched));  // 只能改方法体
    }
}
# 挂载: VirtualMachine.attach(pid).loadAgent("hotfix.jar") — 用完即卸, 转正式发布

场景 9 · 三方 SDK 冲突隔离: shade 重定位

SDK 自带旧版 guava 与宿主冲突, 打包时整体改名搬家:

<plugin>
  <artifactId>maven-shade-plugin</artifactId>
  <configuration><relocations>
    <relocation>
      <pattern>com.google.common</pattern>
      <shadedPattern>com.x.shaded.guava</shadedPattern>   // 字节码级搬家+改引用
    </relocation>
  </relocations></configuration>
</plugin>
# 代价: 类"同源不同名", 与外部 guava 互不兼容 — SDK 边界勿泄漏 shaded 类型

场景 10 · 启动加速: 分层 CDS 与 AOT

类加载是冷启动大头, JDK 17+ 有现成工具链:

// Spring Boot 3 + JDK17: 开启分层 CDS
java -Djarmode=tools -jar app.jar extract --layers
java -XX:SharedArchiveFile=app.jsa -jar app.jar
# 极致: GraalVM native-image (AOT)
native-image -jar app.jar              # 毫秒级启动, 代价: 反射/动态代理要配置, JIT 峰值没了

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 依赖 shaded 后行为突变 — shade 重定位了某库包名, 与别处加载的"同名不同 loader"类互不兼容。正解: ClassCastException 先查 getClassLoader() 是否相同。
(MySet) lib.get();  // 错: 一见 CCE 就怀疑业务逻辑
// 对: 先比 obj.getClass().getClassLoader() — shaded 与原生必不同
坑 2 · static 块里干重活 — 类初始化是全局串行加锁的, static 里连数据库/拉配置, 高并发首访会集体阻塞。正解: 移到显式懒加载方法, 或限定只做纯赋值。
static { cfg = loadFromDb(); }   // 错: 初始化全局串行, 首访集体阻塞
static synchronized Config cfg() {  // 对: 显式懒加载接管重活
    if (cfg == null) cfg = loadFromDb(); return cfg; }
坑 3 · 循环类初始化 — A 的 <clinit> 触发 B, B 又回触发 A, 可能拿到"半初始化"状态(不报错但字段是默认值)。正解: 避免静态初始化相互依赖; 用 log 打印 <clinit> 顺序排查。
class A { static int X = B.Y; }  // A→B, B 又回头读 A.X
class B { static int Y = A.X; }  // → 不报错但 X=Y=0 半初始化
坑 4 · 自定义加载器泄漏 — 热部署每版新建 loader 却被缓存持有, Metaspace 只涨不跌。正解: 旧版本显式置空引用; 监控 Metaspace 与类加载数(jstat -class / ClassLoadingMXBean)。
cache.put(v, new HotSwapCL());  // 错: 旧 loader 被缓存持有, 永不回收
// 对: 旧版置 null; jstat -class 监控 Loaded 只涨不跌
坑 5 · 胖 jar 里同名资源/类顺序不定 — 两个 jar 都有 META-INF/services 或同名类, 打包顺序决定谁生效。正解: enforcer 排重; 运行期用 codeSource 打印确认"到底加载了谁"。
# 错: 两 jar 都带同名类 → 谁排前谁生效, 全凭打包顺序
c.getProtectionDomain().getCodeSource()  // 对: 打印实际来源实锤
坑 6 · 以为 Thread.contextClassLoader 万能 — 它默认是 App 加载器, 在容器/线程池里可能被改写过。正解: 使用后恢复原值(try/finally), 避免污染线程池里的其他任务。
// 错: 假定 TCCL 永远是 AppClassLoader(容器/池里可能被改写)
ClassLoader t = Thread.currentThread().getContextClassLoader();  // 对: 先取再判空用
坑 7 · forName 与 loadClass 的初始化差异 — Class.forName(name) 默认执行静态初始化; CL.loadClass(name) 只加载不初始化。注册驱动用 forName(要触发 static 块), 框架扫描用 loadClass(避免误触发)。混用 = 幽灵静态副作用。
Class.forName(name);              // 错在混用: 默认触发静态副作用
Class.forName(name, false, cl);    // 对: 只加载; loadClass 同样不初始化
坑 8 · TCCL 改完不恢复 — 线程池里的线程是复用的, setContextClassLoader 后不还原, 后续无关任务全被你改的加载器影响。正解: try/finally 保存恢复原值。
t.setContextClassLoader(myCl);   // 错: 池内线程复用, 污染后续任务
try { t.setContextClassLoader(myCl); run(); }
finally { t.setContextClassLoader(old); }  // 对: 保存恢复
坑 9 · 重写 loadClass 破坏委派 — 自定义加载器直接重写 loadClass 跳过父级, 核心 API 的唯一性保证被自己拆掉。正解: 只重写 findClass(字节码来源), 委派骨架不动。
protected Class<?> loadClass(String n) {   // 错: 跳过父级拆掉唯一性
    return findMyBytes(n); }
protected Class<?> findClass(String n) { ... }  // 对: 只管字节码来源
坑 10 · 资源路径斜杠差异 — Class.getResource 相对路径无斜杠会按包名解析, ClassLoader.getResource 必须以 / 开头(绝对)。正解: 统一用 ClassLoader + 前导斜杠, 封装工具方法。
MyCls.getResource("a.txt");    // 相对当前包名解析, 两写法行为不同
MyCls.getResource("/a.txt");   // 对: 前导斜杠 = classpath 根, 语义明确
坑 11 · shaded 与原生共存转型异常 — 同一库 shaded 版与原生版同 JVM 共存, 参数类型"看着同名实为两类", ClassCastException 隐晦。正解: SDK 边界只暴露自有类型; 异常先比 getClassLoader()。
// 错: 参数"同名实为两类" → 隐晦的 ClassCastException
ex.getClass().getClassLoader();  // 对: 异常先比加载器; SDK 边界只出自有类型
坑 12 · 并行类加载死锁 — 两个线程各持一把加载器锁互等对方加载的类(JDK7 前常见)。正解: 自定义加载器注册为 parallel capable(registerAsParallelCapable()); 升级 JDK。
// 错: 不注册 → 两线程各持加载器锁互等 → 类加载死锁
static { registerAsParallelCapable(); }  // 对: 锁细化到类名粒度
坑 13 · static 单例被多加载器实例化 — 两个加载器各加载一次"单例"类 → 两个实例, 配置/连接池双份。正解: 单例类只由一个加载器管(放公共层); 或用全局注册表 keyed by class name。
// 错: loaderA/loaderB 各加载 Singleton → 两份实例两个连接池
// 对: 单例放公共层只由一个加载器管, 或注册表按类名全局唯一
坑 14 · slf4j 多绑定冲突 — fat jar 里多个日志实现同时被发现, "Class path contains multiple SLF4J bindings"。正解: 依赖树排除多余实现; 统一走平台日志桥接方案。
# 错: fat jar 同时塞 logback + slf4j-log4j12 → multiple bindings
mvn dependency:tree -Dincludes=org.slf4j  # 对: 排除多余实现
坑 15 · 资源路径含空格/中文 — URL 转义的 %20 让 File(URI) 与字符串拼路径行为不一致, 本地好好的线上炸。正解: 永远走 getResourceAsStream 流式读取, 不落 File。
// 错: URL 里 %20, new File(url.getPath()) 本地好线上炸
Class.getResourceAsStream("/cfg.json")  // 对: 流式读不落 File
坑 16 · redefine 当万能热修 — redefine 不能增删字段/方法/改继承, 只能换方法体; 复杂改动运行时报错。正解: 热修仅限紧急止血, 修复必须走正式发布流程。
// 错: 想用 redefine 加字段/改继承 → 运行时报不支持
// 对: 只换方法体; 复杂改动走正式发布, 热修仅紧急止血
坑 17 · 子线程 TCCL 的继承时机 — TCCL 在线程创建时继承, 池里老线程不随提交者变。正解: 任务内显式 set/restore, 或用装饰器(Runnable 包装)统一处理。
// 错: 提交后才改提交者 TCCL — 池内老线程无感, 拿的还是老上下文
// 对: Runnable 装饰器内显式 set / finally restore
坑 18 · JPMS 未开放包 — JDK9+ 模块系统下反射闯内部包直接 IllegalAccessException(add-opens 也要版本匹配)。正解: 只用导出 API; 深度依赖内部类的工具先确认 opens 配置。
# 错: 反射闯 JDK 内部包 → IllegalAccessException
java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar  # 对
坑 19 · 加密字节码与生态不兼容 — 自定义加密加载器能跑, 但 agent/native-image/静态分析全瞎。正解: 保护代码换商业混淆器或 native 编译, 别在加载器层做加密闭环。
# 错: 加密加载器闭环 → agent / native-image / 静态分析全瞎
# 对: 商业混淆器或 native-image 保护, 不在加载器层做加密
坑 20 · 依赖树"看起来对" — dependency:tree 显示唯一版本, 但 shade/provided/系统路径还能把旧版本塞进来。正解: 终审以运行时为准 — codeSource 打印 + ClassGraph 扫实际 classpath。
# 错: 只看 dependency:tree 就断定版本唯一(shade 能绕过)
c.getProtectionDomain().getCodeSource()  // 对: 运行时实锤加载来源