「这是我参与11月更文挑战的第3天,活动详情查看:2021最后一次更文挑战」。
本文不仅是推荐一款好用的Spring Boot开发时热加载工具: spring-boot-devtools。
更是深入源码解读SpringBoot热加载工具DevTools类加载机制和基本流程。
dev-tools使用
当我们在springboot的pom文件中依赖了springboot-devtools这个第三方库的时候,就可以用这个功能了。
在maven项目里引入:
<dependency>
<artifactId>spring-boot-devtools</artifactId>
<groupId>org.springframework.boot</groupId>
<optional>true</optional>
</dependency>
修改代码,执行javac编辑或者点击IDE的[BUILD->BUILD PROJECT]按钮,或者使用快捷键,
这个时候你就能在控制台看到系统已经重启了。
用法很简单,开发很方便,但原理却不是那么简单。
原理剖析
spring-boot-devtools自己定义了类加载的机制,Restart classloader。
如果你对类加载机制还不太了解,这个文章你不知道的虚拟机类加载机制
20+图详解应该能让你完全弄明白,强烈推荐先看懂parent 委派机制等原理,再继续回到这里。
基本概况
devtools的基本流程是:
- 使用一个后台线程fileWatcher,监听配置的监听路径path下的字节码文件有没有变化,
- 如果有变化,则发送
ClassPathChangedEventevent, restartingClassPathChangedEventListener监听,调用dev-tools的restart,- restart逻辑:释放现有的spring Context和内存资源,并且使用自定义的Restart classloader来加载项目主类和有变化的类,生成新的类对象。
- 反射调用项目主类的main来重启程序。
对流程先有个基本印象,下面会详细讲解。
dev-tools只能用于Dev?
- 一般情况下,dev-tools不会被maven repackage的流程打包到jar里,认为不能用于生产环境。如果想要生产环境,需要特殊的maven plugin的设置。
- 在开发环境下,默认依赖了dev-tools就生效。但是可以通过
spring.devtools.restart.enabled这个参数来设置,设置为false,开发环境的dev-tools也可以不生效。
JVM-Spring-Restart流转
我在你不知道的虚拟机类加载机制里论述了JVM启动过程的类加载,这里很有必要重复JVM类加载启动过程,如下图
具体参考你不知道的虚拟机类加载机制。
然后就开始执行Springboot的run和类加载,如下,
Spring-Restart具体流程:
- spring启动run:SpringApplication.run
- spring启动listerners:SpringApplication.runListeners
- spring发布events
- spring invoke listerner
- devtools监听Spring启动的事件:RestartApplicationEvent.onApplicationStartingEvent Restarter对象是一个单例,主要负责Restart类加载器初始化和重启的逻辑。 创建leakSafeThread线程,parent线程调用join等待leakSafeThread的完成,然后自己结束。
这样,Spring成功将系统的控制器转移给Restarter,也就是dev-tools的restarter模块。
如下图所示:
RestartLauncher
leakSafeThread又创建restartMain线程,自己join阻塞。
restartMain线程运行run,使用contextClassLoader加载main Class,反射调用Main.
try {
//使用contextClassLoader加载main Class
Class<?> mainClass = Class.forName(this.mainClassName, false, getContextClassLoader());
Method mainMethod = mainClass.getDeclaredMethod("main", String[].class);
//反射调用Main
mainMethod.invoke(null, new Object[] { this.args });
}
整体的流程如下所示:
从上图可以清晰的从线程的角度看到JVM-Spring-Restart流转具体细节:
- main@1: JVM启动
- main@1: SpringApplication启动
- Thread-1@1768:这个是leakSafeThread线程的名字,它创建Restarter类加载器,并将Restarter类加载器的parent设置为App classloader;创建Launch线程,然后设置Launch线程的contextClassloader 为Restarter Classloader
- restartedMain@1990: 使用contextClassloader ,也就是RestartClassloader加载main所在的类-MyApplication,反射重启main。
整个调用流程的debug如下示意图:
Restart Class Loader
那么Restart ClassLoader是怎么加载类的呢,工作机制是怎样的?
先看RestartClassLoader.java的一段代码:
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
String path = name.replace('.', '/').concat(".class");
ClassLoaderFile file = this.updatedFiles.getFile(path);
if (file != null && file.getKind() == Kind.DELETED) {
throw new ClassNotFoundException(name);
}
synchronized (getClassLoadingLock(name)) {
//Restart ClassLoader是否被JVM标记加载过该类?如果已经登记过了,则可不需要再去findClass
Class<?> loadedClass = findLoadedClass(name);
if (loadedClass == null) {
try {
//能在项目源文件里找到该类(通过super.find),则加载
loadedClass = findClass(name);
}
catch (ClassNotFoundException ex) {
//jar包等都交给其他的base 类加载器
loadedClass = Class.forName(name, false, getParent());
}
}
}
可以看出这个类加载器的基本工作机制是:
- Restart ClassLoader是否是被JVM标记加载过该类,如果加载过,则结束。
- 否则,判断是否能在当前项目类文件里找到该类,如果可以,则加载
- 否则,如jar包等都交给其他的base类加载器去加载
RestartClassLoader的parent被设置为App classloader,如下图:
因为这里不先将类委托给parent去加载,所以是破坏了双亲继承原则的。
从整体JVM-Spring-Restart流转的角度来看类加载,类加载的流程也是发生了从AppClassloader的加载到RestartClassLoader的加载的变化。
- Spring启动的时候使用AppClassloader加载类
- 然后,Restarter显示的使用RestartClassLoader加载Main所在的类MyApplication
- 原来Spring启动的时候,使用APP Classloader加载了很多类到JVM的方法区,包括
- Class对象(Object,Bootstrap classloader)
- Class对象(...,app classloader)
- Class对象(MyApplication,app classloader)
- Spring-Restater转移后,Restart ClassLoader加载了:
- Class对象(MyApplication,Restart classloader)
- Class对象(MyService,Restart classloader)
- Class对象(MyController,Restart classloader)
- ...
也就是说,此时系统里面存在两个由不同的类加载器加载的MyApplication类对象。
重启流程
上面讲了系统初始化的流程,那字节码变化时的重启流程是怎样的呢?
重启的过程,字节码发生了变化,没法debug;可以打开日志debug看看重启的过程具体是怎么流转的。
从日志可以看出热更新的时候:
- File Watcher线程开始Restarting application
- 启动
LeakSafeThread线程,执行释放现有的spring Context和内存资源,并且使用自定义的Restart classloader来加载项目主类和有变化的类,生成新的类对象。 - 反射调用项目主类的main来重启程序。
下图展示了从系统启动到两次热更新重启的过程:
- 文件发生变化event1:LeakSafeThread2启动,清理Spring Context,原来的线程(main、LeakSafeThread1和RestartLauncher线程1)生命周期结束,之后RestartLauncher线程2使用新的Restart classloader加载类,启动系统
- 文件发生变化event2: LeakSafeThread3启动,清理Spring Context,原来的线程(LeakSafeThread1和RestartLauncher线程1)生命周期结束,之后RestartLauncher线程3使用更新的Restart classloader加载类,启动系统
跟踪restart的源码,也可以看到重启的时候会先清理资源:
public void restart(FailureHandler failureHandler) {
//File Watcher线程开始Restarting application
this.logger.debug("Restarting application");
//使用LeakSafeThread
getLeakSafeThread().call(() -> {
//清理资源
Restarter.this.stop();
//启动资源
Restarter.this.start(failureHandler);
return null;
});
}
清理资源
清理资源主要是在stop函数里面完成的:
protected void stop() throws Exception {
// 关闭spring Application
for (ConfigurableApplicationContext context : this.rootContexts) {
context.close();
this.rootContexts.remove(context);
}
//清理spring 缓存的bean
cleanupCaches();
if (this.forceReferenceCleanup) {
//清理soft引用和弱引用,防止oom
forceReferenceCleanup();
}
//触发GC
System.gc();
重启的时候有一些清理的工作:
- 关闭Spring Application,像web资源,数据库连接等资源都会关闭,原来的main和restart Main线程执行结束。
- 清理spring 缓存的bean
- 清理soft引用和弱引用,防止oom
但是注意这里:虽然heap内存有释放和清理,但是方法区不会被清理,也就是每次热加载时候 classloader加载的类都一致存在,如果加载10次,那么某个类在方法区就有10个副本,当方法区溢出的时候要考虑是否是这个因素造成的。
清理之后,创建新的RestartClassLoader和launch线程,替换原来的loader和launch线程。
系统start
Restarter.this.start(failureHandler)这里start还是和初始化的start之前一样的逻辑。
现在关于
dev-tools的原理和流程都讲清楚了~
参考文献
问题解答
发现读者有疑问,我觉得问的很好。认真思考了一下,把我的思考加在这里吧
Q:
请问RestartClassLoader类的 updatedFiles有什么用呢?debug的时候getFile永远返回null 感觉不是只加载了更改的文件,而是重新加载了工作空间下所有文件, 因为检测文件改变的watcher发射的事件携带了被更改的文件,但是监听该事件的类没有从该事件中获取已更改的文件
A :
- 1、上面有写到,想要debug调试devtools的源码,是不会正常工作的。在重新编码,重新reload字节码之后,debug程序就不能正常工作了。因为debug使用的是ASM等字节码操纵技术在启动时的字节码里插入桩代码来运行的。
- 2、updatedFiles是监听的发生变化的字节码文件,比如对于DELETED的文件,在加载的时候则会先抛出异常。不会再走尝试先使用URLClassLoader去加载资源,没找到再判断parent能否加载的流程,还是能节省一些效率吧。
- 3、检测到文件变化后会生成新的classloader实例,确实重新加载了工作空间下需要的所有字节码文件(这里实现上有优化空间..);不在工作空间下的则会交给parent去查找(parent已经加载过)