手写spring-boot-devtools(一):手把手解析源码(配gif动态图)

文章涉及的源码均已上传到了码云,参考【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}

  1. maven引入
<!-- devtools热部署依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-devtools</artifactId>
    <!-- 防止将依赖传递到其他模块中 -->
    <optional>true</optional>
</dependency>
  1. 配置idea

    2.1 修改后自动编译
    image.png
    image.png

   2.2 修改后手动编译

image.png

二.阅读计划

自下而上开始追踪spring-boot-devtools的源码,观看每次编译后重启的效果可知,是整个应用都重新启动了,而非只是重新加载了仅变化的class文件,所以这里的自然就是main方法了,可以在main方法打上断点,然后首次启动程序

image.png
可以发现,main方法由不同的线程调用了两次,不同的类加载器加载了两次
然后我们再修改某一个文件手动编译,让程序再次重启

image.png
可以发现,重启后的线程id和类加载器的内存地址和首次启动时的都是不同的
所以,通过这两次测试可知,spring-boot-devtools核心原理利用了ClassLoader的隔离性,如下

  1. 启动的时候,有一个切入点,会使用一个新的线程和RestartClassLoader类加载器,再次调用main方法
  2. 文件变更后,重新又使用一个新的线程和RestartClassLoader类加载器,再次调用main方法
    所以,抛开具体实现,大概的阅读计划就是
  3. main方法在哪重新调用
  4. 新的线程在哪初始化
  5. 新的类加载器在哪初始化,其加载路径又是什么
  6. 如何监听文件变更
  7. 文件变更后在哪重新调用main方法
    弄懂了上面这几点,是否就把spring-boot-devtools整个数据流向串起来了?下面开始具体阅读

三.开始阅读

1. main方法在哪重新调用

继续回到第二次断点时左下角的调用链,可以发现其调用了org.springframework.boot.devtools.restart.RestartLauncher#run,我们点进去看看

   调用链A:
image.png
分析调用链A可以发现,是在这个线程中使用java反射回调的main方法
那么接下来就看谁在哪里实例化了当前类RestartLauncher,我们在RestartLauncher的构造函数打上断点,重启应用

   调用链B:
RestartLauncher的构造函数.gif
分析调用链B可以发现,又是另外一个线程LeakSafeThread调用了RestartLauncher,LeakSafeThread的start方法又是在callAndWait或者call方法调用的,所以我们继续在LeakSafeThread的callAndWait和call方法打上断点,重启应用

   调用链C:

devtools-调用链C.gif

分析调用链C可以发现,其实现是基于监听了springboot的启动事件ApplicationStartingEvent,在消费这个事件的时候,重新回调了main方法。
看过ClassLoader分析(二):具体用途文章的就可以知道,采用这种使用新的类加载器来重新调用main方法,以达到所有自己的类都优先使用自定义类加载器的目的,就必须要解决一个问题
如何让main方法第二次运行时,不会继续使用新的类加载器来再次重新调用main方法,而陷入死循环?

   继续看调用链C,调用链C2:

image.png
分析调用链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:
devtools-调用链B2.gif
image.png

2.1 结论:

新的线程在LeakSafeThread.start()时,调用org.springframework.boot.devtools.restart.Restarter#relaunch初始化

3. 新的类加载器在哪初始化,其加载路径又是什么

分析上一步的relaunch方法可知,类加载器是作为形参传过来的,所以可以继续使用调用链B

   继续分析调用链B,调用链B3:

dev-tools调用链B3.gif

image.png
分析调用链B3可以发现,新的类加载器是在org.springframework.boot.devtools.restart.Restarter#doStart处实例化的
然后我们点进去RestartClassLoader,可以发现RestartClassLoader是继承于URLClassLoader的,所以他的加载路径,就是构造函数传过来的URL数组喽,至于这个路径在哪如何得到的,与本次阅读计划无关,不影响整体流程,就先不关心了
分析URL数组的值可知,其并没有包括spring-boot-devtools.jar或其他任何jar包,只有当前工作路径(/target/classes)

加载一个类,必然会调用loadClass方法,所以继续看RestartClassLoader.load方法
image.png
分析上图可以发现,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:

   如何监听文件变更.gif
image.png

分析调用链D可以发现,spring-boot-devtools的文件监听机制是通过后台另起了一个线程org.springframework.boot.devtools.filewatch.FileSystemWatcher.Watcher,不断地循环判断文件是否有变化,如果有变化,就使用springboot发布ClassPathChangedEvent事件

  • 那么监听文件是否有变化的Watcher线程又是在哪里实例化的呢
    我们在线程Watcher的构造函数打上断点,重新编译某个文件

   调用链E:
Watcher又是在哪里实例化的呢.gif

分析调用链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}

image.png

4.1 结论:

spring-boot-devtools的文件监听机制是通过在注册org.springframework.boot.devtools.classpath.ClassPathFileSystemWatcher到IOC时,后台另起了一个线程,不断地循环判断文件是否有变化
如果有变化,就使用springboot发布ClassPathChangedEvent事件

5. 文件变更后在哪重新调用main方法

根据上一步可知,文件发生变更后发布了ClassPathChangedEvent事件,那又是哪里消费的该事件呢
依然是分析调用链D
image.png

image.png
注意这里清理资源的的stop方法,很重要的,注释掉这行,重启将会有各种问题

image.png

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:

   devtools-调用链F.gif
image.png
分析调用链F可知,spring是调用org.springframework.util.ClassUtils#getDefaultClassLoader方法获取加载bean时的类加载器,该方法又优先使用线程的上下文加载器。
而启动main方法的线程org.springframework.boot.devtools.restart.RestartLauncher#RestartLauncher又重置了类加载器为RestartClassLoader
image.png

1.1 结论:

spring加载Bean时,优先使用线程的上下文类加载器

五.总结

1. 核心代码走向

下面这段代码使用@link注释,复制到你自己的项目中去,ctrl+鼠标左键,即可快速跳转到目标方法,十分方便日后万一遗忘了,快速温习一遍

devtools-核心代码走向.gif

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. 核心流程图

抛开细节实现,主要的流程如下图
image.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值