2700 字
约 9 分钟
20
S05-X20 · 类加载机制与热部署的边界
2024-12-31
2026-11-08

改一行日志级别要不要重启?改一个方法体要不要重启?答案不在工具,在这次改动在 JVM 看来是不是“同一个类”。热部署的多数失败——静态状态残留、ClassCastException 指向两个同名类、改完代码不生效——都源于没分清这条线。


1. 类加载的五个阶段

阶段 做什么 常见失败
加载 Loading 读字节码,生成 Class 对象 ClassNotFoundException、NoClassDefFoundError
验证 Verification 字节码合法性、栈图检查 VerifyError、ClassFormatError
准备 Preparation 分配静态变量内存并赋零值 此时读到的静态字段都是 0/null
解析 Resolution 符号引用转直接引用 NoSuchMethodError、NoSuchFieldError
初始化 Initialization 执行 <clinit>,父类先于子类 ExceptionInInitializerError
public class Flag {
    static int a = 1;          // 准备阶段 a = 0,初始化阶段才赋为 1
    static final int B = 2;    // 编译期常量:准备阶段直接是 2(ConstantValue)
    static { System.out.println("clinit a=" + a); }
}

由此解释两类困惑:<clinit> 之前反射读静态字段拿到的是零值;static final 编译期常量不走 <clinit>,会被内联进引用方的常量池——所以改常量值,对方可能毫无反应。


2. 双亲委派与它保证的事

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        Class<?> c = findLoadedClass(name);          // 1. 已加载?
        if (c == null) {
            try { c = parent.loadClass(name, false); }   // 2. 先问父
            catch (ClassNotFoundException e) { }
            if (c == null) c = findClass(name);          // 3. 自己找
        }
        return c;
    }
}
加载器 加载什么 典型来源
Bootstrap java.base 等核心模块 JVM 内置,getClassLoader() 返回 null
Platform 平台模块中的部分类 JDK 9 起的 PlatformClassLoader
Application classpath / 模块路径上的应用类 -cp、--module-path

它保证的事只有一件:核心类不会被应用自己的同名类替换。你写一个 java.lang.String,永远加载不到——因为委派链上 Bootstrap 先命中。这条保证是安全模型与类型一致性的地基。


3. 打破双亲委派的三种场景

场景 怎么做 目的
SPI(JDBC/JNDI 等) 用线程上下文类加载器 Thread.currentThread().getContextClassLoader() 核心库要加载“实现方”的类,而核心库的加载器看不到应用类
Tomcat 等容器 WebappClassLoader 对应用类先查本地再委派父 每个 webapp 独立、可单独重载
OSGi 按 bundle 的 import/export 构建依赖图,网状委派 细粒度版本共存与动态装卸
// ServiceLoader 内部就是这么做的:用 TCCL 去发现实现类
ServiceLoader<Driver> drivers = ServiceLoader.load(Driver.class);
// 等价于 load(Driver.class, Thread.currentThread().getContextClassLoader())
// 调用方可以显式指定,避免容器里拿错加载器:
ServiceLoader<MySpi> spi = ServiceLoader.load(MySpi.class, MySpi.class.getClassLoader());

TCCL 是双亲委派的“合法后门”,也是容器里最容易出错的地方:线程池里的线程若由别的应用创建,其 contextClassLoader 可能指向另一个 webapp,ServiceLoader 就会找到“别人家加载器”里的实现类。


4. 类与类加载器共同决定唯一性

URLClassLoader l1 = new URLClassLoader(new URL[]{pluginUrl}, parent);
URLClassLoader l2 = new URLClassLoader(new URL[]{pluginUrl}, parent);
Class<?> c1 = l1.loadClass("com.example.Plugin");
Class<?> c2 = l2.loadClass("com.example.Plugin");
System.out.println(c1 == c2);   // false —— 同一个 class 文件,两个不同的类
现象 原因
instanceof 返回 false 两个类不是同一个运行时类
ClassCastException: ... cannot be cast to ... 报错信息里常带两个不同的 loader 名
getClass() != Expected.class 判断类身份时只比了全限定名
静态字段各有一份 每个类有自己的 static 区,不共享

唯一性 = 全限定名 + 定义类加载器。这也是“热部署后 ClassCastException 突然出现”的机制:旧对象由旧加载器加载,新代码由新加载器加载,接口同名但类型不同源。


5. 热部署能做什么、不能做什么

改动类型 能否热更 说明
方法体内部逻辑 可以 Instrumentation.redefineClasses 支持替换方法体
新增/删除字段或方法 通常不行 JVM 的类重定义不允许改变成员结构,只能整类重载
修改方法签名 不行 属于结构变更
修改继承关系 / 实现接口 不行 同上
改 static final 常量值 视情况 编译期常量会被内联到引用方,需重编引用方
改配置文件 / 模板 / 静态资源 不是类加载问题 走各自的刷新机制

redefineClasses 能改方法体与部分常量池引用,但不能增删成员、不能改签名、不能改继承。所以“加个字段”只能换新加载器整类重载,代价是静态状态与已有实例全部作废。


6. JRebel 与 Spring Boot DevTools 的机制差异

维度 JRebel Spring Boot DevTools
机制 Java Agent 改写字节码 + 类版本映射 双类加载器:base + restart,改动触发 restart
生效范围 方法体、成员、部分结构变更 重新加载被改动的类(本质是重启加载器)
静态状态 尽量保留(这是它的卖点) 丢弃,restart 加载器重建
代价 商业授权、启动加 agent 免费、restart 期间应用短暂不可用
失败模式 版本映射错乱、需清缓存 restart 循环、端口占用、静态缓存丢失
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-devtools</artifactId>
    <optional>true</optional>
</dependency>

触发条件是 classpath 变化或 spring.devtools.restart.trigger-file 指定的文件;不想触发就设 spring.devtools.restart.enabled=false。

分辨两者很简单:DevTools 的“热”是重启得快,不是不重启,所以它一定丢静态状态、一定重建单例;JRebel 尝试保留堆上状态,代价是行为与“真正重启过”不再等价。


7. 类卸载的条件与“重启后内存还涨”

类被卸载需要同时满足:该类的所有实例可回收、Class 对象本身可回收、定义它的 ClassLoader 可回收。三者缺一,类就留在 Metaspace 里。

常见“加载器回收不掉”的原因 表现
线程的 contextClassLoader 指向它 线程池常驻 → 加载器常驻
ThreadLocal 的 value 持有加载器中的对象 线程活多久,加载器就活多久
静态字段把加载器(或其类)登记进全局表 全局表不清,永远可达
JNI 全局引用 / 第三方库缓存 Class 需重启进程

“重启后内存还是涨”则通常是另一类问题,按顺序排查:

  1. 进程真的重启了吗——端口被旧进程占着、蓝绿切换后流量仍打到旧实例;
  2. 看的是哪个内存——整机或容器 RSS 含 page cache 与其他进程;
  3. 抓错了进程——jcmd 给的 PID 还是旧实例。

Metaspace 在进程重启后必然归零,如果“重启后 Metaspace 仍高”,那一定不是同一个进程。


8. Metaspace 泄漏的排查

$ jcmd 5123 VM.metaspace                      # 按加载器/类型看占用
$ jcmd 5123 GC.class_histogram | grep -E '\$\$|\$Proxy|Generated'   # 动态类计数
$ java -XX:MaxMetaspaceSize=512m ...          # 兜底,防止吃光容器内存
动态类来源 类名特征 处置
JDK 动态代理 jdk.proxy$... / $ProxyN 缓存 Proxy 实例,别每次 newProxyInstance
CGLIB / ByteBuddy Xxx$$EnhancerBy...、Xxx$ByteBuddy$... 复用增强后的类,别重复生成
反射访问器 GeneratedConstructorAccessor 等 降低反射频率或缓存 MethodHandle
自定义加载器反复 new 加载器数随请求增长 复用加载器,用后释放引用

GC.class_histogram 不列 hidden class(Lambda 等),“类数没涨但 Metaspace 涨”时要看 VM.metaspace 的按加载器分组。


9. 排错:热部署与类加载的坑

现象 原因 处理
改完代码没生效 类被父加载器抢先加载(委派在先) 确认改动类由哪个加载器定义,必要时清 restart 加载器
ClassCastException 指向两个同名类 同一 class 被两个加载器加载 统一由同一加载器加载,或改用接口下沉到 base 层
热部署后静态缓存还是旧值 加载器未重建 / 状态被保留 显式清理静态状态,或真正重启
自定义加载器越用越多 每次 new 且未释放引用 单例复用,关闭时断开引用
ThreadLocal 与加载器互持 线程池线程持有 value finally 里 remove()
Metaspace 持续增长 动态类生成无上限 缓存代理/增强类,设 MaxMetaspaceSize
以为改配置也算热部署 配置不参与类加载过程 用配置中心或 @RefreshScope 重建 Bean

10. 边界:什么时候必须重启

场景 为什么必须重启 例外
改了类结构(字段/签名/继承) JVM 不支持结构级重定义 换新加载器整类重载并重建实例
换了依赖版本 旧类已由 base 加载器定义,替换不掉 把该依赖移入 restart 加载器
改了启动参数 参数在 JVM 启动时固定 jcmd VM.set_flag 仅对少数可管理参数有效
改了 static final 常量并被内联 引用方常量池已写死 重编引用方或避免编译期内联
加载器泄漏已发生 旧加载器仍被引用 只能重启进程

收口:边界不在工具,而在 JVM 对“类的身份”的定义——名字 + 加载器,一次性确定。


11. 自测

  1. 准备阶段与初始化阶段,static int a = 1 的值分别是什么?
  2. 双亲委派保证的那一件事,具体防止了什么?
  3. SPI 为什么必须用线程上下文类加载器?用错会拿到谁的实现类?
  4. 同一个 class 文件被两个加载器加载后,instanceof 为什么会返回 false?
  5. redefineClasses 能做与不能做的分界在哪一条?
  6. 类卸载需要同时满足哪三个条件?
S05-X20 · 类加载机制与热部署的边界
https://www.clxhxhhr.top/posts/5914/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。