文章涉及的源码均已上传到了码云,参考【README.md】文件部署运行即可
手写系列码云地址: git@gitee.com:tangjingshan/tjs-study-mini.git
本文代码路径:{@link tjs.study.notes.dotest.jvm.classload.DoTestOfClassLoad#main}
前言
阅读spring-boot-devtools需要以下相关知识,具体可以参考之前的博客
源码的基本调试技巧:https://juejin.cn/post/7008104502121201701
ClassLoader的相关知识:https://juejin.cn/post/7021010821291442190
springboot事件监听机制的相关知识
spring加载Bean时,优先使用线程的上下文类加载器:验证详见本文第四点
一.如何使用
码云项目:tjs-study-notes
码云路径:{@link tjs.study.notes.dotest.jvm.classload.DoTestOfClassLoad#main}
- maven引入
<!-- devtools热部署依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<!-- 防止将依赖传递到其他模块中 -->
<optional>true</optional>
</dependency>
-
配置idea
2.1 修改后自动编译
2.2 修改后手动编译
二.阅读计划
自下而上
开始追踪spring-boot-devtools的源码,观看每次编译后重启的效果可知,是整个应用都重新启动了,而非只是重新加载了仅变化的class文件,所以这里的下
自然就是main方法了,可以在main方法打上断点,然后首次启动程序
可以发现,main方法由不同的线程调用了两次,不同的类加载器加载了两次
然后我们再修改某一个文件手动编译,让程序再次重启
可以发现,重启后的线程id和类加载器的内存地址和首次启动时的都是不同的
所以,通过这两次测试可知,spring-boot-devtools核心原理利用了ClassLoader的隔离性
,如下
- 启动的时候,有一个切入点,会使用一个新的线程和RestartClassLoader类加载器,再次调用main方法
- 文件变更后,重新又使用一个新的线程和RestartClassLoader类加载器,再次调用main方法
所以,抛开具体实现,大概的阅读计划就是 - main方法在哪重新调用
- 新的线程在哪初始化
- 新的类加载器在哪初始化,其加载路径又是什么
- 如何监听文件变更
- 文件变更后在哪重新调用main方法
弄懂了上面这几点,是否就把spring-boot-devtools整个数据流向串起来了?下面开始具体阅读
三.开始阅读
1. main方法在哪重新调用
继续回到第二次断点时左下角的调用链,可以发现其调用了org.springframework.boot.devtools.restart.RestartLauncher#run
,我们点进去看看
调用链A:
分析调用链A可以发现,是在这个线程中使用java反射回调的main方法
那么接下来就看谁在哪里实例化了当前类RestartLauncher,我们在RestartLauncher的构造函数打上断点,重启应用
调用链B:
分析调用链B可以发现,又是另外一个线程LeakSafeThread调用了RestartLauncher,LeakSafeThread的start方法又是在callAndWait或者call方法调用的,所以我们继续在LeakSafeThread的callAndWait和call方法打上断点,重启应用
调用链C:
分析调用链C可以发现,其实现是基于监听了springboot的启动事件ApplicationStartingEvent
,在消费这个事件的时候,重新回调了main方法。
看过ClassLoader分析(二):具体用途文章的就可以知道,采用这种使用新的类加载器来重新调用main方法,以达到所有自己的类都优先使用自定义类加载器的目的,就必须要解决一个问题
如何让main方法第二次运行时,不会继续使用新的类加载器来再次重新调用main方法,而陷入死循环?
继续看调用链C,调用链C2:
分析调用链C2可以发现,只有当静态常量Restarter.instance为空时
才会回调main方法,并给instance赋一个值。
而instance属于类Restarter,类Restarter又是由appClassLoader加载而来的,所以即使第二次调用main方法,第二次运行到Restarter.instance是否为空这个判断时,instance已经不为空了,因为第二次加载Restarter类时从appClassLoader的缓存中取的
至于第二次调用main方法时,为什么不是RestartClassLoader类加载器加载Restarter类,是因为RestartClassLoader加载路径只有当前工作路径(/target/classes),其他的都交由上级类加载器加载,这段代码后面会说。
1.1 结论:
main方法在消费springboot启动事件ApplicationStartingEvent
时,调用org.springframework.boot.devtools.restart.RestartLauncher#run
方法,使用java反射再次回调main方法
2. 新的线程在哪初始化
继续分析在RestartLauncher的构造函数打上断点得到的调用链B,调用链B2:
2.1 结论:
新的线程在LeakSafeThread.start()时,调用org.springframework.boot.devtools.restart.Restarter#relaunch
初始化
3. 新的类加载器在哪初始化,其加载路径又是什么
分析上一步的relaunch方法可知,类加载器是作为形参传过来的,所以可以继续使用调用链B
继续分析调用链B,调用链B3:
分析调用链B3可以发现,新的类加载器是在org.springframework.boot.devtools.restart.Restarter#doStart
处实例化的
然后我们点进去RestartClassLoader,可以发现RestartClassLoader是继承于URLClassLoader的,所以他的加载路径,就是构造函数传过来的URL数组喽,至于这个路径在哪如何得到的,与本次阅读计划无关,不影响整体流程,就先不关心了
分析URL数组的值可知,其并没有包括spring-boot-devtools.jar或其他任何jar包,只有当前工作路径(/target/classes)
加载一个类,必然会调用loadClass方法,所以继续看RestartClassLoader.load方法
分析上图可以发现,RestartClassLoader打破了双亲委派机制,其是优先加载RestartClassLoader自己管控的路径,自己加载失败了才交给父加载器加载
所以除当前工作路径外的类都是由父加载器去加载的,这也是上面【1. main方法在哪重新调用】没有死循环的关键变量Restarter.instance
的值不会被清掉的原因
3.1 结论:
新的类加载器RestartClassLoader
是在org.springframework.boot.devtools.restart.Restarter#doStart
处实例化,其加载路径仅仅为当前工作路径(/target/classes)
,而且RestartClassLoader打破了双亲委派机制,优先自己,自己失败,才委托给父加载器
4. 如何监听文件变更
文件变更必然会重启项目,重启项目必然会再次调用main方法
所以我们首次完全启动好后,在main方法加上断点,然后重新改一个文件再重新编译下,触发重启,程序第三次调用main方法
这里就不贴图了,和上面的【调用链A】【调用链B】是一模一样的
继续在LeakSafeThread的callAndWait和call方法打上断点,重新编译一个文件
调用链D:
分析调用链D可以发现,spring-boot-devtools的文件监听机制是通过后台另起了一个线程org.springframework.boot.devtools.filewatch.FileSystemWatcher.Watcher
,不断地循环判断文件是否有变化,如果有变化,就使用springboot发布ClassPathChangedEvent
事件
- 那么监听文件是否有变化的Watcher线程又是在哪里实例化的呢
我们在线程Watcher的构造函数打上断点,重新编译某个文件
调用链E:
分析调用链E可知,监听文件是否变化的线程是在加载org.springframework.boot.devtools.classpath.ClassPathFileSystemWatcher
到IOC,回调ClassPathFileSystemWatcher的InitializingBean#afterPropertiesSet
方法时,start启动的。
- 文件监听的具体实现
已经有很多第三方框架有实现了,所以暂时不管spring-boot-devtools的具体实现,这里贴一下spring-boot-devtools是如何判断某个文件是否有变化的
所有文件会被封装成FileSnapshot包装类
{@link org.springframework.boot.devtools.filewatch.FileSnapshot#equals}
4.1 结论:
spring-boot-devtools的文件监听机制是通过在注册org.springframework.boot.devtools.classpath.ClassPathFileSystemWatcher
到IOC时,后台另起了一个线程,不断地循环判断文件是否有变化
如果有变化,就使用springboot发布ClassPathChangedEvent
事件
5. 文件变更后在哪重新调用main方法
根据上一步可知,文件发生变更后发布了ClassPathChangedEvent
事件,那又是哪里消费的该事件呢
依然是分析调用链D
注意这里清理资源的的stop方法,很重要的,注释掉这行,重启将会有各种问题
5.1 结论:
ClassPathChangedEvent监听器是在org.springframework.boot.devtools.autoconfigure.LocalDevToolsAutoConfiguration.RestartConfiguration#restartingClassPathChangedEventListener
注册的,其会调用org.springframework.boot.devtools.restart.Restarter#restart(org.springframework.boot.devtools.restart.FailureHandler)
方法去重启main方法
四.其他知识点
1. spring加载Bean时用的哪个类加载器
根据上面的阅读计划可知,由于入口类是由RestartClassLoader类加载器加载的,所以所有的类都是优先交给RestartClassLoader去尝试加载,那我们就在RestartClassLoader.loaderClass方法加上一个断点,不就可以追踪到是谁调用了。断点条件如下name!=null&&name.indexOf("UserTestControllerTools")!=-1
调用链F:
分析调用链F可知,spring是调用org.springframework.util.ClassUtils#getDefaultClassLoader
方法获取加载bean时的类加载器,该方法又优先使用线程的上下文加载器。
而启动main方法的线程org.springframework.boot.devtools.restart.RestartLauncher#RestartLauncher
又重置了类加载器为RestartClassLoader
1.1 结论:
spring加载Bean时,优先使用线程的上下文类加载器
五.总结
1. 核心代码走向
下面这段代码使用@link
注释,复制到你自己的项目中去,ctrl+鼠标左键,即可快速跳转到目标方法,十分方便日后万一遗忘了,快速温习一遍
import org.springframework.beans.factory.support.AbstractBeanFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.devtools.classpath.ClassPathFileSystemWatcher;
import org.springframework.util.ClassUtils;
@SpringBootApplication
public class DoTestOfDevtools {
/**
* spring-boot-devtools启用了一个新的类加载器去加载工作目录(/target/classes),其他的类都由默认的加载器加载
* 然后启用一个线程去监听文件,当文件发生变更,重新构造一个类加载器去重新调用main方法,即可达到重启的目的
* 而此次重启,并不会再次加载第三方jar(第三方jar依然交于默认类加载器加载),所以更快
*
* 1. main方法在哪重新调用
* 监听springboot启动事件
* {@link org.springframework.boot.devtools.restart.RestartApplicationListener#onApplicationStartingEvent(org.springframework.boot.context.event.ApplicationStartingEvent)}
* 新建一个classLoader
* {@link org.springframework.boot.devtools.restart.Restarter#doStart()}
* ( ClassLoader classLoader = new RestartClassLoader fixme 实例化新的类加载器)
* {@link org.springframework.boot.devtools.restart.classloader.RestartClassLoader#RestartClassLoader(ClassLoader, java.net.URL[], org.springframework.boot.devtools.restart.classloader.ClassLoaderFileRepository, org.apache.commons.logging.Log)}
* 新建一个线程回调启动类的main方法
* {@link org.springframework.boot.devtools.restart.Restarter.LeakSafeThread#callAndWait(java.util.concurrent.Callable)}
* (callAndWait,导致首次执行SpringApplication#run方法,执行到listeners.starting后接停止了)
* {@link org.springframework.boot.devtools.restart.RestartLauncher#run()}
* (setContextClassLoader(classLoader);fixme 这里重置当前线程的类加载器)
*
* 4. 如何监听文件变更
* 启动文件监听器线程
* {@link ClassPathFileSystemWatcher#afterPropertiesSet()}
* (this.fileSystemWatcher.start();)
* 文件变更,发布事件
* {@link org.springframework.boot.devtools.classpath.ClassPathFileChangeListener#onChange(java.util.Set)}
* 监听文件变更事件
* {@link org.springframework.boot.devtools.autoconfigure.LocalDevToolsAutoConfiguration.RestartConfiguration#restartingClassPathChangedEventListener(org.springframework.boot.devtools.filewatch.FileSystemWatcherFactory)}
*
* 监听的class文件路径:(仅当前工作目录)
* {@link org.springframework.boot.devtools.restart.ChangeableUrls#ChangeableUrls(java.net.URL...)}
* 判断两个版本文件是否变化
* {@link org.springframework.boot.devtools.filewatch.FileSnapshot#equals(java.lang.Object)}
*
* spring加载Bean时用的哪个类加载器:
* springIOC加载bean的时候优先使用Thread.currentThread().getContextClassLoader()
* {@link AbstractBeanFactory#beanClassLoader}
* {@link ClassUtils#getDefaultClassLoader()}
*
*/
public static void main(String[] args) {
System.out.println("当前线程名:" + Thread.currentThread().getName()+" id:"+Thread.currentThread().getId());
System.out.println("当前类加载器:" + DoTestOfDevtools.class.getClassLoader() + "\n");
SpringApplication.run(DoTestOfDevtools.class, args);
}
}
2. 核心流程图
抛开细节实现,主要的流程如下图