由RestartClassLoader探索Springboot热部署

在SpringBoot项目引入的依赖的代码中发现下面这样问题,最终确定是由RestartClassLoader导致的,由此展开一步一步探索

// 类名一致,但加载出的类却不相等
Class.forName(paramClassNames) == paramClass // false

问题

Debug 发现 paramClass .getClassLoader() 居然是个RestartClassLoader ,然而 Class.forName() 方法则用的是当前调用者的ClassLoader,发现是普通的应用类加载器AppClassLoader

// JDK Class.forName()方法
@CallerSensitive
public static Class forName(String className) throws ClassNotFoundException {
    Class caller = Reflection.getCallerClass();
     return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
}

那么疑问来了

  1. 为什么引用依赖的代码的类加载器是AppClassLoader而项目代码paramClass的类加载器是RestartClassLoader
  2. 自定义RestartClassLoader 的目的、或者说功能是什么?

为什么ClassLoader不一致

全局搜索RestartClassLoader 发现其属于依赖 Spring-boot-devtools 引入的包,继承自URLClassLoader,是Spring热部署功能代码,继续反查源码寻找到此类加载器唯一生效的地方。

// RestartClassLoader 类构造函数
public RestartClassLoader(ClassLoader parent, URL[] urls,
        ClassLoaderFileRepository updatedFiles, Log logger) {
    super(urls, parent); 。// 父类 URLClassLoader 构造函数
}
// Restarter 类中,忽略部分行
private Throwable doStart() throws Exception {
    ClassLoader parent = this.applicationClassLoader;
    URL[] urls = this.urls.toArray(new URL[this.urls.size()]);
    ClassLoaderFiles updatedFiles = new ClassLoaderFiles(this.classLoaderFiles);
    ClassLoader classLoader = new RestartClassLoader(parent, urls, updatedFiles,
            this.logger);
    return relaunch(classLoader);
}

可以看到唯一实例化此类加载器的参数中传入的urls将作为父类URLClassLoader的参数即此类加载器负责加载URL,继续反查确认此urlsRestarter构造函数从传入参数DefaultRestartInitializer.getInitialUrls(Thread)中获取的,获取方式是复制类加载器thread.getContextClassLoader() 中以file:开头以 / 结尾的URLs并通过DevToolsSettings读取META-INF/spring-devtools.properties配置文件进行纳入、排查

// Restarter 类构造函数
protected Restarter(Thread thread, String[] args, boolean forceReferenceCleanup,
        RestartInitializer initializer) {
    this.initialUrls = initializer.getInitialUrls(thread); // 
    this.mainClassName = getMainClassName(thread);
    this.applicationClassLoader = thread.getContextClassLoader();
}
// DefaultRestartInitializer.getInitialUrls() -> ChangeableUrls()
private ChangeableUrls(URL... urls) {
    DevToolsSettings settings = DevToolsSettings.get();
    List reloadableUrls = new ArrayList(urls.length);
    for (URL url : urls) { // 过滤URL
        if ((settings.isRestartInclude(url) || isFolderUrl(url.toString()))
                && !settings.isRestartExclude(url)) {
            reloadableUrls.add(url);
        }
    }

    this.urls = Collections.unmodifiableList(reloadableUrls);
}

既然问题请求清晰了,那么我们在Restarter构造函数打个断点看看传入的Thread是哪个线程,又是怎么调用的,过滤后还有哪些URL.

main() -> SpringApplication.run() -> SpringApplicationRunListeners.starting() ->
SimpleApplicationEventMulticaster.multicastEvent() RestartApplicationListener.onApplicationEvent() ->
RestartApplicationListener.onApplicationStartingEvent() -> Restarter.initialize()

由此我们看到是由实现ApplicationListenerRestartApplicationListener完成对Restarter的初始化的,而此是传入的Thread正是main线程,此线程的ContextClassLoader便是AppClassLoader,所以经过过滤后就将只有类似于 file:/projectPath/projectName/module/target/classes这样的属于此项目代码的URL,所以此类加载器不负责加载第三方jar包的类文件。

那这个类加载器在哪开始加载类的呢?

在上文的Restarter.initialize()中初始完Restarter后,将调用此Restarter实例的initialize() 方法,如果是刚实例化的继续调用immediateRestart()并启动另一LeakSafeThread线程执行doStart() 并join()等待,此线程将执行doStart()中RestartClassLoader的实例化,并再启动一RestartLauncher线程,并将实例化的RestartClassLoader设为此线程的ContextClassLoader,在此线程中反射调用Main 方法,从而实现热部署重启,并将应用环境的所有项目中的类交给此自定义的类加载器进行加载。

RestartLauncher(ClassLoader classLoader, String mainClassName, String[] args,
        UncaughtExceptionHandler exceptionHandler) {
    this.mainClassName = mainClassName;
    //省略
    setContextClassLoader(classLoader); // 设置此线程上下文类加载器
}
@Override
public void run() {
    try {// 此线程反射调用main方法
        Class mainClass = getContextClassLoader().loadClass(this.mainClassName);
        Method mainMethod = mainClass.getDeclaredMethod("main", String[].class);
        mainMethod.invoke(null, new Object[] { this.args });
    }
    catch (Throwable ex) {
        //省略
    }
}

ClassLoader不一致的解决方法

  1. 最粗暴的方式就是直接去除依赖 Spring-boot-devtools
  2. 如果确实想要热部署功能,Springboot也提供了配置,没错就是上文提到的过滤URLs时使用DevToolsSettings读取META-INF/spring-devtools.properties配置文件进行纳入、排查,你可以自己新建 spring-devtools.properties 文件,配置上需要此自定义类加载器负责来加载的正则表达式,形式如(exclude表示排查、include表示纳入):
    restart.exclude.spring-boot=/spring-boot/target/classes/
    restart.exclude.spring-boot-devtools=/spring-boot-devtools/target/classes/
    restart.exclude.spring-boot-starters=/spring-boot-starter-[\\w-]+/
    
    

restart.include.commons-pool2=/org/apache/commons/commons-pool2/2.4.3/commons-pool2-2.4.3.jar

Spring热部署过程触发机制

既然我们已经探寻了RestartClassLoader ,那么肯定也会好奇,这Spring是怎么实现类文件变更后自动触发重启的呢?
细看其实分成两部分,一部分是对文件变更的监视,一部分是将变更通知到容器实现应用重启。关键配置类为LocalDevToolsAutoConfiguration(实现本地热部署)和RemoteClientConfiguration(通过HTTP发送变更文件信息通知远程热部署),我们就以LocalDevToolsAutoConfiguration为例。

// 本地热部署配置类
public class LocalDevToolsAutoConfiguration {
    // 实现静态资源文件变更自动刷新页面而非重启应用
    static class LiveReloadConfiguration {
        // 与WS或JS脚本实现页面刷新
        public LiveReloadServer liveReloadServer() {
            return new LiveReloadServer(this.properties.getLivereload().getPort(),
                    Restarter.getInstance().getThreadFactory());
        }
        // 省略配置
    }
    // 实现文件变更自动重启应用
    static class RestartConfiguration {
        // 监听文件变更事件
        @EventListener
        public void onClassPathChanged(ClassPathChangedEvent event) {
            if (event.isRestartRequired()) {
                Restarter.getInstance().restart(
                        new FileWatchingFailureHandler(fileSystemWatcherFactory()));
            }
        }
        // 文件变更监视
        @Bean
        @ConditionalOnMissingBean
        public ClassPathFileSystemWatcher classPathFileSystemWatcher() {
            URL[] urls = Restarter.getInstance().getInitialUrls();
            ClassPathFileSystemWatcher watcher = new ClassPathFileSystemWatcher(
                    fileSystemWatcherFactory(), classPathRestartStrategy(), urls);
            watcher.setStopWatcherOnRestart(true);
            return watcher;
        }
        // 省略部分配置
    }
}

可见,是由ClassPathFileSystemWatcher实现对文件变更的监视并通过ClassPathChangedEvent事件通知来触发 Restarter.restart()也就是上文描述的步骤 实例化RestarterClassLoader –> 将其设为线程上下文类加载器 –> 再反射调用Main方法

细看ClassPathFileSystemWatcher传入的参数urls会发现正是等于上文提到的RestarterClassLoader的传入参数。再深入此类可知其是通过DevToolsProperties来配置诸如,等

  • restart.exclude 实现指定文件变更不重启,如静态资源文件
  • restart.pollInterval 控制监视轮询间隔
  • restart.additionalExclude 指定额外的监视路径

而真正实现文件变更监视的FileSystemWatcher,其实现只是扫描目录下所有文件,与初始的文件信息快照做对比。不过有一点倒挺细节的,就是不仅控制监视轮询间隔,还能通过restart.pollInterval 设置文件变更后多长时间不再继续变更,才触发重启,以防止项目批量编译类文件。

private void scan() throws InterruptedException {
    Thread.sleep(this.pollInterval - this.quietPeriod); //
    Map previous;
    Map current = this.folders;
    do {
        previous = current;
        current = getCurrentSnapshots();
        Thread.sleep(this.quietPeriod);  // 等待
    }
    while (isDifferent(previous, current)); //有变更就循环
    if (isDifferent(this.folders, current)) {
        updateSnapshots(current.values());
    }
}

本地热部署重启就到这了,有兴趣的童鞋也可以看看热部署页面自动刷新LiveReload、远程热部署重启 等,机制都是相同的,以上。

觉得不错不妨打赏一笔