在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); }
那么疑问来了
- 为什么引用依赖的代码的类加载器是
AppClassLoader
而项目代码paramClass
的类加载器是RestartClassLoader
? - 自定义
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,继续反查确认此urls
是Restarter
构造函数从传入参数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(); ListreloadableUrls = 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()
由此我们看到是由实现ApplicationListener
的RestartApplicationListener
完成对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不一致的解决方法
- 最粗暴的方式就是直接去除依赖 Spring-boot-devtools
- 如果确实想要热部署功能,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); // Mapprevious; 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
、远程热部署重启 等,机制都是相同的,以上。