在 GraalVM 静态编译下无侵入实现可观测探索
作者:铖朴、层风
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 ] 和基于静态编译构建微服务应用,做更详细的了解。
静态编译技术虽好,但对现有的 Java 技术体系也会有一定的影响。例如,探索过静态编译的朋友可能会清楚,经过静态编译后 Java 语言由于没有了字节码,会让原本一些基于 Java 字节码实现的 Java Agent 无侵入字节码改写技术失效。比如,目前 Java 生态中存在大量基于字节码改写无侵入地为 Java 应用提供如分布式链路追踪能力的解决方案,在现有的 Java 静态编译方案下,它们都将失效,这些也是很多企业在实施静态编译技术之前不得不考虑的技术难题。
通过静态插桩另辟蹊径
那是不是在静态编译场景下,就无法像传统的 Java 应用那样基于 Java Agent 探针实现开箱即用的可观测效果呢?
近期,阿里云可观测团队联合阿里云程序语言与编译器团队一起,为 GraalVM 实现了静态的 Agent 插桩增强能力,并在阿里云 ARMS 可观测平台上验证了静态增强数据的正确性和完整性,可有效解决目前 Java 静态编译时 Java Agent 字节码增强的问题。实现 Java 应用既要有基于 GraalVM 静态编译带来的性能提升,又能跟非静态编译场景下一样,能够通过类似于 Java Agent 这类技术无侵入的对应用实现分布式链路追踪等可观测效果。
什么是静态插桩?
要搞清楚什么是静态插桩?不得不提其相对的一个概念:动态插桩。
熟悉 Java Agent 探针技术的读者,应该了解 Java Agent 的作用过程,其本质是一种字节码改写技术,在应用运行过程中的类加载阶段通过字节码改写技术,在应用的特定类方法(也叫埋点)前后插入一些增强逻辑,以达到对应用研发人员而言无感知地给应用增加一些比如,分布式链路追踪等可观测能力。
相比于动态插桩应用运行过程中通过字节码改写动态插入一些逻辑,静态插桩即是在程序启动前就执行字节码改写,然后在运行前的 GraalVM 静态编译阶段,将之前收集的字节码改写最终内容编译到最终的可执行文件中,以实现动态插装一样的无侵入给应用在特定埋点进行能力增强的效果。
针对 Java Agent 的静态插桩方案
通过上述对静态插桩概念的介绍可知,要对应用代码进行插装,无非要解决以下两个问题:
- 在应用的哪些位置进行插桩?
- 要在特定的位置插桩哪些内容?
因此,我们设计了一种 “预执行记录+编译时替换” 的方法来解决该问题,其过程整体分为两步:
- 通过应用程序的预执行记录所有被增强的类信息;
- 在 GraalVM 静态编译阶段,利用之前预执行收集的被增强类实现编译阶段的替换。
这样理论上就解决了在应用的哪些位置,增强什么内容的问题。
方案正确性论证
首先,回顾一下 Java Agent 机制的详细工作过程,其是在应用的 main 函数启动前,将 Agent 中定义的类转换器(Transformer)和响应 eventHandlerClassFileHook 的钩子实现注册到 JVM 中。每当应用程序中首次加载一个类时,都先执行 eventHandlerClassFileHook 钩子中注册的代码,然后再加载类。开发者可以在该钩子中实现对指定类的变换,这样运行时加载到的类就是经过 Agent 增强的类了。
因此,对于任意类 C,JVM 的 Agent 机制可以保证在 C 首次被加载的时刻即被 Agent 替换为 C'。从实际运行的程序的角度,它在运行时自始至终接触到的只有 C',而不是 C。因此,假设我们在编译时就实现了将 C 替换为 C',那么对于应用程序来说,其所见到的类自始至终也是 C'。由此可见,在此问题上编译时替换和运行时替换对程序运行的效果是完全等价的。
所以,这个运行时问题也就转换成为了两个编译时问题:
- 如何可以在编译前就获得 C'?
- C 和 C'是两个同名类,如何在编译时保证同名类替换?
预执行记录被增强的类
了解过 GraalVM 静态编译技术的读者,应该知道,GraalVM 提供了一个叫做 native-image-agent [ 4] 的探针,通过给应用进行挂载进行预执行,可以记录 Java 应用程序中的反射、动态类加载、动态代理、序列化等动态行为,输出记录了这些信息的配置文件。在编译阶段,配置文件也会作为编译的输入为编译器提供动态行为信息,以实现 Java 动态特性在静态编译环境仍然可生效的目的。
因此,我们通过对 native-image-agent 进行改写,在原有的基础上增加了对 Agent 实现类变换代码增强行为的观察记录逻辑,实现原理如图 2 所示。图中的黄色 Agent 在原始应用 App 上对红色的代码 C 实行运行时动态增强,将 C 部分代码转换为 C',从而得到了 App'。增加了记录代码增强能力的 native-image-agent 负责观察从 C 到 C' 的过程,将 C 的具体类名保存到配置文件,将变换后的 C' 保存到磁盘。
图 2 native-image-agent 监测原 Agent 代码变换过程示意图
通过 native-image-agent 实施增强记录是本方案的核心。整个过程必须包括被变换的类名、原始类文件的 byte 数组和变换后的类文件 byte 数组。以便可以判断出类是否发生了变化,以免记录下大量的噪音信息。
通过梳理 JVM 中 Agent 的工作流程,我们选择了 Java 函数 sun/instrument/InstrumentationImpl.transform 作为观察切入点,即图 3 中的红圈处。以下将这个函数简称为 transform 函数。
图 3 JVM 支持 agent 实现动态代码变换流程图
我们在 native-image-agent 中增加一个针对 transform 函数的函数断点,然后对比变换前后的类数据是否一致。如果一致,说明没有做变换,该类无需进行记录;如果不一致,说明类已经被改变,则将其类的全限定名输出到配置文件,将类的内容保存到磁盘。
编译时替换
得到了增强类,接下来只要在编译时用它们替换原始类,就可以在最终经过静态编译的 native image 可执行文件中实现插桩增强的效果了。那么在编译时如何替换呢?最简单且安全的方式就是在类加载时替换。GraalVM 的静态编译能力本身也是一个 Java 程序,需要将编译的目标类全部加载到 classpath 上。
所以简单地说,我们只要在生成 classpath 列表时,将增强类的路径放在最前边就可以了。对于使用了 module system 的情况,因为同一个类不能出现在两个 module 中,我们就要将增强类准备为 jar 包,通过 --patch-module 的形式替换原始类。这个过程原理简单,但是自动化实现的过程比较复杂,需要在修改 GraalVM 静态编译框架,在此就不展开了。
经过上述方法的处理,GraalVM 静态编译后的本地可执行程序中就只有变换后的代码,其运行时行为就与期待的行为一致。通过以上预执行记录+编译时替换两个步骤就实现了对应用在 GraalVM 环境下的静态插桩。
静态插桩技术实践
基于上述方案,我们已经对一些常用的微服务组件,比如 Spring Boot、Kafka、MySQL 和 Redis 进行了效果验证,我们目前是直接基于业界知名的可观测 Java Agent 探针实现 opentelemetry-java-instrumentation [ 5] 进行数据采集(后文简称 OT 探针),然后将采集的可观测数据上报到阿里云应用实时监控服务 ARMS 中的可观测链路 OpenTelemetry 版 [ 6] 中进行的效果验证,如下为相关测试效果。
测试效果
JVM 模式
在一般的 JVM 运行时环境下,利用 OT 探针无侵入对 Spring Boot 应用进行可观测数据采集,然后将数据上报到阿里云应用实时监控服务 ARMS 中的可观测链路 OpenTelemetry 版中的效果如图5所示:
图 5 在传统 JVM 条件下的可观测数据采集与展示效果
我们测试过程中对应用发了 5 次调用,从图 5 的效果调用链记录的次数和调用链详情信息与实例应用都是一致的。
GraalVM 模式
在 GraalVM 静态编译环境下,基于上述方案,然后利用 OT 探针无侵入对 Spring Boot 应用进行可观测数据采集,将数据上报到阿里云应用实时监控服务 ARMS 中的可观测链路 OpenTelemetry 版中的效果如图 6 所示:
图 6 在 GraalVM 静态编译条件下的可观测数据采集与展示效果
测试过程中同样对应用发了 5 次调用,通过上述效果对比截图可以发现,Spring Boot 应用基于 GraalVM 静态编译后,采用静态插桩技术,所采集的请求数等指标与 JVM 环境动态增强方式一致,得益于静态编译技术的优化,请求 Span 耗时(不涉及网络情况下)比 JVM 环境增强方式低很多。除了上述 Spring Boot 应用的测试结果,其他的一些常用组件,例如 Kafka、MySQL 和 Redis 都做了上述同样测试,发现方案都是有效的!
另外,下表为我们测试的部分框架应用基于正常 JVM 环境下挂载探针 vs 基于静态编译场景挂载探针耗时和运行时内存占用情况数据(测试环境:32 核(vCPU)/64 GiB/5 Mbps):
基于静态编译后,各类型应用的启动耗时大致降低了 98% 左右,运行时内存占用比原先下降了约 70% 左右,从测试结果看,上述 4 个框架组件基于当前方案,既能享受到静态编译带来的性能大幅度提升,也可消除静态编译带来的 Java Agent 无侵入增强失效问题。
其他
最后,如上述内容介绍所示,当前我们已经完成了方案的验证,并向 GraalVM 社区提交了相关的修改 PR [ 7] 。如果要在生产场景应用,也还有一些其他工程性的问题需要处理和优化。比如,Java Agent 可能出于一些场景需要,要能实现对 JDK 中的类进行替换,而 GraalVM 本身也修改了部分 JDK 类,以使之适应静态编译后的运行时。所以碰到两边都进行修改要考虑兼容性等。最后,欢迎对该方案感兴趣或者希望进行相关效果复现的读者,可以加钉钉群: 80805000690,获取相关资料和做进一步交流探讨。
相关链接:
[1] Java 程序的启动过程分析
https://shipilev.net/talks/j1-Oct2011-21682-benchmarking.pdf
[2] GraalVM 开源社区
[3] 从本地原生到云原生,Alibaba Dragonwell 静态编译的实践与挑战
https://www.infoq.cn/article/uzHpEbpMwiYd85jYslka
[4] native-image-agent
https://www.graalvm.org/latest/reference-manual/native-image/metadata/AutomaticMetadataCollection/
[5] opentelemetry-java-instrumentation
https://github.com/open-telemetry/opentelemetry-java-instrumentation
[6] 可观测链路 OpenTelemetry 版
https://help.aliyun.com/zh/arms/tracing-analysis/
[7] 支持静态插桩相关 PR