Spring DevTools热加载原理:为什么它要用两个ClassLoader
Spring DevTools热加载原理:为什么它要用两个ClassLoader
适读人群:Spring Boot开发者,对JVM类加载机制感兴趣的Java工程师 | 阅读时长:约15分钟
开篇故事
刚用DevTools那会儿,我就有个疑问:改了代码,IntelliJ重新编译,应用自动重启,但速度明显比冷启动快得多。这是怎么做到的?
有一次我在项目里引入了一个第三方库,改完代码重启,结果报了一个非常奇怪的ClassCastException——明明是同一个类,却说类型不匹配。看了异常信息,发现两个类名完全一样,但ClassLoader不同:一个是RestartClassLoader,另一个是AppClassLoader。
这才让我意识到:DevTools的热加载根本不是JVM层面的热替换,而是替换了一个ClassLoader,重新加载你的代码,但第三方库的ClassLoader保持不变。两个ClassLoader加载的同名类,在JVM眼里是完全不同的类型,所以才会出现ClassCastException。
今天我们就把这个机制从底层讲清楚。
一、为什么需要两个ClassLoader
1.1 Java ClassLoader的基本限制
JVM中,类的唯一标识是类名 + ClassLoader。同一个.class文件,用不同的ClassLoader加载,得到的是两个不相关的类型。
而且,已经加载的类无法被卸载(除非其ClassLoader被GC回收)。这意味着:
- 如果用同一个ClassLoader加载了类A,修改A的源码重新编译,你没办法让JVM"更新"已有的类A
- 唯一的方式是:丢掉原来的ClassLoader,创建一个新的ClassLoader,重新加载所有类
但重新加载所有类太慢了,包括Spring框架本身、大量第三方库,这些都不会变。
1.2 DevTools的解决方案
DevTools把类分成两类:
- 不变的类:Spring Framework、第三方库(jar包里的类),用
Base ClassLoader(应用的AppClassLoader)加载,只加载一次 - 会变的类:你自己写的代码(
target/classes或build/classes),用Restart ClassLoader加载,每次重启时丢弃并重建
重启时,只丢弃Restart ClassLoader,重建一个新的,重新加载你的代码。第三方库不受影响,所以比冷启动快很多。
二、源码核心路径解析
2.1 ClassLoader双层结构
2.2 DevTools启动流程
关键类:org.springframework.boot.devtools.restart.Restarter
// Restarter.java(简化)
public class Restarter {
private volatile ClassLoader applicationClassLoader;
private final Set<URL> urls; // 需要热加载的URL(target/classes目录)
private final ClassLoaderFiles classLoaderFiles;
// 初始化:在SpringApplication.run之前触发
public static void initialize(String[] args, boolean forceReferenceCleanup,
RestartInitializer initializer) {
Restarter restarter = new Restarter(Thread.currentThread(), args,
forceReferenceCleanup, initializer);
restarter.initialize(false);
Restarter.instance = restarter;
}
// 执行重启
public void restart() {
restart(FailureHandler.NONE);
}
synchronized void restart(FailureHandler failureHandler) {
if (!this.enabled) return;
// 1. 停止当前ApplicationContext
stop();
// 2. 创建新的RestartClassLoader
// 3. 在新ClassLoader下重新启动Spring
start(failureHandler);
}
private void start(FailureHandler failureHandler) throws Exception {
// 关键:创建新的RestartClassLoader
URL[] urls = this.urls.toArray(new URL[0]);
ClassLoader classLoader = new RestartClassLoader(
Restarter.class.getClassLoader(), // 父ClassLoader = AppClassLoader
urls, // 指向target/classes等目录
this.classLoaderFiles,
this.logger);
// 在新ClassLoader下重新运行main方法
RestartLauncher launcher = new RestartLauncher(classLoader,
this.mainClassName, this.args, this.exceptionHandler);
launcher.start();
launcher.join();
}
}2.3 RestartClassLoader的加载策略
// RestartClassLoader.java(简化)
public class RestartClassLoader extends URLClassLoader {
private final ClassLoaderFiles updatedFiles;
public RestartClassLoader(ClassLoader parent, URL[] urls,
ClassLoaderFiles updatedFiles, Log logger) {
super(urls, parent); // 注意:父ClassLoader是AppClassLoader
this.updatedFiles = updatedFiles;
}
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 打破双亲委派:先在自己的URL(target/classes)查找
// 如果找到了,用自己加载(而不是委托给父ClassLoader)
// 这样才能"覆盖"第三方库中的同名类
String path = name.replace('.', '/').concat(".class");
ClassLoaderFile file = this.updatedFiles.getFile(path);
if (file != null && file.getKind() == ClassLoaderFile.Kind.DELETED) {
throw new ClassNotFoundException(name);
}
// 自己先尝试加载
synchronized (getClassLoadingLock(name)) {
Class<?> loadedClass = findLoadedClass(name);
if (loadedClass != null) return loadedClass;
try {
// 在target/classes等URL下查找
return findClass(name);
} catch (ClassNotFoundException ex) {
// 找不到则委托给父ClassLoader(AppClassLoader)
return super.loadClass(name);
}
}
}
}关键点:RestartClassLoader打破了标准的双亲委派模型,优先在自己的URL下查找,找不到才委托给父ClassLoader。这确保了你修改的类会被RestartClassLoader加载,而不是被父ClassLoader里的旧版本覆盖。
2.4 文件变更监控
FileSystemWatcher每隔一段时间(默认500ms)检查监控目录下的文件变化,发现.class文件改变时触发重启流程。
三、完整代码示例
3.1 验证ClassLoader双层结构
@SpringBootApplication
public class DevToolsDemoApplication {
@Autowired
private UserService userService; // 你自己写的Service
@PostConstruct
public void checkClassLoader() {
// 检查各类的ClassLoader
Class<?> myServiceClass = UserService.class;
Class<?> springClass = ApplicationContext.class;
Class<?> javaClass = String.class;
System.out.println("=== ClassLoader层次 ===");
System.out.println("UserService CL: "
+ myServiceClass.getClassLoader().getClass().getName());
// 使用DevTools时: org.springframework.boot.devtools.restart.RestartClassLoader
System.out.println("ApplicationContext CL: "
+ springClass.getClassLoader().getClass().getName());
// 使用DevTools时: jdk.internal.loader.ClassLoaders$AppClassLoader
System.out.println("String CL: "
+ (javaClass.getClassLoader() == null ? "Bootstrap CL" :
javaClass.getClassLoader().getClass().getName()));
// Bootstrap CL(null表示Bootstrap)
}
}3.2 DevTools配置优化
# application.yml(开发环境配置)
spring:
devtools:
restart:
enabled: true
# 额外监控的目录(除了classpath)
additional-paths: src/main/resources
# 排除不需要触发重启的路径
exclude: "static/**,templates/**,*.xml"
# 轮询间隔(毫秒)
poll-interval: 1000
# 安静期:文件变更后等待多久再触发重启(避免多个文件变更触发多次重启)
quiet-period: 400
livereload:
enabled: true # 启用LiveReload,浏览器自动刷新
port: 357293.3 自定义热加载排除规则(避免ClassCastException)
// 如果你的自定义类需要在Base ClassLoader加载(不被热加载),
// 可以通过配置排除
@Configuration
public class DevToolsConfig {
@Bean
public DevToolsProperties.Restart restartProperties() {
DevToolsProperties.Restart restart = new DevToolsProperties.Restart();
// 这些包下的类不被RestartClassLoader加载
restart.setExclude(new LinkedHashSet<>(Set.of(
"com/example/cache/**", // 缓存相关,重启后需要重新预热
"com/example/security/**" // 安全相关,避免Session失效
)));
return restart;
}
}或者在application.properties:
# 这些路径下的类改变不触发重启
spring.devtools.restart.exclude=static/**,templates/**,com/example/cache/**四、踩坑实录
坑1:ClassCastException: cannot cast Xxx to Xxx
现象:引入DevTools后,某些对象类型转换报ClassCastException,两边的类名完全一样。
根因:如开头所说,两个ClassLoader加载的同名类是不同类型。常见于:
- 通过SPI(ServiceLoader)加载的类
- 序列化/反序列化涉及自定义类
- 某些使用反射的框架组件
解决:找出是哪个类被两个不同ClassLoader加载了,把它移到第三方jar里(不被RestartClassLoader管理),或者配置DevTools排除这个类。
坑2:DevTools在生产环境意外激活
现象:打包后的jar在生产环境也触发了热加载,浪费内存和CPU。
根因:DevTools在打包时默认会被排除(Maven/Gradle的插件会自动处理),但如果你用java -cp xxx.jar方式运行而不是通过Spring Boot Launcher,可能会包含devtools。
最佳实践:
<!-- Maven pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional> <!-- 不传递到依赖这个模块的项目 -->
<scope>runtime</scope>
</dependency>DevTools也会自动检测是否在生产环境(通过spring.devtools.restart.enabled=false或检测jar包启动方式)。
坑3:重启后某些配置没有刷新
现象:修改了application.yml,重启后配置还是旧的。
根因:DevTools的"重启"是在同一个JVM进程里替换ClassLoader,环境中的某些缓存可能没有清除。特别是当你使用了@ConfigurationProperties缓存的配置。
解决:spring.devtools.restart.additional-paths=src/main/resources,确保资源文件变化也触发重启。
坑4:H2控制台在重启后无法访问
现象:使用H2内存数据库 + DevTools,重启后H2控制台报"数据库不存在"。
根因:DevTools重启后,内存数据库的数据丢失了(因为实际上是新建了ApplicationContext,连接到了新的内存数据库实例)。
解决:开发期间使用文件型H2数据库而不是内存数据库,或者用ApplicationRunner在每次启动时重新初始化数据。
五、总结与延伸
DevTools热加载的核心思想:
- 把代码分为稳定层(第三方jar)和变化层(你的代码)
- 用不同ClassLoader加载,重启时只重建变化层的ClassLoader
- 通过文件监控触发重启,比冷启动快2-5倍
理解了双ClassLoader的原理,也就理解了为什么会出现ClassCastException,以及为什么DevTools只适合开发环境。
JVM的类加载机制是很多高级特性的基础——热部署、OSGI、容器隔离都依赖于此。如果你想深入,可以研究Java Agent和Instrumentation API,那是实现真正"不重启热替换"的更底层机制(JRebel就是基于这个原理)。
下一篇我们聊嵌入式Tomcat的启动全流程,看从main()方法到第一个HTTP请求的完整路径。
