作者:铖朴、层风
GraalVM 静态编译
背景介绍
随着云原生浪潮的蓬勃发展,利用云原生技术为企业应用提供极致的弹性能力是企业数字化升级的核心诉求。但 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 程序运行内存占用高的原因。
静态编译技术
严重的冷启动耗时和较高的运行时内存占用使得 Java 应用难以满足云原生快速启动和快速扩缩容的需求。因此业界,以 Oracle 公司为主导的 GraalVM 开源社区 [ 2] ,通过推出 Java 静态编译技术,可以提前将 Java 程序编译为本地可执行文件,达到运行即巅峰的效果,可有效解决 Java 应用冷启动和运行时内存占用高的问题,让 Java 继续在云原生技术浪潮中焕发生机。阿里巴巴作为 GraalVM 社区中国唯一的全球顾问委员会成员,持续在 GraalVM 上深入打磨,使之更加适合电商和云上场景。如果之前对静态编译技术不了解,可以阅读从本地原生到云原生,Alibaba Dragonwell 静态编译的实践与挑战 [ 3] 和基于静态编译构建微服务应用,做更详细的了解。
静态编译技术虽好,但是变成 GraalVM Native Image 之后,也会带来一系列问题,例如:
- Java 程序的许多动态特性都不直接生效了,例如动态类加载,反射,动态代理等。需要使用 GraalVM 提供的额外配置方式来解决这个问题。
- 丧失了 Java 程序多年来引以为傲的平台无关性。
- 最重要的是,基于字节码改写实现的 Java Agent 将不再适用,因为没有了字节码概念,所以之前我们通过 Java Agent 做到的各种可观测能力,例如收集 Trace/Metrics 等信号这些能力都不能生效了。
因此,我们希望在提升启动时间和降低内存消耗的同时,让应用同时具备开箱即用的可观测能力,即 Java Agent 所做的增强都能继续保持工作。那么该如何解决这个问题呢?
解决方案
针对上述这一普遍痛点,阿里云可观测团队联合阿里云程序语言与编译器团队一起,在业界,首创性地设计实现了一种静态的 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 中,运行过程中的字节码不再存在,因此无法采用类似上述方案在运行时增强应用程序。如果要实现上述类似的能力,只能在应用运行之前去给应用添加上述能力。因此,这个问题就转化为:
- 如何在运行前转换目标类,得到转换后的类?
- 如何在运行前让转换后的类替换原始类?
针对上述两个问题,我们设计的整体方案如下图 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 应用进行可观测的步骤如下:
安装依赖
GraalVM 场景下,首先需要在环境中安装以下依赖:
- 根据自身应用所在 Region,下载对应 ARMS 探针 GraalVM 版本(当前暂先支持以下 5 个区域,如有更多需求,欢迎通过钉钉答疑群(群号:80805000690)与我们联系):
地域:华东 1(杭州)
公网地址
wget "http://arms-apm-cn-hangzhou.oss-cn-hangzhou.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
-
下载支持可观测能力的 GraalVM JDK 版本:支持可观测能力的 GraalVM JDK 版本 [ 4] 。
-
解压,在目录中执行:graalvm-java17-23.0.4-ali-1.2b/bin/native-image --version,结果显示如下:
-
下载 Maven(如果环境已有,可不安装):apache-maven-3.8.4 [ 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
引入依赖
完成安装后,给应用添加如下依赖:
<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 指向应用原始的动态配置文件地址。
预执行
为了让 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=
########
静态编译
完成依赖添加后,按照如下步骤对应用进行静态编译:
- 执行 mvn -Pnative package 开始静态编译。
- 执行 sh ArmsAgentNative/run.sh --native --Carms 运行静态编译后的经过项目。
效果演示
当完成上述静态编译后,相关可执行 Native Image 文件中就包含了 ARMS 可观测 Java Agent 的代码。执行按照正常 GraalVM 应用部署运行方式进行运行即可,以下是其在 ARMS 控制台上的部分可观测数据采集效果:
GraalVM 应用指标数据采集效果
GraalVM 应用调用链数据采集效果
如下图是一个通过 Spring Schedule 发起定时任务调用 Restful 接口,然后通过 HttpClient 对外进行调用的示例:
性能效果
我们基于上述方案,也对 GraalVM 应用在启动速度和运行时内存占用进行了一些测试验证,发现 Java 应用基于 GraalVM 静态编译后,不仅可以正常使用开箱即用的可观测能力,运行时内存占用和启动延时仍然有巨大的优化效果(以下测试在 32 vCPU/64 GiB/5 Mbps 环境中完成)。
其他
最后,欢迎对上述相关产品能力和技术方案感兴趣的读者,可以加钉钉群:80805000690,获取相关资料和做进一步交流探讨。
相关链接:
[1] Java 程序的启动过程分析
https://shipilev.net/talks/j1-Oct2011-21682-benchmarking.pdf
[2] GraalVM 开源社区
[3] 从本地原生到云原生,Alibaba Dragonwell 静态编译的实践与挑战
https://www.infoq.cn/article/uzHpEbpMwiYd85jYslka
[4] 支持可观测能力的 GraalVM JDK 版本
[5] apache-maven-3.8.4
标签:10,Java,cn,ArmsAgentNative,观测,编译,Agent,GraalVM From: https://www.cnblogs.com/alisystemsoftware/p/18544359