深入源码解读SpringBoot热加载工具DevTools-类加载机制和基本流程

2,179 阅读8分钟

「这是我参与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下的字节码文件有没有变化,
  • 如果有变化,则发送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类加载启动过程,如下图

launcher.png

具体参考你不知道的虚拟机类加载机制

然后就开始执行Springboot的run和类加载,如下,

devt4.png

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模块。

如下图所示:

image.png

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 });
}

整体的流程如下所示:

devt5.png

从上图可以清晰的从线程的角度看到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如下示意图:

image.png

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,如下图:

devt6.png

因为这里不先将类委托给parent去加载,所以是破坏了双亲继承原则的。

从整体JVM-Spring-Restart流转的角度来看类加载,类加载的流程也是发生了从AppClassloader的加载到RestartClassLoader的加载的变化。

  • Spring启动的时候使用AppClassloader加载类
  • 然后,Restarter显示的使用RestartClassLoader加载Main所在的类MyApplication

devt7.png

  • 原来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看看重启的过程具体是怎么流转的。

image.png 从日志可以看出热更新的时候:

  • File Watcher线程开始Restarting application
  • 启动LeakSafeThread线程,执行释放现有的spring Context和内存资源,并且使用自定义的Restart classloader来加载项目主类和有变化的类,生成新的类对象。
  • 反射调用项目主类的main来重启程序。

下图展示了从系统启动到两次热更新重启的过程:

devt8.png

  • 文件发生变化event1:LeakSafeThread2启动,清理Spring Context,原来的线程(main、LeakSafeThread1和RestartLauncher线程1)生命周期结束,之后RestartLauncher线程2使用新的Restart classloader加载类,启动系统
  • 文件发生变化event2: LeakSafeThread3启动,清理Spring Context,原来的线程(LeakSafeThread1RestartLauncher线程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已经加载过)