.class 从字节流到可用对象: 生命周期五阶段 · 加载器层次 · 双亲委派的"为什么"与"怎么打破"
类加载回答"字节码怎么变成 JVM 里的类": 五个生命周期阶段里只有初始化执行你的 static 赋值; 而双亲委派是一条"先问父亲"的加载规则 — 用"核心类永远由顶层提供"换来安全与唯一。生产中它露面的场合几乎只有一个: Jar 包多版本冲突引发的 NoSuchMethodError —— 解药是 dependency:tree 而不是重启。
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
static { loadFromDb(); } // 全局串行加锁, 首访并发全阻塞 // 关键: 初始化锁保证 <clinit> 只跑一次 → 静态单例天然安全
instanceof 为 false、ClassCastException。这是 Tomcat 隔离与"诡异转型异常"的根源。 Class<?> c1 = loaderA.loadClass("User"); Class<?> c2 = loaderB.loadClass("User"); c1 == c2; // → false: 名字相同, 加载器不同 = 两个类
class MyCL extends URLClassLoader { protected Class<?> findClass(String name) { ... } } // 关键: 只重写 findClass = 委派骨架不动(推荐)
// DriverManager 在核心层, 父加载器够不到应用 classpath: ServiceLoader.load(Driver.class) // 内部取 TCCL 反向委托给子级
java -verbose:class -jar app.jar | wc -l # → 上万行: 每行一次加载, 部分 <clinit> 串行执行 // 关键: CDS / AOT / CRaC 优化的正是这串类初始化
运行期才炸(编译期用的是另一个版本), 典型于传递依赖升级后:
// 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
public class Config { private static class Holder { static final Config INSTANCE = new Config(); // <clinit> 由 JVM 加锁执行一次 } public static Config get() { return Holder.INSTANCE; } // 首次调用才初始化 = 懒加载 }
规则引擎脚本放 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)。
自己框架的扩展点, 用 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)); // 自动发现, 零手写注册
规则引擎插件放 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 加载, 旧引用置空
Spring Boot 嵌套 jar 里 File 读不到, 必须走流:
// 坏: fat jar 内资源不是文件系统路径, 直接 OOM/FileNotFound new File(getClass().getResource("/rules.json").toURI()) # 好: 流式读取, 任何打包形态都成立 try (InputStream in = getClass().getClassLoader() .getResourceAsStream("rules.json")) { ... }
类冲突的治本方案是在依赖管理层面收口, 而不是运行期躲:
<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
线上紧急 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") — 用完即卸, 转正式发布
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 类型
类加载是冷启动大头, 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 峰值没了
(MySet) lib.get(); // 错: 一见 CCE 就怀疑业务逻辑 // 对: 先比 obj.getClass().getClassLoader() — shaded 与原生必不同
static { cfg = loadFromDb(); } // 错: 初始化全局串行, 首访集体阻塞 static synchronized Config cfg() { // 对: 显式懒加载接管重活 if (cfg == null) cfg = loadFromDb(); return cfg; }
class A { static int X = B.Y; } // A→B, B 又回头读 A.X class B { static int Y = A.X; } // → 不报错但 X=Y=0 半初始化
cache.put(v, new HotSwapCL()); // 错: 旧 loader 被缓存持有, 永不回收 // 对: 旧版置 null; jstat -class 监控 Loaded 只涨不跌
# 错: 两 jar 都带同名类 → 谁排前谁生效, 全凭打包顺序 c.getProtectionDomain().getCodeSource() // 对: 打印实际来源实锤
// 错: 假定 TCCL 永远是 AppClassLoader(容器/池里可能被改写) ClassLoader t = Thread.currentThread().getContextClassLoader(); // 对: 先取再判空用
Class.forName(name) 默认执行静态初始化; CL.loadClass(name) 只加载不初始化。注册驱动用 forName(要触发 static 块), 框架扫描用 loadClass(避免误触发)。混用 = 幽灵静态副作用。 Class.forName(name); // 错在混用: 默认触发静态副作用 Class.forName(name, false, cl); // 对: 只加载; loadClass 同样不初始化
t.setContextClassLoader(myCl); // 错: 池内线程复用, 污染后续任务 try { t.setContextClassLoader(myCl); run(); } finally { t.setContextClassLoader(old); } // 对: 保存恢复
protected Class<?> loadClass(String n) { // 错: 跳过父级拆掉唯一性 return findMyBytes(n); } protected Class<?> findClass(String n) { ... } // 对: 只管字节码来源
MyCls.getResource("a.txt"); // 相对当前包名解析, 两写法行为不同 MyCls.getResource("/a.txt"); // 对: 前导斜杠 = classpath 根, 语义明确
// 错: 参数"同名实为两类" → 隐晦的 ClassCastException ex.getClass().getClassLoader(); // 对: 异常先比加载器; SDK 边界只出自有类型
registerAsParallelCapable()); 升级 JDK。 // 错: 不注册 → 两线程各持加载器锁互等 → 类加载死锁 static { registerAsParallelCapable(); } // 对: 锁细化到类名粒度
// 错: loaderA/loaderB 各加载 Singleton → 两份实例两个连接池 // 对: 单例放公共层只由一个加载器管, 或注册表按类名全局唯一
# 错: fat jar 同时塞 logback + slf4j-log4j12 → multiple bindings mvn dependency:tree -Dincludes=org.slf4j # 对: 排除多余实现
// 错: URL 里 %20, new File(url.getPath()) 本地好线上炸 Class.getResourceAsStream("/cfg.json") // 对: 流式读不落 File
// 错: 想用 redefine 加字段/改继承 → 运行时报不支持 // 对: 只换方法体; 复杂改动走正式发布, 热修仅紧急止血
// 错: 提交后才改提交者 TCCL — 池内老线程无感, 拿的还是老上下文 // 对: Runnable 装饰器内显式 set / finally restore
# 错: 反射闯 JDK 内部包 → IllegalAccessException java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar # 对
# 错: 加密加载器闭环 → agent / native-image / 静态分析全瞎 # 对: 商业混淆器或 native-image 保护, 不在加载器层做加密
# 错: 只看 dependency:tree 就断定版本唯一(shade 能绕过) c.getProtectionDomain().getCodeSource() // 对: 运行时实锤加载来源