Spring over Graal

前面几部分,我们以定性的角度分析了 Graal VM 诞生的背景与它的价值,在最后这部分,我们尝试进行一些实践和定量的讨论,介绍具体如何使用 Graal VM 之余,也希望能以更加量化的角度去理解程序运行在 Graal VM 之上,会有哪些具体的收益和代价。

尽管需要到 2020 年 10 月正式发布之后,Spring 对 Graal VM 的支持才会正式提供,但现在的我们其实已经可以使用 Graal VM 来(实验性地)运行 Spring、Spring Boot、Spring Data、Netty、JPA 等等的一系列组件(不过 SpringCloud 中的组件暂时还不行)。接下来,我们将尝试使用 Graal VM 来编译一个标准的 Spring Boot 应用:

  • 环境准备 :
    • 安装 Graal VM,你可以选择直接下载安装(版本选择 Graal VM CE 20.0.0),然后配置好 PATH 和 JAVA_HOME 环境变量即可;也可以选择使用SDKMAN来快速切换环境。个人推荐后者,毕竟目前还不适合长期基于 Graal VM 环境下工作,经常手工切换会很麻烦。
      # 安装SDKMAN
      $ curl -s "https://get.sdkman.io" | bash
      # 安装Graal VM
      $ sdk install java 20.0.0.r8-grl
      
    • 安装本地镜像编译依赖的 LLVM 工具链。
      # gu命令来源于Graal VM的bin目录
      $ gu install native-image
      
      请注意,这里已经假设你机器上已有基础的 GCC 编译环境,即已安装过 build-essential、libz-dev 等套件。没有的话请先行安装。对于 Windows 环境来说,这步是需要 Windows SDK 7.1 中的 C++编译环境来支持。我个人并不建议在 Windows 上进行 Java 应用的本地化操作,如果说在 Linux 中编译一个本地镜像,通常是为了打包到 Docker,然后发布到服务器中使用。那在 Windows 上编译一个本地镜像,你打算用它来干什么呢?
  • 编译准备 :
    • 首先,我们先假设你准备编译的代码是“符合要求”的,即没有使用到 Graal VM 不支持的特性,譬如前面提到的 Finalizer、CGLIB、InvokeDynamic 这类功能。然后,由于我们用的是 Graal VM 的 Java 8 版本,也必须假设你编译使用 Java 语言级别在 Java 8 以内。
    • 然后,我们需要用到尚未正式对外发布的 Spring Boot 2.3,目前最新的版本是 Spring Boot 2.3.0.M4。请将你的 pom.xml 中的 Spring Boot 版本修改如下(假设你编译用的是 Maven,用 Gradle 的请自行调整):
      <parent>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-parent</artifactId>
          <version>2.3.0.M4</version>
          <relativePath/>
      </parent>
      
      由于是未发布的 Spring Boot 版本,所以它在 Maven 的中央仓库中是找不到的,需要手动加入 Spring 的私有仓库,如下所示:
      <repositories>
          <repository>
              <id>spring-milestone</id>
              <name>Spring milestone</name>
              <url>https://repo.spring.io/milestone</url>
          </repository>
      </repositories>
      
    • 最后,尽管我们可以通过命令行(使用 native-image 命令)来直接进行编译,这对于没有什么依赖的普通 Jar 包、写一个 Helloworld 来说都是可行的,但对于 Spring Boot,光是在命令行中写 Classpath 上都忙活一阵的,建议还是使用Maven 插件来驱动 Graal VM 编译,这个插件能够根据 Maven 的依赖信息自动组织好 Classpath,你只需要填其他命令行参数就行了。因为并不是每次编译都需要构建一次本地镜像,为了不干扰使用普通 Java 虚拟机的编译,建议在 Maven 中独立建一个 Profile 来调用 Graal VM 插件,具体如下所示:
      <profiles>
        <profile>
          <id>graal</id>
          <build>
            <plugins>
              <plugin>
                <groupId>org.graalvm.nativeimage</groupId>
                <artifactId>native-image-maven-plugin</artifactId>
                <version>20.0.0</version>
                <configuration>
                  <buildArgs>-Dspring.graal.remove-unused-autoconfig=true --no-fallback -H:+ReportExceptionStackTraces --no-server</buildArgs>
                </configuration>
                <executions>
                  <execution>
                    <goals>
                      <goal>native-image</goal>
                    </goals>
                    <phase>package</phase>
                  </execution>
                </executions>
              </plugin>
              <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
              </plugin>
            </plugins>
          </build>
        </profile>
      </profiles>
      
      这个插件同样在 Maven 中央仓库中不存在,所以也得加上前面 Spring 的私有库:
      <pluginRepositories>
          <pluginRepository>
              <id>spring-milestone</id>
              <name>Spring milestone</name>
              <url>https://repo.spring.io/milestone</url>
          </pluginRepository>
      </pluginRepositories>
      
      至此,编译环境的准备顺利完成。
  • 程序调整 :
    • 首先,前面提到了 Graal VM 不支持 CGLIB,只能使用 JDK 动态代理,所以应当把 Spring 对普通类的 Bean 增强给关闭掉:
      @SpringBootApplication(proxyBeanMethods = false)
      public class ExampleApplication {
          public static void main(String[] args) {
              SpringApplication.run(ExampleApplication.class, args);
          }
      }
      
    • 然后,这是最麻烦的一个步骤,你程序里反射调用过哪些 API、用到哪些资源、动态代理,还有哪些类型需要在编译期初始化的,都必须使用 JSON 配置文件逐一告知 Graal VM。前面也说过了,这事情只有理论上的可行性,实际做起来完全不可操作。Graal VM 的开发团队当然也清楚这一点,所以这个步骤实际的处理途径有两种,第一种是假设你依赖的第三方包,全部都在 Jar 包中内置了以上编译所需的配置信息,这样你只要提供你程序里用户代码中用到的配置即可,如果你程序里没写过反射、没用过动态代理什么的,那就什么配置都无需提供。第二种途径是 Graal VM 计划提供一个 Native Image Agent 的代理,只要将它挂载在在程序中,以普通 Java 虚拟机运行一遍,把所有可能的代码路径都操作覆盖到,这个 Agent 就能自动帮你根据程序实际运行情况来生成编译所需要的配置,这样无论是你自己的代码还是第三方的代码,都不需要做预先的配置。目前,第二种方式中的 Agent 尚未正式发布,只有方式一是可用的。幸好,Spring 与 Graal VM 共同维护的在Spring Graal Native项目已经提供了大多数 Spring Boot 组件的配置信息(以及一些需要在代码层面处理的 Patch),我们只需要简单依赖该工程即可。
      <dependencies>
          <dependency>
              <groupId>org.springframework.experimental</groupId>
              <artifactId>spring-graal-native</artifactId>
              <version>0.6.1.RELEASE</version>
          </dependency>
          <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context-indexer</artifactId>
          </dependency>
      </dependencies>
      
      另外还有一个小问题,由于目前 Spring Boot 嵌入的 Tomcat 中,WebSocket 部分在 JMX 反射上还有一些瑕疵,在修正该问题的 PR被 Merge 之前,暂时需要手工去除掉这个依赖:
      <dependencies>
          <dependency>
              <groupId>org.springframework.boot</groupId>
              <artifactId>spring-boot-starter-web</artifactId>
              <exclusions>
                  <exclusion>
                      <groupId>org.apache.tomcat.embed</groupId>
                      <artifactId>tomcat-embed-websocket</artifactId>
                  </exclusion>
              </exclusions>
          </dependency>
      </dependencies>
      
    • 最后,在 Maven 中给出程序的启动类的路径:
      <properties>
          <start-class>com.example.ExampleApplication</start-class>
      </properties>
      
  • 开始编译 :
    • 到此一切准备就绪,通过 Maven 进行编译:
      $ mvn -Pgraal clean package
      
      编译的结果默认输出在 target 目录,以启动类的名字命名。
    • 因为 AOT 编译可以放心大胆地进行大量全程序的重负载优化,所以无论是编译时间还是空间占用都非常可观。笔者在 intel 9900K、64GB 内存的机器上,编译了一个只引用了 org.springframework.boot:spring-boot-starter-web 的 Helloworld 类型的工程,大约耗费了两分钟时间。
      [com.example.exampleapplication:9839]   (typeflow):  22,093.72 ms,  6.48 GB
      [com.example.exampleapplication:9839]    (objects):  34,528.09 ms,  6.48 GB
      [com.example.exampleapplication:9839]   (features):   6,488.74 ms,  6.48 GB
      [com.example.exampleapplication:9839]     analysis:  65,465.65 ms,  6.48 GB
      [com.example.exampleapplication:9839]     (clinit):   2,135.25 ms,  6.48 GB
      [com.example.exampleapplication:9839]     universe:   4,449.61 ms,  6.48 GB
      [com.example.exampleapplication:9839]      (parse):   2,161.78 ms,  6.32 GB
      [com.example.exampleapplication:9839]     (inline):   3,113.77 ms,  6.25 GB
      [com.example.exampleapplication:9839]    (compile):  15,892.88 ms,  6.56 GB
      [com.example.exampleapplication:9839]      compile:  25,044.34 ms,  6.56 GB
      [com.example.exampleapplication:9839]        image:   6,580.71 ms,  6.63 GB
      [com.example.exampleapplication:9839]        write:   1,362.73 ms,  6.63 GB
      [com.example.exampleapplication:9839]      [total]: 120,410.26 ms,  6.63 GB
      [INFO]
      [INFO] --- spring-boot-maven-plugin:2.3.0.M4:repackage (repackage) @ exampleapplication ---
      [INFO] Replacing main artifact with repackaged archive
      [INFO] ------------------------------------------------------------------------
      [INFO] BUILD SUCCESS
      [INFO] ------------------------------------------------------------------------
      [INFO] Total time: 02:08 min
      [INFO] Finished at: 2020-04-25T22:18:14+08:00
      [INFO] Final Memory: 38M/599M
      [INFO] ------------------------------------------------------------------------
      
  • 效果评估 :
    • 笔者使用 Graal VM 编译一个最简单的 Helloworld 程序(就只在控制台输出个 Helloworld,什么都不依赖),最终输出的结果大约 3.6MB,启动时间能低至 2ms 左右。如果用这个程序去生成 Docker 镜像(不基于任何基础镜像,即使用 FROM scratch 打包),产生的镜像还不到 3.8MB。 而 OpenJDK 官方提供的 Docker 镜像,即使是 slim 版,其大小也在 200MB 到 300MB 之间。
    • 使用 Graal VM 编译一个简单的 Spring Boot Web 应用,仅导入 Spring Boot 的 Web Starter 的依赖的话,编译结果有 77MB,原始的 Fat Jar 包大约是 16MB,这样打包出来的 Docker 镜像可以不依赖任何基础镜像,大小仍然是 78MB 左右(实际使用时最好至少也要基于 alpine 吧,不差那几 MB)。相比起空间上的收益,启动时间上的改进是更主要的,Graal VM 的本地镜像启动时间比起基于虚拟机的启动时间有着绝对的优势,一个普通 Spring Boot 的 Web 应用启动一般 2、3 秒之间,而本地镜像只要 100 毫秒左右即可完成启动,这确实有了数量级的差距。
    • 不过,必须客观地说明一点,尽管 Graal VM 在启动时间、空间占用、内存消耗等容器化环境中比较看重的方面确实比 HotSpot 有明显的改进,尽管 Graal VM 可以放心大胆地使用重负载的优化手段,但如果是处于长时间运行这个前提下,至少到目前为止,没有任何迹象表明它能够超越经过充分预热后的 HotSpot。在延迟、吞吐量、可监控性等方面,仍然是 HotSpot 占据较大优势,下图引用了 DEVOXX 2019 中 Graal VM 团队自己给出的 Graal VM 与 HotSpot JIT 在各个方面的对比评估:

Graal VM 与 HotSpot 的对比Graal VM 与 HotSpot 的对比

Graal VM 团队同时也说了,Graal VM 有望在 2020 年之内,在延迟和吞吐量这些关键指标上追评 HotSpot 现在的表现。Graal VM 毕竟是一个 2018 年才正式公布的新生事物,我们能看到它这两三年间在可用性、易用性和性能上持续地改进,Graal VM 有望成为 Java 在微服务时代里的最重要的基础设施变革者,这项改进的结果如何,甚至可能与 Java 的前途命运息息相关。