Springboot-devtools 原理分析
Springboot-devtools 原理分析
springdev-tools 实现开发过程中,自动重启应用程序,提供了一定的方便。
使用的话,需要引入 starter 依赖,然后设置 IDEA 文件更新策略,我一般设置为切出 IDEA 时更新类和文件。devtools 检测类路径下文件夹变化,然后通过反射调用主类的 Main 方法重启应用程序,实现热部署。
为什么写这篇文章
最近在学习虚拟机类加载器相关的知识,顺受拿着 Springboot 项目测了几行代码,然后就发现了令自己困惑的事情。
首先热部署是基于 Java 的类加载机制的,然后 devtools 的原理大概就是监控类路径下 class 文件的变化,然后重新加载类,通过反射调用 Main 方法,重新启动程序。
这篇文章已经讲得比较清楚了 devtools 基本原理
public static void main(String[] args) {
logger.debug(Connection.class.getSimpleName() + " " + Connection.class.getClassLoader());
logger.debug(Main.class.getSimpleName() + " " + Main.class.getClassLoader());
SpringApplication application = new SpringApplication(Main.class);
application.setBannerMode(Banner.Mode.OFF);
application.addListeners(new StartListener());
application.run(args);
logger.info(Configuration.class.getSimpleName() + " " + Configuration.class.getClassLoader());
logger.info(Connection.class.getSimpleName() + " " + Connection.class.getClassLoader());
logger.info(ApplicationContext.class.getSimpleName() + " " + ApplicationContext.class.getClassLoader());
logger.info(Main.class.getSimpleName() + " " + Main.class.getClassLoader());
}
程序运行后,控制台信息
10:31:57.317 [main] DEBUG com.rufeng.boot.Main - Connection null
10:31:57.322 [main] DEBUG com.rufeng.boot.Main - Main sun.misc.Launcher$AppClassLoader@18b4aac2
10:31:57.578 [Thread-1] DEBUG org.springframework.boot.devtools.restart.classloader.RestartClassLoader - Created RestartClassLoader org.springframework.boot.devtools.restart.classloader.RestartClassLoader@36c60b36
10:31:57.581 [restartedMain] DEBUG com.rufeng.boot.Main - Connection null
10:31:57.581 [restartedMain] DEBUG com.rufeng.boot.Main - Main org.springframework.boot.devtools.restart.classloader.RestartClassLoader@36c60b36
...
2022-01-05 11:02:09.021 INFO 11848 --- [ restartedMain] com.rufeng.boot.Main : Configuration sun.misc.Launcher$AppClassLoader@18b4aac2
2022-01-05 11:02:09.021 INFO 11848 --- [ restartedMain] com.rufeng.boot.Main : Connection null
2022-01-05 11:02:09.021 INFO 11848 --- [ restartedMain] com.rufeng.boot.Main : ApplicationContext sun.misc.Launcher$AppClassLoader@18b4aac2
2022-01-05 11:02:09.021 INFO 11848 --- [ restartedMain] com.rufeng.boot.Main : Main org.springframework.boot.devtools.restart.classloader.RestartClassLoader@6f61b662
注意到 3 个问题
- 程序刚启动,三个线程的 debug 信息,二次执行
- 在 SpringApplication run 方法后的代码只执行了一次
- 第二次启动后,Main 类的类加载器变为 spring 的 RestartClassLoader
有以下几点想法:
- main 线程执行到 run 方法里面,没有出来了,所以后面的代码没有执行,有两种可能
- 被强行终止了
- 一直被 join
- jar 包、Java 自带的类被 AppClassLoader 加载,工作目录下的类被 RestartClassLoader 加载,当然,这个也不是绝对的
- 类字节码变化,重启应用程序,需要做哪些事情,直接反射调用 Main 方法?缓存?
流程分析
springboot 启动后,在 run 方法中的 listeners.starting 方法中,发布了 ApplicationStartingEvent,然后 RestartApplicationListener 开始运行,整个重启过程从这里开始。
监听器哪来的
此处有一个疑问,这个监听器只有引了 devtools 包后才会有,并且到 starting 时,容器还没有刷新,所有的 Bean 还未被解析,那么,这个监听器哪来的?
了解 springboot 自动配置的朋友们应该会想到,来自 META/INF 下的 spring.factories 文件,如下所示:
# Application Listeners
org.springframework.context.ApplicationListener=\
org.springframework.boot.devtools.restart.RestartApplicationListener,\
org.springframework.boot.devtools.logger.DevToolsLogFactory.Listener
在该监听器的方法中,我们关注的是 onApplicationStartingEvent 方法,在该方法中判断了 devtools 是否 enabled,然后开始进入 Restarter,这个类是重启的关键类
if (restartInitializer != null) {
String[] args = event.getArgs();
boolean restartOnInitialize = !AgentReloader.isActive();
if (!restartOnInitialize) {
logger.info("Restart disabled due to an agent-based reloader being active");
}
Restarter.initialize(args, false, restartInitializer, restartOnInitialize);
}
重启的逻辑在 initialize 方法中,我们需要讨论的也多在这个类中
private static final Object INSTANCE_MONITOR = new Object();
private static final String[] NO_ARGS = {};
private static Restarter instance;
...
public static void initialize(String[] args, boolean forceReferenceCleanup, RestartInitializer initializer,
boolean restartOnInitialize) {
Restarter localInstance = null;
synchronized (INSTANCE_MONITOR) {
if (instance == null) {
localInstance = new Restarter(Thread.currentThread(), args, forceReferenceCleanup, initializer);
instance = localInstance;
}
}
if (localInstance != null) {
localInstance.initialize(restartOnInitialize);
}
}
单例的写法
可以看到,Restarter 是一个单例,起初看到 spring 这种单例的写法时,下意识地想到一个问题,没写 volatile,类还没构造完毕就被拿去用了?
后来发现自己还是不够仔细,对单例模式的几种写法还没完全理解,这里我们只讨论 spring-devtools 的这种写法和双重校验锁。
懒汉式,线程安全,同步整个方法
这是 spring-devtools 的写法,这种写法是不会出问题了,任何个线程进入到该方法必须获取到锁,一旦有线程释放锁,同步代码块必定被执行过,那么单例一定初始化完成,后续的线程获取到锁之后也不会再去初始化单例对象。
实际上,再去获取单例的时候,99% 以上的情况都是初始化好的,不需要进入同步块,但是这样写差不多锁住了整个方法,性能上存在缺陷。
双重校验锁
public class Singleton {
private static final Object INSTANCE_MONITOR = new Object();
private static volatile Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (INSTANCE_MONITOR) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
与上面不同的是,这里首先执行第一个 if,不需要获取锁,那么任何一个线程进入都可以执行,如果已被初始化(绝大多数情况下),直接返回单例对象,不需要阻塞等待,性能上得到优化。
但是,注意到一个问题,instance = new Singleton6 () 这句代码不是原子性的,从 Java 字节码的角度来说(Java 字节码对 CPU 来说也不一定是原子的)
0 getstatic #2 <com/rufeng/Singleton.instance : Lcom/rufeng/Singleton;>
3 ifnonnull 38 (+35)
6 getstatic #3 <com/rufeng/Singleton.INSTANCE_MONITOR : Ljava/lang/Object;>
9 dup
10 astore_0 # 将栈顶引用型数值存入第一个本地变量
11 monitorenter # 进入同步代码块
12 getstatic #2 <com/rufeng/Singleton.instance : Lcom/rufeng/Singleton;> # 获取指定类的静态域,并将其值压入栈顶
15 ifnonnull 28 (+13) # 不为null
18 new #4 <com/rufeng/Singleton> # 创建一个对象,并将其引用值压入栈顶
21 dup # 复制栈顶数值并将复制值压入栈顶
22 invokespecial #5 <com/rufeng/Singleton.<init> : ()V> # 执行构造方法
25 putstatic #2 <com/rufeng/Singleton.instance : Lcom/rufeng/Singleton;> 为指定类的静态域赋值
28 aload_0 # 将第一个本地引用型变量推至栈顶
29 monitorexit # 退出同步代码块
30 goto 38 (+8)
33 astore_1
34 aload_0
35 monitorexit
36 aload_1
37 athrow
38 getstatic #2 <com/rufeng/Singleton.instance : Lcom/rufeng/Singleton;>
41 areturn # 从当前方法返回对象引用
按照 Java 字节码的流程来说,instance = new Singleton6 () 这条指令有三个步骤:
- new 创建一个对象,分配内存空间
- 执行构造方法
- 将 instance 指向该对象
顺序执行的情况下,返回的单例对象一定是初始化完成了的,但是,指令可能会存在重排序的情况。
倘若 3 在 2 之前执行,对于单线程来说,不影响结果,在并发的情况下,可能会出现返回还没完全初始化的对象。考虑下面的情况:
- 线程 1 进入方法,instance 为 null,拿到锁,instance 为 null,开始 new 对象,执行指令 1,指令 3。
- 线程 2 进入方法,instance 不为 null,不需要进入同步代码块,直接返回 instance,此时的 instacne 对象还未被初始化。
- 线程 1 执行指令 3,返回 instance。
这样的话,线程 2 拿到的 instance 是有问题的。
如果同步整个方法,就算指令被重排序了,也是不会出现这种问题的。
反射重启程序
private void immediateRestart() {
try {
getLeakSafeThread().callAndWait(() -> {
start(FailureHandler.NONE);
cleanupCaches();
return null;
});
}
catch (Exception ex) {
this.logger.warn("Unable to initialize restarter", ex);
}
SilentExitExceptionHandler.exitCurrentThread();
}
当单例初始化完成之后,马上会执行 immediateRestart 方法,LeaksafeThread 继承自 Thread,其主要方法如下:
@SuppressWarnings("unchecked")
<V> V callAndWait(Callable<V> callable) {
this.callable = callable;
start();
try {
join();
return (V) this.result;
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException(ex);
}
}
@Override
public void run() {
// We are safe to refresh the ActionThread (and indirectly call
// AccessController.getContext()) since our stack doesn't include the
// RestartClassLoader
try {
Restarter.this.leakSafeThreads.put(new LeakSafeThread());
this.result = this.callable.call();
}
我们来捋一下流程,此时仍然在 main 线程中,在 main 线程中,Restarter 的 initialize 方法中,初始化 Restarter 单例,首次初始化完毕后,进入 immediateRestart 方法,获取 LeakSafeThread 线程对象,传入 Callable 对象,调用其 callAndWait 方法,该方法启动新线程,此时,两个线程开始了,也就是上图控制台第二个线程 Thread-1。
启动后,调用 LeakSafeThread 的 join 方法,注意,仍然是在 main 线程中,此时重启线程正在运行,而 main 线程等待重启线程执行完,被 join 阻塞。
调用堆栈如下:
接下来,我们来看看 LeakSafeThread 的 run 方法,每次运行新的线程,就会往队列中 put 一个新的线程,然后真正执行 Callable 对象的方法。
即 lambda Callable 中的三行代码。然后继续到 doStart 方法,relaunch 方法
protected Throwable relaunch(ClassLoader classLoader) throws Exception {
RestartLauncher launcher = new RestartLauncher(classLoader, this.mainClassName, this.args,
this.exceptionHandler);
launcher.start();
launcher.join();
return launcher.getError();
}
RestartLauncher 又是一个新的线程,注意,此时在 LeakSafeThread 线程中,线程名为 Thread-1,又生出一个子线程,子线程运行后,join,等待子线程执行完成。
此时,main 线程被 join 阻塞,等待 Thread-1 结束,Thread-1 被 join 阻塞,等到 RestarterLauncher 结束,那么 RestarterLauncher 什么时候结束?下面是他的 run 方法:
@Override
public void run() {
try {
Class<?> mainClass = Class.forName(this.mainClassName, false, getContextClassLoader());
Method mainMethod = mainClass.getDeclaredMethod("main", String[].class);
mainMethod.invoke(null, new Object[] { this.args });
}
catch (Throwable ex) {
this.error = ex;
getUncaughtExceptionHandler().uncaughtException(this, ex);
}
}
在其初始化方法中,setName (“restartedMain”),及对应上文控制台 debug 的线程名,到这里,又回到了 main 方法。
此时 main 线程中的 application 还阻塞在 run 方法没有返回,重启的线程就会正常执行 run 方法启动应用程序,然后正常返回执行完 main 方法。该线程 run 方法结束。
该线程结束后,Thread-1 不再阻塞,start 方法结束,之后 cleanupCaches () 执行完,退出。
main 线程如何静默退出
Thread-1 退出,main 线程不再阻塞,注意,main 线程是在 immediateRetart 的 satrt 方法中阻塞,现在不再阻塞,继续 immediateRestart 方法,有一句比较关键的代码,而 exitCurrentThread 方法也只是简单地抛出了异常
SilentExitExceptionHandler.exitCurrentThread();
static void exitCurrentThread() {
throw new SilentExitException();
}
等等,抛了异常,怎么没报错呢?
结合控制台的输出,我们可以猜想,这句代码,把 main 线程终止了,而且是悄无声息的终止。
考虑以下代码
public static void main(String[] args) {
try {
new Thread(() -> {
int x = 1 / 0;
}).start();
} catch (Exception e) {
e.printStackTrace();
}
}
这样是没法捕获线程中抛出的异常的,除非在线程的 run 方法中捕获,无法在其他线程中处理线程发生的异常?答案是有的。
JDK 提供了 UncaughtExceptionHandler 接口,用于处理多线程中发生的异常
public static void main(String[] args) {
try {
Thread thread = new Thread(() -> {
int x = 1 / 0;
});
thread.setUncaughtExceptionHandler((t, e) -> {
System.out.println(t.getName());
System.out.println(e.getMessage());
});
thread.start();
} catch (Exception e) {
e.printStackTrace();
}
}
这样异常就会被捕获了,也就是控制台不会报错,并且 main 线程退出的原理了。
SilentExitExceptionHandler 的处理异常的方法:
@Override
public void uncaughtException(Thread thread, Throwable exception) {
if (exception instanceof SilentExitException || (exception instanceof InvocationTargetException
&& ((InvocationTargetException) exception).getTargetException() instanceof SilentExitException)) {
if (isJvmExiting(thread)) {
preventNonZeroExitCode();
}
return;
}
if (this.delegate != null) {
this.delegate.uncaughtException(thread, exception);
}
}
上面分析的是程序启动后,马上又重启的过程,事实上这与检测到文件变化后再重启的流程有略微差别,立即重启多了退出 main 线程的部分,而检测文件变化重启多了事件监听、停止程序等工作。
文件变化监听
autoconfigure 中,注入了 FileSystemWatcher、ClassPathFileSystemWatcher、ApplicationListener 三个关键的 Bean
@Bean
ApplicationListener<ClassPathChangedEvent> restartingClassPathChangedEventListener(
FileSystemWatcherFactory fileSystemWatcherFactory) {
return (event) -> {
if (event.isRestartRequired()) {
Restarter.getInstance().restart(new FileWatchingFailureHandler(fileSystemWatcherFactory));
}
};
}
/* 监控文件变化的线程 */
/*org.springframework.boot.devtools.filewatch.FileSystemWatcher.Watcher*/
public void run() {
int remainingScans = this.remainingScans.get();
while (remainingScans > 0 || remainingScans == -1) {
try {
if (remainingScans > 0) {
this.remainingScans.decrementAndGet();
}
scan();
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
remainingScans = this.remainingScans.get();
}
}
线程不断扫描路径下的文件,一旦发生变化,即发布 ClassPathChangedEvent,然后监听器调用 Restater 单例的 restart 方法完成重启。
类文件变化重启
restart 方法,和 immediateRestart 很像,不同的是后者没有 stop 方法、同时线程没有退出
public void restart(FailureHandler failureHandler) {
if (!this.enabled) {
this.logger.debug("Application restart is disabled");
return;
}
this.logger.debug("Restarting application");
getLeakSafeThread().call(() -> {
Restarter.this.stop();
Restarter.this.start(failureHandler);
return null;
});
}
那么这里的流程也很清晰了,进入该方法时,所在的线程为 Watcher 线程,该线程生出子线程完成重启后,继续扫描工作,不需要退出。
stop 清理工作
stop 方法做了很多清理工作
- 关闭当前应用程序上下文,因为需要重新初始化
- 清除 Class 对象的有关缓存,比如 ConversionService、RelectionUtils、AnnotationUtils 等的缓存,因为类的字节码可能已被修改
- 如果需要的话,清除软引用和弱引用,方法是强制 OOM
- 执行一次 GC
- 执行 System.runFinalization ()
关于 System.runFinalization 和 System.gc
清除软引用和弱引用的方法
/**
* Cleanup any soft/weak references by forcing an {@link OutOfMemoryError} error.
*/
private void forceReferenceCleanup() {
try {
final List<long[]> memory = new LinkedList<>();
while (true) {
memory.add(new long[102400]);
}
}
catch (OutOfMemoryError ex) {
// Expected
}
}
重新加载类文件
从上面反射调用 main 方法看到,使用的类加载器为 RestartClassLoader,该类继承自 URLClassLoader,表示支持从 URL 路径加载类字节码,如果所有的 URL 路径都找不到目标类的字节码文件,抛出 ClassNotFoundException,其 loadClass 方法如下
@Override
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)) {
Class<?> loadedClass = findLoadedClass(name);
if (loadedClass == null) {
try {
loadedClass = findClass(name);
}
catch (ClassNotFoundException ex) {
loadedClass = Class.forName(name, false, getParent());
}
}
if (resolve) {
resolveClass(loadedClass);
}
return loadedClass;
}
}
- 如果删除了某个类文件,那么直接抛出异常,不支持该种情况重启
- 检查当前类加载器是否已经加载过目标类,事实上这个一直返回 false,因为每次重启都会 new 一个 RestartClassLoader,尽管扫描出了被修改的文件,但是还是会去重新加载类路径下的所有文件,因为缓存之类的需要重建
- 调用自身的 findClass 方法,实际上和 URLClassLoader 的 findClass 差不多,这就先去指定路径下找
- 如果 URL 路径下没有找到,尝试 Class.forName 加载该类,此时使用的就是当前类的类加载器,即 RestartClassLoader 的类加载器 AppClassLoader,引入的 jar 包就会在这里被加载。
本文不仅是推荐一款好用的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 下的字节码文件有没有变化,
- 如果有变化,则发送
ClassPathChangedEvent
event, 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 已经加载过)
链接:https://juejin.cn/post/7027647026510692360