GraalVM静态编译在云原生环境中的可观测实践与性能优化

探讨GraalVM静态编译如何提升Java应用的性能与可观测性,并分享实践案例与步骤。

原文标题:10 倍性能提升, GraalVM 应用可观测实践

原文作者:阿里云开发者

冷月清谈:

本文探讨了GraalVM静态编译技术在云原生环境中的应用,重点介绍了阿里云ARMS发布的Java Agent探针,如何为GraalVM应用提供可观测能力。文章详细分析了Java应用在启动和运行过程中的冷启动和内存占用问题,并提出通过GraalVM的静态编译技术解决这些问题。此外,文中介绍了静态Java Agent的插桩增强能力,以确保在优化性能的同时保留可观测能力。最后,文章提供了实施ARMS Java Agent的步骤和性能测试结果,显示基于GraalVM的应用在启动速度和内存占用方面的显著优化效果。

怜星夜思:

1、GraalVM的静态编译与动态特性之间的权衡如何处理?
2、在GraalVM应用中,如何有效监控性能与可观测性?
3、GraalVM应用的静态编译对于企业运维的影响如何?

原文内容

阿里妹导读


本文介绍了 GraalVM 静态编译技术在云原生环境下的应用:ARMS 发布了支持 GraalVM 应用的 Java Agent 探针,可为 GraalVM 应用提供开箱即用的可观测能力。同时,文章还提供了使用 ARMS 对 GraalVM 应用进行可观测的详细步骤。

一、GraalVM 静态编译


1.1 背景介绍

随着云原生浪潮的蓬勃发展,利用云原生技术为企业应用提供极致的弹性能力是企业数字化升级的核心诉求。但 Java 作为一种解释执行+运行时实时编译的语言,相比于其他静态编译型语言天生具有如下不足,严重影响了其快速启动与扩缩容效果。
冷启动问题
Java 程序启动运行详细过程如图 1 所示:
图 1:Java 程序的启动过程分析[1]
Java 应用在启动时首先需要加载 JVM 虚拟机到内存中,如图 1 红色部分所示。然后 JVM 虚拟机再加载对应的应用程序到内存中,该过程对应上图中的浅蓝色类加载(Class Load,CL)部分。在类加载过程中,应用程序就会开始被解释执行,对应上图中浅绿色部分。解释执行过程 JVM 对垃圾对象进行回收,对应上图中的黄色部分。随着程序运行的深入,JVM 会采用及时编译(Just In Time,JIT)技术对执行频率较高的代码进行编译优化,以便提升应用程序运行速度。JIT 过程对应上图中的白色部分。经过 JIT 编译优化后的代码对应图中深绿色部分。经过上述分析,不难看出,一个 Java 程序从启动到达到被 JIT 动态编译优化会经过 VM init,App init 和 App active 几个阶段,相比于其他一些编译型语言,其冷启动问题比较严重。
运行时内存占用高问题
除了冷启动问题,从图 1 中可以看到,一个 Java 程序运行过程中,什么都不做首先便需要加载一个 JVM 虚拟机,该过程一般会占用一定量内存,另外,JIT 编译和 GC 都会有一定量的内存开销。最后,由于 Java 程序是先解释执行字节码,然后再做 JIT 编译优化,因此由于其编译期比较晚,一些非必要的代码逻辑可能也会被预先加载到内存中进行编译。所以除了实际要执行的应用程序外,这些非必要代码逻辑也是一笔难以忽视的额外开销。综上所述,这些就是很多人常诟病 Java 程序运行内存占用高的原因。


1.2 静态编译技术

严重的冷启动耗时和较高的运行时内存占用使得 Java 应用难以满足云原生快速启动和快速扩缩容的需求。因此业界,以 Oracle 公司为主导的 GraalVM 开源社区[2],通过推出 Java 静态编译技术,可以提前将 Java 程序编译为本地可执行文件,达到运行即巅峰的效果,可有效解决 Java 应用冷启动和运行时内存占用高的问题,让 Java 继续在云原生技术浪潮中焕发生机。阿里巴巴作为 GraalVM 社区中国唯一的全球顾问委员会成员,持续在 GraalVM 上深入打磨,使之更加适合电商和云上场景。如果之前对静态编译技术不了解,可以阅读从本地原生到云原生,Alibaba Dragonwell 静态编译的实践与挑战[3]和,做更详细的了解。静态编译技术虽好,但是变成 GraalVM Native Image 之后,也会带来一系列问题,例如:
  1. Java 程序的许多动态特性都不直接生效了,例如动态类加载,反射,动态代理等。需要使用 GraalVM 提供的额外配置方式来解决这个问题。

  2. 丧失了 Java 程序多年来引以为傲的平台无关性。

  3. 最重要的是,基于字节码改写实现的 Java Agent 将不再适用,因为没有了字节码概念,所以之前我们通过 Java Agent 做到的各种可观测能力,例如收集 Trace/Metrics 等信号这些能力都不能生效了。
因此,我们希望在提升启动时间和降低内存消耗的同时,让应用同时具备开箱即用的可观测能力,即 Java Agent 所做的增强都能继续保持工作。那么该如何解决这个问题呢?


1.3 解决方案

针对上述这一普遍痛点,阿里云可观测团队联合阿里云程序语言与编译器团队一起,在业界,首创性地设计实现了一种静态的 Java Agent 插桩增强能力来解决该问题。
在正式介绍具体解决方案之前,回顾一下 Java Agent 的工作原理显得有必要。Java Agent 使用中包含了一些重要过程:preMain 执行、main 函数执行和类加载。当应用程序使用 Java Agent 时,它会为特定类(例如图中的类 C)注册一个转换器 transformer。在 preMain 执行之后,会执行应用的 main 函数,在这个过程中可能会加载各种类,当类加载器遇到类 C 时,会触发 Java Agent 注册的回调 callback,其中会执行针对类 C 的转换逻辑,将其转换为类 C',最后,类加载器加载转换后的类 C',从而实现基于 Java Agent 对原始应用中的特定类进行字节码改写,增加一些额外逻辑的效果。
图 2:Java Agent 技术工作原理
然而,在 GraalVM 中,运行过程中的字节码不再存在,因此无法采用类似上述方案在运行时增强应用程序。如果要实现上述类似的能力,只能在应用运行之前去给应用添加上述能力。因此,这个问题就转化为:
  1. 如何在运行前转换目标类,得到转换后的类?

  2. 如何在运行前让转换后的类替换原始类?
针对上述两个问题,我们设计的整体方案如下图 3 所示。它包含两个阶段:Pre Running Phase 预运行阶段和 Static Compilation Phase 静态编译。在预运行阶段,应用程序挂载 OTel Java Agent 和 Native Image agent 两个探针进行预执行。其中,OTel Java Agent 负责在预执行过程中将类从 C 转换为 C'。Native Image agent 负责在该过程中收集转换后的类,例如收集下图中展示的类 C'。从而解决问题 A:如何在运行前转换目标类,得到转换后的类。
图 3:静态的 Java Agent 插桩增强解决方案
接下来,在静态编译阶段,我们将原始应用程序、OTel Agent、转换后的类和配置作为输入,并对其进行编译。在编译过程中,我们将应用程序中类 C 替换为 C',并生成一个仅包含 C' 的可执行程序以供运行。从而解决问题 B:如何在运行前让转换后的类替换原始类。
了解了整体方案后,有的读者可能对 Native Image agent 是什么以及如何使用它来收集转换后的类感到好奇。Native Image agent 其实是 GraalVM 已提供的一个工具。它可以扫描我们的应用程序以收集静态编译所需的所有动态配置。以便消除 GraalVM Compiler 的不足影响,允许开发人员在 GraalVM 中继续使用 Java 所提供的一些动态特性,例如反射、动态代理等。
但是,它并不能直接帮助我们收集转换后的类。为了解决这个问题,如下图 4 所示:我们在 Native Image agent 中添加了一个拦截器。此拦截器在转换前后检查类的字节码。如果检测到更改,它会记录并保存它们;否则,它会忽略该类。
图 4:Native image agent 改造
事实上,我们发现仅记录转换后的类是不够的。有些类不是原始应用程序的一部分,例如动态生成的类。因此,我们还需要使用 Native Image agent 对其进行收集。此外,由于 PreMain 是 JVM 和 Java Agent 中的概念,在 GraalVM 中没有被原生支持。我们也用它生成了必要的 premain 配置,以便让 GraalVM 知道 OTel Java Agent 的入口点。
除了上述内容,对于一些特殊情况,我们还做了一些额外适配。例如,因为 GraalVM 编译器也是一个 Java 程序,我们无法直接基于 Native Image agent 收集 JDK 中被变换后的类并像一般的非 JDK 类一样直接在编译时进行替换,因为这可能会影响 GraalVM 的编译行为。因此,我们通过在 GraalVM 中实现了一些特殊 API,并在 OTel Java Agent 中基于其对 JDK 类进行重新埋点,以使 GraalVM 静态编译过程中可以识别到相关内容并在不修改自身所依赖的 JDK 前提下,在最终生成的 Native Image 可执行文件中包含相关 JDK 类的转换逻辑。最后,OTel Java Agent 中有多个类加载器,而 GraalVM 中只有一个类加载器,我们对类进行 Shade 处理以实现类似的功能。

二、通过 ARMS 对 GraalVM 应用进行观测

目前 ARMS Java Agent 已经基于上述方案,完成了相关能力支持,具体使用 ARMS 对 GraalVM 应用进行可观测的步骤如下:


2.1 安装依赖

GraalVM 场景下,首先需要在环境中安装以下依赖:
1. 根据自身应用所在 Region,下载对应 ARMS 探针 GraalVM 版本(当前暂先支持以下 5 个区域,如有更多需求,欢迎通过钉钉答疑群(群号:80805000690)与我们联系):
地域:华东 1(杭州)
公网地址
wget "http://arms-apm-cn-hangzhou.oss-cn-hangzhou-internal.aliyuncs.com/ArmsAgentNative.zip" -O ArmsAgentNative.zip
VPC 地址
wget "http://arms-apm-cn-hangzhou.oss-cn-hangzhou-internal.aliyuncs.com/ArmsAgentNative.zip" -O ArmsAgentNative.zip
地域:华东 2(上海)
公网地址
wget "http://arms-apm-cn-shanghai.oss-cn-shanghai.aliyuncs.com/ArmsAgentNative.zip" -O ArmsAgentNative.zip
VPC 地址
wget "http://arms-apm-cn-shanghai.oss-cn-shanghai-internal.aliyuncs.com/ArmsAgentNative.zip" -O ArmsAgentNative.zip
地域:华北 2(北京)
公网地址
wget "http://arms-apm-cn-beijing.oss-cn-beijing.aliyuncs.com/ArmsAgentNative.zip" -O ArmsAgentNative.zip
VPC 地址
wget "http://arms-apm-cn-beijing.oss-cn-beijing-internal.aliyuncs.com/ArmsAgentNative.zip" -O ArmsAgentNative.zip
地域:华北 3(张家口)
公网地址
wget "http://arms-apm-cn-zhangjiakou.oss-cn-zhangjiakou.aliyuncs.com/ArmsAgentNative.zip" -O ArmsAgentNative.zip
VPC 地址
wget "http://arms-apm-cn-zhangjiakou.oss-cn-zhangjiakou-internal.aliyuncs.com/ArmsAgentNative.zip" -O ArmsAgentNative.zip
地域:华南 1(深圳)
公网地址
wget "http://arms-apm-cn-shenzhen.oss-cn-shenzhen.aliyuncs.com/ArmsAgentNative.zip" -O ArmsAgentNative.zip
VPC 地址
wget "http://arms-apm-cn-shenzhen.oss-cn-shenzhen-internal.aliyuncs.com/ArmsAgentNative.zip" -O ArmsAgentNative.zip
解压文件后,进入到 ArmsAgentNative 目录,执行以下命令完成在本地环境的探针安装:
sh install.sh

2. 下载支持可观测能力的 GraalVM JDK 版本:支持可观测能力的 GraalVM JDK 版本[4]。
3. 解压,在目录中执行:graalvm-java17-23.0.4-ali-1.2b/bin/native-image --version,结果显示如下:
4. 下载 Maven(如果环境已有,可不安装):apache-maven-3.8.4[5]。
5. 解压,将环境变量 JAVA_HOME 和 MAVEN_HOME 设置对应解压文件后的路径,比如类似如下所示(注意将 /xxx/ 换成实际路径):
export MAVEN_HOME=/xxx/apache-maven-3.8.4
export PATH=$PATH:$MAVEN_HOME/bin
export JAVA_HOME=/xxx/graalvm-java17-23.0.4-ali-1.2b
export PATH=$PATH:$JAVA_HOME/bin


2.2 引入依赖

完成安装后,给应用添加如下依赖:
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>arms-javaagent-native</artifactId>
<version>4.1.11</version>
<type>pom</type>
</dependency>
</dependencies>
<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<extensions>true</extensions>
<executions>
<execution>
<id>build-native</id>
<goals>
<goal>compile-no-fork</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
<configuration>
<fallback>false</fallback>
<buildArgs>
<arg>-H:ConfigurationFileDirectories=native-configs,/xxx/dynamic-configs</arg>
</buildArgs>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
注意:需要将第 30 行中的路径 /xxx/dynamic-configs 指向应用原始的动态配置文件地址。


2.3 预执行

为了让 ARMS Agent 对应用的动态增强代码能被静态编译到最终的 Native Image 文件中,需要预先挂载 ARMS Agent 对应用进行如上图 3 的预执行,预执行需要确保应用核心代码分支都被执行,当前已提供一个相关脚本供您完成该过程。注意将应用的所有 RESTful 接口都按提示声明在脚本中,以便执行过程相关接口被正常调用触发业务执行。
打开脚本,按照注意完成以下自定义内容补充后,执行 sh ArmsAgentNative/run.sh --collect --jvm --Carms 挂载 ARMS Java Agent 运行(run.sh 在 ArmsAgentNative 中),进入预执行,搜集静态编译的配置项:
######## 请根据实际修改一下参数
# ARMS 接入参数, 在Java应用监控面板获取到对应的LicenseKey。AppName代表接入到ARMS的哪一个应用中,您可以根据需要自定义您的应用名,在分布式架构中,同一个应用内可以包含多个对等的实例
export ARMS_LICENSEKEY=
export ARMS_APPNAME=
# 应用接口列表,例PS=(interface1 interface2 interface3 interface4)
export PS=
# 应用端口,例PORT="8080"
export PORT=
# Native image文件路径,静态编译后会在应用target目录下生成一个native image文件,例NATIVE_IMAGE_FILE="target/graalvm-demo"
export NATIVE_IMAGE_FILE=
# 运行ARMS Native Agent的命令,例JAVA_CMD="-javaagent:./arms-native/aliyun-java-agent-native.jar -jar target/graalvm-demo-1.0.0.jar"
export JAVA_CMD=
########


2.4 静态编译

完成依赖添加后,按照如下步骤对应用进行静态编译:
  1. 执行 mvn -Pnative package 开始静态编译。
  2. 执行 sh ArmsAgentNative/run.sh --native --Carms 运行静态编译后的经过项目。


2.5 效果演示

当完成上述静态编译后,相关可执行 Native Image 文件中就包含了 ARMS 可观测 Java Agent 的代码。执行按照正常 GraalVM 应用部署运行方式进行运行即可,以下是其在 ARMS 控制台上的部分可观测数据采集效果:
GraalVM 应用指标数据采集效果
GraalVM 应用调用链数据采集效果
如下图是一个通过 Spring Schedule 发起定时任务调用 Restful 接口,然后通过 HttpClient 对外进行调用的示例:


2.6 性能效果

我们基于上述方案,也对 GraalVM 应用在启动速度和运行时内存占用进行了一些测试验证,发现 Java 应用基于 GraalVM 静态编译后,不仅可以正常使用开箱即用的可观测能力,运行时内存占用和启动延时仍然有巨大的优化效果(以下测试在 32 vCPU/64 GiB/5 Mbps 环境中完成)。


2.7 其他

最后,欢迎对上述相关产品能力和技术方案感兴趣的读者,可以加钉钉群:80805000690,获取相关资料和做进一步交流探讨。

相关链接:

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

https://shipilev.net/talks/j1-Oct2011-21682-benchmarking.pdf
[2] GraalVM 开源社区
https://www.graalvm.org/?spm=a2c6h.12873639.article-detail.10.d8ae4dd9VkMytl

[3] 从本地原生到云原生,Alibaba Dragonwell 静态编译的实践与挑战

https://www.infoq.cn/article/uzHpEbpMwiYd85jYslka

[4] 支持可观测能力的 GraalVM JDK 版本

https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20240929/pqwbtu/graalvm-java17-23.0.4-ali-1.2b.tar.gz
[5] apache-maven-3.8.4

https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20240929/fdfzbj/apache-maven-3.8.4-bin.tar.gz



静态编译能够显著降低应用的启动时间,这对于运维人员来说,可以减少资源占用,提高扩展效率,但可能需要重新审视运维流程。

其实我觉得对于很多使用Java的企业来说,性能提升是最重要的,动态特性可以通过其它手段实现,或者在不需要的时候关掉动态特性。

我认为运维会变得更加简洁,因为静态编译后的应用可直接执行,减少了对JVM的依赖,此后升级时也能减少不必要的重复配置。

除了ARMS,还有哪些开源的工具可以实现可观测性,比如Prometheus或者Grafana,这些工具结合GraalVM的应用也能提供很好的性能监控。

监控应该是一个持续的过程,建议使用APM工具结合日志追踪,通过链路追踪来帮助定位性能瓶颈,有时候不仅靠APM工具,有效的日志管理也很重要。

我认为可以通过逐步引入静态编译的方式来让现有的Java程序适应,也就是逐步迁移。比如初期可以选择不损失较多动态特性的模块进行编译。

静态编译提高了性能,但确实会限制Java的动态特性,如反射和动态代理。我觉得这可以通过设计更灵活的配置机制来弥补,比如提供额外的层次或插件机制。

我觉得ARMS的探针是一个很好的工具,它能够在不干扰原有应用逻辑的情况下进行监测。我建议在开发的早期就开始引入监控体系,以便对性能问题早发现早解决。

当然,静态编译的运维也会带来新的挑战,比如因为少了JVM的动态调优能力,运维人员可能需要提前做更多的性能测试,确保静态编译的可行性。