改一行日志级别要不要重启?改一个方法体要不要重启?答案不在工具,在这次改动在 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 |
需重启进程 |
“重启后内存还是涨”则通常是另一类问题,按顺序排查:
- 进程真的重启了吗——端口被旧进程占着、蓝绿切换后流量仍打到旧实例;
- 看的是哪个内存——整机或容器 RSS 含 page cache 与其他进程;
- 抓错了进程——
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. 自测
- 准备阶段与初始化阶段,
static int a = 1的值分别是什么? - 双亲委派保证的那一件事,具体防止了什么?
- SPI 为什么必须用线程上下文类加载器?用错会拿到谁的实现类?
- 同一个 class 文件被两个加载器加载后,
instanceof为什么会返回 false? redefineClasses能做与不能做的分界在哪一条?- 类卸载需要同时满足哪三个条件?