JavaSpringBoot

前言

Spring Boot 提出了多项开箱即用的功能特性,但归根到底还是围绕简化应用的创建、开发、运行。开发环境下经常对项目代码进行变动,如果每次都重新启动应用会浪费大量时间,为此就产生了多种进行热部署的方案,可以在不重启的情况下使用新的代码。

热部署常用实现方案

然而,在 Java 中实现热部署并不是一件容易的事情。

  1. ClassLoader 重新加载Java 作为一种静态语言,类一经加载到 JVM 中,便无法修改,而且同一个类加载器对于同一个类只能加载一次,因此热部署常用的一种解决方案是创建新的 ClassLoader 加载新的 class 文件,然后替换之前创建的对象。
  2. Java Agent另一种解决方案是使用 Java Agent,Java Agent 可以理解为 JVM 层面的 AOP,可以在类加载时将 class 文件的内容修改为自定义的内容,并且支持修改已加载到 JVM 的 class,不过对于已加载到 JVM 的 class 只能修改方法体,因此具有一定的局限性。

    spring-boot-devtools

    spring-boot-devtools 快速上手

    Spring Boot 通过 Maven 插件 spring-boot-devtools 提供对热部署的支持,只要将这个依赖添加到类路径,当类路径下的 class 发生变化时就会自动重启应用上下文,从而使用新的 class 文件中的代码。这个插件的坐标如下。

    1. <dependency>
    2. <groupId>org.springframework.boot</groupId>
    3. <artifactId>spring-boot-devtools</artifactId>
    4. <optional>true</optional>
    5. </dependency>

    引入依赖时指定 optional 避免依赖传递,同时 spring-boot-maven-plugin 打包时也会忽略 spring-boot-devtools 插件。

    spring-boot-devtools 功能特性

    spring-boot-devtools 作为一个开发环境的插件,不仅支持热部署,具体来说有以下特性。
    将第三方库(如 thymeleaf、freemarker)缓存相关的属性配置到 Environment,以便开发环境禁用缓存。类路径下的 class 文件发生变更时触发 ApplicationContext 重启。内嵌 LiveReload 服务器,资源发生变化时触发浏览器刷新。支持全局配置,所有的 Spring Boot 应用的 spring-boot-devtools 插件使用同一套配置,如指定检查 class 文件变化的轮训时间。支持远程触发热部署(不推荐使用)。

    spring-boot-devtools 实现原理

    虽然 spring-boot-devtools 支持添加配置用来修改自身行为,通常情况下使用默认配置即可,不再赘述配置相关内容。下面把重点放到 spring-boot-devtools 热部署的具体实现上。
    spring-boot-devtools 热部署使用了 ClassLoader 重新加载 的实现方式,具体来说使用两类 ClassLoader,一类是加载第三方库的 CladdLoader,另一类是加载应用类路径下 class 的自定义 RestartClassLoader,应用类路径下 class 变化会触发应用重新启动,由于不需要重新加载第三方库的 class,因此相比重新启动整个应用速度上会快一些。
    那具体到代码层面怎么实现的呢?spring-boot-devtools 利用 Spring Boot 应用自动装配的特性,在 spring.factories 文件中添加了很多配置。
    Spring Boot 开发环境热部署(HotSwap)详解 - 图1

    SpringApplication 启动时触发应用重启

    spring-boot-devtools 通过 RestartApplicationListener 监听 SpringApplication 的启动,监听到启动时关闭当前线程,并重启应用,重启时使用自定义的 RestartClassLoader 加载应用类路径下的 class。监听 Spring Boot 应用启动的核心代码如下。

    1. public class RestartApplicationListener implements ApplicationListener<ApplicationEvent>, Ordered {
    2. @Override
    3. public void onApplicationEvent(ApplicationEvent event) {
    4. if (event instanceof ApplicationStartingEvent) {
    5. onApplicationStartingEvent((ApplicationStartingEvent) event);
    6. }
    7. ... 省略部分代码
    8. }
    9. private void onApplicationStartingEvent(ApplicationStartingEvent event) {
    10. String enabled = System.getProperty(ENABLED_PROPERTY);
    11. if (enabled == null || Boolean.parseBoolean(enabled)) {
    12. String[] args = event.getArgs();
    13. DefaultRestartInitializer initializer = new DefaultRestartInitializer();
    14. boolean restartOnInitialize = !AgentReloader.isActive();
    15. // 初始化 Restarter
    16. Restarter.initialize(args, false, initializer, restartOnInitialize);
    17. } else {
    18. Restarter.disable();
    19. }
    20. }
    21. }

    RestartApplicationListener 监听到 SpringApplication 启动事件后开始对 Restarter 进行初始化,Restarter 是重启应用的核心类,Restarter 初始化过程仅仅实例化自身并调用其初始化方法,初始化的核心代码如下。

    1. public class Restarter {
    2. protected void initialize(boolean restartOnInitialize) {
    3. preInitializeLeakyClasses();
    4. if (this.initialUrls != null) {
    5. this.urls.addAll(Arrays.asList(this.initialUrls));
    6. if (restartOnInitialize) {
    7. this.logger.debug("Immediately restarting application");
    8. immediateRestart();
    9. }
    10. }
    11. }
    12. private void immediateRestart() {
    13. try {
    14. // 等待新线程执行结束
    15. getLeakSafeThread().callAndWait(() -> {
    16. start(FailureHandler.NONE);
    17. cleanupCaches();
    18. return null;
    19. });
    20. } catch (Exception ex) {
    21. this.logger.warn("Unable to initialize restarter", ex);
    22. }
    23. // 再通过抛出异常的方式退出主线程
    24. SilentExitExceptionHandler.exitCurrentThread();
    25. }
    26. }

    Restarter 首先收集类路径的 URL,然后立即调用 #immediateRestart 方法重启应用,待新线程重启应用后再通过抛出异常的方式关闭 main 线程。启动应用的核心代码如下。

    1. public class Restarter {
    2. private Throwable doStart() throws Exception {
    3. Assert.notNull(this.mainClassName, "Unable to find the main class to restart");
    4. URL[] urls = this.urls.toArray(new URL[0]);
    5. ClassLoaderFiles updatedFiles = new ClassLoaderFiles(this.classLoaderFiles);
    6. // 使用新的类加载器加载变化的类
    7. ClassLoader classLoader = new RestartClassLoader(this.applicationClassLoader, urls, updatedFiles, this.logger);
    8. if (this.logger.isDebugEnabled()) {
    9. this.logger.debug("Starting application " + this.mainClassName + " with URLs " + Arrays.asList(urls));
    10. }
    11. return relaunch(classLoader);
    12. }
    13. }

    Restarter 先根据类路径下 URL 收集文件系统中的 class 文件到 ClassLoaderFiles,然后使用新的类加载器 RestartClassLoader 对应用重启,剩下的就很简单了,直接调用 main 方法即可。

    类路径 class 文件变化时触发应用重启

    除了首次应用启动时切换 ClassLoader 重启应用,对开发者而言,最重要的就是 class 文件发生变化时重启应用了。自动配置类位于 LocalDevToolsAutoConfiguration.RestartConfiguration,spring-boot-devtools 提供了一个 ClassPathFileSystemWatcher bean 用于监听 class 文件的变化。

    1. @Configuration(proxyBeanMethods = false)
    2. @ConditionalOnInitializedRestarter
    3. @EnableConfigurationProperties(DevToolsProperties.class)
    4. public class LocalDevToolsAutoConfiguration {
    5. @Lazy(false)
    6. @Configuration(proxyBeanMethods = false)
    7. @ConditionalOnProperty(prefix = "spring.devtools.restart", name = "enabled", matchIfMissing = true)
    8. static class RestartConfiguration {
    9. @Bean
    10. @ConditionalOnMissingBean
    11. ClassPathFileSystemWatcher classPathFileSystemWatcher(FileSystemWatcherFactory fileSystemWatcherFactory,
    12. ClassPathRestartStrategy classPathRestartStrategy) {
    13. URL[] urls = Restarter.getInstance().getInitialUrls();
    14. ClassPathFileSystemWatcher watcher = new ClassPathFileSystemWatcher(fileSystemWatcherFactory,
    15. classPathRestartStrategy, urls);
    16. watcher.setStopWatcherOnRestart(true);
    17. return watcher;
    18. }
    19. }
    20. }

    ClassPathFileSystemWatcher 实现了 InitializingBean 接口,会在初始化时启动一个线程监听 class 文件的变化,然后发送一个 ClassPathChangedEvent 事件,因此 spring-boot-devtools 还提供了一个对应的监听器。

    1. @Configuration(proxyBeanMethods = false)
    2. @ConditionalOnInitializedRestarter
    3. @EnableConfigurationProperties(DevToolsProperties.class)
    4. public class LocalDevToolsAutoConfiguration {
    5. @Lazy(false)
    6. @Configuration(proxyBeanMethods = false)
    7. @ConditionalOnProperty(prefix = "spring.devtools.restart", name = "enabled", matchIfMissing = true)
    8. static class RestartConfiguration {
    9. @Bean
    10. ApplicationListener<ClassPathChangedEvent> restartingClassPathChangedEventListener(
    11. FileSystemWatcherFactory fileSystemWatcherFactory) {
    12. return (event) -> {
    13. if (event.isRestartRequired()) {
    14. // 类路径发生变化时重启应用上下文
    15. Restarter.getInstance().restart(new FileWatchingFailureHandler(fileSystemWatcherFactory));
    16. }
    17. };
    18. }
    19. }
    20. }

    监听器监听到 class 文件变化后通过 Restarter 再次重启了应用,流程与首次重启时类似,不再赘述。

    JRebel

    除了 spring-boot-devtools,Spring 官方推荐的另一个热部署工具是 JRebel。JRebel 的核心是一个普通的 jar 包,内置了对多种框架的支持,通过 java -jar 启动时指定 -javaagent 即可使用 JRebel,而无需修改代码。同时 JRebel 也提供了多种的 IDE 插件,避免了手动启动指定 agent。

    JRebel 在 Idea 中的使用

    由于目前大家多使用 Idea 作为 IDE,因此这里介绍下 JRebel 在 Idea 中的使用。

  3. 下载首先在 Idea 的插件市场搜索 JRebel,并选择 JRebel and XRebel,然后 install,之后重启 IDE 使插件生效。

  4. 激活然后点击 Help->JRebel->Activation 进入激活页面。

选择 Team URL,在 https://jrebel.qekang.com/ 网站可以查找 可用的 Team URL,然后输入任意邮箱即可激活。

  1. 项目支持配置选择 View->Tool Windows->JRebel 对项目进行配置。勾选项目名称右侧的第一个复选框即可快速开启 JRebel 对项目的支持。此时将在 resources 目录下生成一个 rebel.xml 文件,这个文件用于配置 JRebel 监听的类路径。
  2. 自动编译配置

访问 Setting,在 Compiler 页面下勾选 Build project automatically 开启自动构建功能。访问 Setting 页面,在 System Settings 页面下勾选 Save file if the IDE is idle for。

  1. 启动项目然后使用 JRebel 进行 debug 就可以啦,当代码变更触发 IDE 构建后,JRebel 会自动使用新的 class 代码。

    JRebel 实现原理

    虽然 JRebel 在 Idea 中的使用方式比较简单,但当试图探究其实现方式时却发现并没有那么容易。网上的文章前篇一律介绍的是其使用方式,即便其官网也只是简简单单概述为:JRebel 主要在 ClassLoader 级别与 JVM 及应用集成。它不会创建新的 ClassLoader,当监测到 class 文件发生变化时通过扩展类加载器更新应用。
    从官网的描述来看,也并没有深入到具体的实现方式上,真是 听君一席话,如听一席话,由于 JRebel 并未开源,并且其提供的 .jar 文件也进行了代码混淆,因此这里只能对其实现方式进行推测,并逐步验证。这里将推测及分析过程分享给大家。
    推测:JRebel 通过 Java Agent 进行实现JRebel 核心为一个普通的 jar 包,并通过 -javaagent 指定这个 jar 包,因此可以猜测它使用到了 Java Agent 的某些特性。
    Java Agent 的主要作用为替换加载的 class,运行时修改方法体。由于 JRebel 支持在运行时添加、删除方法,因此 JRebel 必然不是通过运行时修改已加载到 JVM 的类路径下 class 方法体的方式来实现热部署的。那么大概率 JRebel 是修改了某些加载到 JVM 的 class。
    推测:JRebel 会在 class 文件发生变化后重新加载 class 文件推测 JRebel 使用了 Java Agent 之后,还是不能了解其主要实现方式,不过当 class 文件发生变动后,JRebel 必然会重新加载变动后的 class 文件,以便执行新的代码,因此可以在 ClassLoader 加载类的某个流程上打上断点,以便查看堆栈信息。
    Spring Boot 项目的示例代码如下。

    1. @SpringBootApplication
    2. @RestController
    3. public class DemoApplication {
    4. public static void main(String[] args) {
    5. SpringApplication.run(DemoApplication.class, args);
    6. }
    7. @GetMapping("/hello")
    8. public String hello() {
    9. return "hallo";
    10. }
    11. }

    不过呢,由于误将 hello 拼成了 hallo,因此需要修改代码,改动后成功进入了断点。JRebel 在 rebel-change-detector-thread 线程监测 class 文件的变动,文件变动后使用 AppClassLoader 加载了 com.zzuhkp.DemoApplication 开头的类,并且类名后还带了 $$M$jr 开头的后缀。可以想到的是同一个 ClassLoader 只能加载一个类,因此 JRebel 对类名进行了修改。这也是官网所描述的,不创建新的 ClassLoader,当 class 发生变化时更新应用。
    问题:JRebel 如何替换新的 class 的?JRebel 加载新的 class 后必然会实例化,然后替换旧的对象,那么它是怎么实例化的呢?有多个构造方法时又该如何选择?
    修改示例代码如下。

    1. @SpringBootApplication
    2. @RestController
    3. public class DemoApplication {
    4. private String str;
    5. public DemoApplication() {
    6. this.str = "你好";
    7. }
    8. public DemoApplication(String str) {
    9. this.str = "你好呀";
    10. }
    11. public static void main(String[] args) {
    12. SpringApplication.run(DemoApplication.class, args);
    13. }
    14. @GetMapping("/hello")
    15. public String hello() {
    16. return str;
    17. }
    18. }

    这里添加了一个成员变量和两个构造方法,访问 /hello 接口,发现返回 你好 二字,可以看出 JRebel 会自动使用无参的构造方法实例化对象。
    那么 JRebel 又是怎么替换旧对象的呢?对于注解驱动的 Spring MVC,Controller 方法会被当做一个 handler 处理请求,如果新添加一个 Controller 方法,那么它必然被注册为 handler 才能处理请求。添加一个 hello2 的方法,并在注册 handler 的流程上打断点。

    1. @GetMapping("/hello2")
    2. public String hello2() {
    3. return str;
    4. }

    当访问 /hello2 果然进入了断点。从堆栈信息来看,多出了 JRebel 的相关类,可以断定,JRebel 对 Spring 的某些 class 做出了修改,当 class 发生变动后,JRebel 自动使用新的 class 实例化的对象注册到 Spring 内部。

    JRebel 小结

    从上述推测和验证的过程来看,JRebel 对热部署的支持利用 Java Agent 修改了 Spring 的某些 class,当应用的 class 发生变化时,JRebel 自动加载新的 class 文件,并利用 Spring 的API 替换 Spring 中的旧对象,从而支持了 Spring 的热部署。

    总结

    由于 spring-boot-devtools 会引入新的依赖,并且 class 文件变更会引起应用重启,而 JRebel 只会加载变动的 class 并利用 Spring 的 API 替换新的对象,因此 JRebel 比 spring-boot-devtools 会快上不少,相对来说比较个人比较支持使用 JRebel。