基于静态编译构建微服务应用

2023年 8月 16日 23.3k 0

Java 的局限性

传统的一个 Java 应用从代码编写到启动运行大致可以分为如下步骤:

  • 首先,编写 .java 源代码程序。
  • 然后,借助 javac 工具将 .java 文件翻译为 .class 的字节码,字节码是 Java 中非常重要的内容之一,正是因为它的出现,Java 才实现对底层环境的屏蔽,达到 Write once, run anywhere 的效果!
  • 基于步骤 2 的 .class 文件会被打包成 jar 包或者 war 包进行部署执行,部署过程中通过 Java 虚拟机加载应用程序然后解释字节码运行业务逻辑。
  • 整个过程如下图所示:图片

    图 1:Java 程序运行过程

    上述过程既给 Java 程序带来了其他编程语言不具备的优势,比如跨平台,易上手等。但同时也给 Java 程序带来了一些性能问题,比如启动速度慢和运行时内存占用高等。

    冷启动问题

    图 1 中 Java 程序启动运行详细过程如下图 2 所示:

    图片

    图 2:Java 程序的启动过程分析[1]

    一个
    Java 应用启动过程首先需要加载该应用程序对应的 JVM 虚拟机软件程序到内存中,如上图红色部分描述所示。然后 JVM
    虚拟机再加载对应的应用程序到内存中,该过程对应上图中的浅蓝色类加载(Class
    Load,CL)部分。在类加载过程中,应用程序就会开始被解释执行,对应上图中浅绿色部分。解释执行过程 JVM
    对垃圾对象进行回收,对应上图中的黄色部分。随着程序的运行的深入,JVM 会采用及时编译(Just In
    Time,JIT)技术对执行频率较高的代码进行编译优化,以便提升应用程序运行速度。JIT 过程对应上图中的白色部分。经过 JIT
    编译优化后的代码对应图中深绿色部分。

    经过上述分析,不难看出,一个 Java 程序从启动到达到被JIT动态编译优化会经过 VM init,App init 和 App active 几个阶段,相比于其他一些编译型语言,其冷启动问题比较严重。

    运行时内存占用高问题

    除了冷启动问题,从上述分析中不难看出,一个 Java 程序运行过程中,什么都不做首先就需要加载一个 JVM 虚拟机,该操作一般占用一定内存。另外,由于 Java 程序是先解释执行字节码,然后再做 JIT 编译优化。

    由于相比于一些编译型语言其将编译优化的动作后置到运行时,因此非常容易出现实际加载的代码比实际需要运行的代码多很多的情况,造成了一些无效内存占用情况。综上所述就是为什么很多人常诟病 Java 程序运行内存占用高的几点主要原因。

    更轻量化的 Java 程序

    静态编译技术

    既然,先解释执行再动态编译的 Java 传统程序运行方式存在上述诸多问题,那有没有一些方式可以让 Java 程序也跟其他程序语言,比如 C/C++ 一样,先编译后执行解决上述问题呢?

    答案是肯定的,提前编译(Ahead-of-Time
    Compilation,AOT Compilation)或者叫静态编译在 Java 领域很早就被提了出来。其核心思想就是将 Java
    程序的编译阶段提前到程序启动前,然后在编译阶段进行代码编译优化,让程序启动既巅峰,消除冷启动,降低运行时内存开销。

    Java 领域静态编译的实现技术有很多,其中最具代表性的还属 Oracle 推出的 GraalVM 开源高性能多语言运行时平台[2]。看到这里有的读者可能会问:“高性能多语言运行时平台是什么?它跟静态编译本身有什么关系?”。

    图片

    图 3:GraalVM 多语言运行时平台

    如上图
    3 所示,GraalVM 中通过提供 Truffle 解释器实现框架,让开发人员可以使用 Truffle 提供的 API
    快速实现特定语言的解释器从而实现对上图中各种编程语言所写的程序都能进行编译运行的效果,从而成为一个多语言运行时平台。GraalVM
    实现静态编译能力的编译器就是 GraalVM JIT Compiler。静态编译框架和运行时由 Substrate VM 子项目实现,兼容
    OpenJDK 运行时实现,提供了原生镜像程序运行时的异常处理、同步调度、线程管理、内存管理等功能。

    因此,GraalVM 不仅可以作为一个多语言运行时平台,而且由于其中提供的 GraalVM JIT Compiler 静态编译器,其可用来对 Java 程序进行静态编译。

    说完静态编译和
    GraalVM 之间的关系,有的读者可能会好奇,基于 GraalVM 的静态编译与常规的 JVM 解释执行方式有哪些区别?基于静态编译的
    Java 程序相比于目前应用广泛的 JVM 运行时编译 Java 程序整个从代码编写到编译执行的区别如下图 4 所示:

    图片

    图 4:静态编译与传统 JVM 运行过程对比

    相比于 JVM 运行时方式,静态编译在运行之前会先对程序解析编译,然后生成一个跟运行时环境强相关的 native image 可执行文件,最后直接执行该文件即可启动程序进行执行。

    说到这里可能有的读者又好奇,上图 4 中的静态编译过程到底会对 Java 程序做哪些解析操作?静态编译后的可执行程序垃圾回收问题怎么解决?如下图 5 所示,其描述了 GraalVM 静态编译技术实现中编译过程的输入与输出内容。

    图片

    图 5:静态编译输入输出

    图 5 中左侧前三个输入内容 Applicaton,Libraries 和 JDK 是一个 Java 程序编译运行必备的三部分,不必多说。而 Substrate VM 就是 GraalVM 中实现静态编译的核心部分,在整个静态编译过程中扮演了重要作用。

    其中在静态分析过程中,如上图
    5 中间部分中所绘制,Substrate VM 通过上下文不敏感的指向分析(Points-to
    Analysis)来对应用程序做静态分析,其可以在不需要运行程序的情况下,基于源程序分析给出所有可能的可达函数列表然后作为后续编译阶段的输入对程序进行静态编译。该过程由于静态分析的局限性,无法覆盖
    Java 中的反射、动态代理、JNI 调用等动态特性。这也造成了很多的 Java
    框架由于在实现过程中使用了大量的上述特性,因此,都难以直接基于 Substrate VM 完成对自身所有代码的静态分析,需要通过额外的外部配置[3]来解决静态分析本身的不足。

    例如像 Spring 社区因此开发了 AOT Engine[4]如下图 6 所示来帮助解决 Spring 项目对其中的反射,动态代理等内容进行静态分析处理并将其转换为 Substrate VM 能在编译阶段可识别的内容,确保对 Spring 应用可基于 Substrate VM 顺利完成静态编译。

    图片

    图 6:Spring AOT Engine

    在静态分析完成后,基于静态分析结果的可达函数列表,调用上文介绍的 GraalVM 中的 GraalVM JIT Compiler 编译器将应用程序编译为与目标平台强相关的本地代码以完成编译过程。

    编译完成后,就会进入到上图
    5 右侧 Native 可执行文件生成阶段。在该过程中,Substrate VM 会将静态编译阶段确定和初始化的内容以及跟 Substrate
    VM 运行时以及 JDK 库中的数据一起保存到最终可执行文件的 Image Heap 中。其中 Substrate VM
    运行时就为最终可执行文件提供了运行过程中所需的垃圾回收、异常处理等能力。对于垃圾回收这块,在一开始的 GraalVM 社区版中仅提供了
    Serial GC。企业版中提供了能力更强的 G1 GC。不过在最新的社区版中 GraalVM 团队也引入了 G1 GC[5]以便为广大开发者提供更强大的静态编译使用能力。

    适配 GraalVM 静态编译

    上节,简单介绍了静态编译技术以及其本身的局限性以后,很多外部社区开发者这时可能会疑问,一个 Java 开源项目如何快速进行静态编译适配?对于这个问题,其实最核心要解决的本质问题就是将开源框架中的 GraalVM 无法识别和处理的动态内容转换为其可识别的内容即可。因此该问题由于不同框架情况不一样,因此解决方式也会有一些差异。例如在
    Spring 中,针对其自身框架开发的 AOT Engine 可以解决其框架提供的通过 @Configuration
    注解注册类初始化过程无法在静态编译阶段被识别、提前在静态编译期生成原本在运行阶段才能生成的动态代理类解决直接静态编译代理类无法被有效生成等问题[6]从而实现 Spring 应用的静态编译适配。

    对于很多基于
    Spring 实现的开源框架,如果本身无法被 GraalVM 识别的动态特性都是由于 Spring 标准的那一套用法所导致,由于自身属于
    Spring 体系,静态编译过程就肯定少不了 Spring AOT Engine
    的参与,因此,框架自身就不需要再提供任何适配就可以具备静态编译能力。

    对于非
    Spring 体系项目或者自身使用了一些 JDK 中原生的反射或者其他 Java 动态特性,针对自身代码中的 Java
    动态用法需要在项目中提供对应的静态配置文件才能在静态编译过程中让编译器识别其中的动态特性,对其进行编译构建才能实现项目的顺利编译与执行。针对这种情况,GraalVM
    提供了一个名叫 native-image-agent 的
    Tracing Agent 来帮助大家更方便地收集元数据并准备配置文件。该 Agent 会在常规 Java VM
    上的应用程序运行过程中自动收集其中的动态特性使用情况并将其转换为 GraalVM 可以识别的配置文件。最后,将通过 Agent
    生成的框架自身的动态配置文件存放在项目的:META-INF/native-image//
    目录下,就可以在静态编译过程中根据这些配置内容,识别项目包中的动态特性。

    Spring
    Cloud Alibaba 2022.0.0.0 版本所包含的所有中间件客户端目前已完成了构建 GraalVM
    原生应用的适配。由于项目自身的特定,项目整体实现中有大量的 Spring 语法导致的无法被 GraalVM 识别的动态特性用法,这块内容直接交由
    Spring AOT Engine 来进行解决,社区未做额外适配工作。

    除了 Spring 体系语法,项目本身还是有一些其他 Java 动态用法的,这块社区通过 native-image-agent 来进行解析与动态配置生成。

    基于静态编译构建微服务

    Spring Cloud Alibaba 2022.0.0.0 版本所包含的所有中间件客户端已完成了构建 GraalVM 原生应用的适配。为用户提供了开箱即用的静态编译能力。相关功能体验过程如下:

    环境准备

    首先需要在首先在机器上安装 GraalVM 发行版。您可以在 Liberica Native Image Kit 页面上手动下载它,也可以使用像 SDKMAN! 这样的下载管理器。本文演示环境为 MacOS,如果是 Windows 可参考相应文档[7]进行操作。执行以下命令安装 GraalVM 环境:

    $ sdk install java 22.3.r17-nik
    $ sdk use java 22.3.r17-nik

    通过检查 java -version 的输出来验证是否配置了正确的版本:

    $ java -version
    openjdk version "17.0.5" 2022-10-18 LTS
    OpenJDK Runtime Environment GraalVM 22.3.0 (build 17.0.5+8-LTS)
    OpenJDK 64-Bit Server VM GraalVM 22.3.0 (build 17.0.5+8-LTS, mixed mode)

    应用构建

    要使用
    GraalVM 静态编译能力构建微服务,首先确保您项目的 Spring Boot 版本为 3.0.0 或以上,Spring Cloud 版本为
    2022.0.0 或以上。然后在项目中引入 Spring Cloud Alibaba 2022.0.0.0 版本的所需模块依赖即可。

    通过以下命令生成应用中反射、序列化和动态代理所需的 Hints 配置文件,前提是应用中引入了 spring-boot-starter-parent 父模块:

    $ mvn -Pnative spring-boot:run

    之后应用会启动,进行预执行,需要尽可能完整的测试一遍应用的所有功能,保证应用的大部分代码都被测试用例覆盖,该过程会基于
    GraalVM 的 native-image-agent
    收集程序中的动态特性,这样可以确保完整生成应用运行过程中的所有必须的动态属性。运行完所有测试用例后,我们发现
    resource/META-INF/native-image 目录下会生成以下一些 hints 文件:

    • resource-config.json:应用中资源 hint 文件
    • reflect-config.json:应用中反射定义 hint 文件
    • serialization-config.json:应用中序列化内容 hint 文件
    • proxy-config.json:应用中 Java 代理相关内容 hint 文件
    • jni-config.json:应用中 Java Native Interface(JNI)内容 hint 文件

    注意事项:Spring
    Cloud Alibaba 2022.0.0.0
    正式版本所有核心模块都已经默认将自身组件相关动态特性所需的配置内容都包含在了依赖中,因此上述预执行过程主要为了扫描应用自身业务代码以及其他第三方包中的动态特性,以便后续静态编译过程能顺利进行,应用能正常启动。

    静态编译

    以上步骤一切准备就绪后,通过以下命令来构建原生镜像:

    $ mvn -Pnative native:compile

    成功执行后,我们在 /target 目录可以看到生成的可执行文件。

    程序运行

    与普通可执行文件无异,通过 target/xxx 启动应用, 可以观察到类似如下的输出:

    2023-08-01T17:21:21.006+08:00  INFO 65431 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
    2023-08-01T17:21:21.008+08:00  INFO 65431 --- [           main] c.a.cloud.imports.examples.Application   : Started Application in 0.553 seconds (process running for 0.562)

    采用 GraalVM 静态编译技术的新版本 Spring Cloud Alibaba 应用,所有核心能力在启动速度和内存占用率方面如下表所示都有显著改善。

    图片图片

    说明:上述测试代码样例来自 Spring Cloud Alibaba 项目中的 examples 模块,4c16g Mac 环境,每组数据测试 3 次取平均,具体数据因机器不同可能会有差异。

    相关链接:

    [1] Java 程序的启动过程分析

    https://shipilev.net/talks/j1-Oct2011-21682-benchmarking.pdf

    [2] GraalVM 开源高性能多语言运行时平台

    https://www.oracle.com/java/graalvm/

    [3] 外部配置

    https://www.graalvm.org/latest/reference-manual/native-image/metadata/

    [4] AOT Engine

    https://spring.io/blog/2021/12/09/new-aot-engine-brings-spring-native-to-the-next-level

    [5] G1 GC

    https://medium.com/graalvm/a-new-graalvm-release-and-new-free-license-4aab483692f5

    [6] 动态代理类等问题

    https://docs.spring.io/spring-boot/docs/current/reference/html/native-image.html#native-image.introducing-graalvm-native-images.understanding-aot-processing

    [7] 相应文档

    https://medium.com/graalvm/using-graalvm-and-native-image-on-windows-10-9954dc071311

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论