在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();
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()
由此我们看到是由实现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); //
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、远程热部署重启 等,机制都是相同的,以上。