vlambda博客
学习文章列表

怎么让 Java 冷启动提速两个数量级?

Java 语言自从诞生以来就被打上了“慢”的标签。经过 25 年的发展,Java 程序的峰值性能在实时编译(JIT)技术的支持下已能超越 C/C++ 程序,但实时编译仍无法解决冷启动速度慢的问题。在云原生的场景下,往往需要快速拉起新的服务以响应用户持续增长的请求,此时服务的启动时间就显得至关重要。

 

阿里巴巴探索了两种不同维度上的冷启动加速技术,经过双 11 大促的检验都取得了良好的效果:AppCDS 技术在传统 Java 环境的维度上,利用类数据共享特性改进启动速度和减少内存开销;静态编译技术则在更为激进的维度上,将 Java 程序提前编译为二进制机器码,实现以 Native Code 的速度启动 Java 程序,带来最多两个数量级的启动性能提升。在 QCon 全球软件开发大会(2021)北京站上,阿里巴巴 JVM 团队技术专家林子熠博士介绍了这两种技术的特点和适用场景。

 

Java 诞生至今的 25 年里,凭借其峰值性能高、语言功能强、生态支持好等特点赢得了语言市场的霸主地位。但 Java 冷启动开销大,而云原生时代下的应用程序短小,启动频繁,冷启动问题的解决机不容发。

冷启动问题根因及应对

 

下图为典型 Java 应用的生命周期:

 


如图,Java 应用生命周期分为 5 个阶段:VM 初始化阶段、APP 初始化阶段、APP 初活跃阶段、APP 稳定执行期、结束阶段。

 

VM 初始化(图中红色)和 Class loading(图中蓝色)的开销为冷启动的根因。阿里巴巴实现了两类改造:一类为改良型技术,调整优化现有 Java 的框架和运行模型,另外一类为革新型的技术,摆脱原有 Java 框架另起炉灶

EagerAppCDS

改良型技术中,阿里巴巴主要实现了基于传统 CDS(Class Data Sharing)的 EagerAppCDS。传统 CDS 包括 mark、Klass*、fields 三部分,如下图左所示:


怎么让 Java 冷启动提速两个数量级?


Klass*指针指向内存中 class 实例 InstanceKlass,该实例为 bytecode Class Loading 解析后生成的,但在多次解析时内容均不变,因此可固定存储磁盘文件 Shared Archive 中,下次运行时从文件中可省略解析直接读取,实现提速。

 

面对 system class,CDS 可根据 name 快速匹配,但面对 customized class 时,JVM 无法辨认 customized class loader 的身份,因此需在 classpath 上扫描 jar 包,根据 name、crc 校验后方可完成匹配。jar 包即为 libs 包,包含大量 I/O 操作,开销大。EagerAppCDS 用 identity 固定了 customized class loader 的名字,可直接通过 identity+name 匹配找到所需 class。

 

下图为 EagerAppCDS 在阿里巴巴内部实践的脱敏数据,如图所示性能提升效果从 12%~95%不等。


怎么让 Java 冷启动提速两个数量级?


EagerAppCDS 虽未开源但已在阿里云 SAE(Serverless 微服务 PaaS 平台)上线。线上可公开实测数据中应用启动耗时降低 5%~45%,提升效果与启动时加载类数量成正比。


怎么让 Java 冷启动提速两个数量级?


除此之外,我们还实现了以下改进型技术:

  • JWarmup:共享预热后的 code cache,减小 JIT 开销;

  • PGO AOT:提前准备 runtime 信息,指导改进 AOT 代码质量;

  • Class Preinit:类预先初始化,降低运行时初始化类的开销。

Graal VM 静态编译技术

革新型技术中,阿里巴巴采用了基于 Graal VM 的静态编译技术。Graal VM 为 Oracle 主导的基于 Java 的开源高性能多语言平台:C++、Kotlin、python 等多种语言可通过 Truffle 框架运行在 GraalVM 上Java 和其他 JVM 语言(如 Groovy、Kotlin 和 Scala 等)编译成 bytecode 后可直接运行。

 

Substrate VM(SVM)为 Graal VM 的静态编译组件,可将 Java 程序静态编译为可执行文件或共享库文件 Native Image,实现直接编译 Java 代码。

 

Java 最初依靠解释器实现无需编译实时执行;该解释器性能较差,因此引入了 JIT(Just In Time)实时编译技术,将高热度函数送到编译器中编译;为解决编译器开销大的问题,引入了 AOT(Ahead Of Time)编译,提前编译部分代码;AOT 缺乏 runtime 数据,运行后即丧失转为 JIT 的机会,运行速度慢;静态编译技术将 AOT 扩大,彻底摒弃 JVM,由 SVM 提供运行环境。


怎么让 Java 冷启动提速两个数量级?

静态编译基本原理

传统 Java 执行模型如下图左所示:Application(应用本身)在 libs 的支持下运行在 JDK 上在 JVM 中执行。


怎么让 Java 冷启动提速两个数量级?


静态编译在 Graal Compiler 编译器中编译 Application、libs、JDK,同时编译 Substrate VM Runtime,获得 Native Image。Native Image 包含 code(编译后的代码)和 Image heap(存储数据)两部分。Image heap 为运行时 heap 的起点,直接读取 Image heap 可以提高运行时的性能。

 

静态编译必须遵循封闭性原则(the closed-world assumption),即所有运行时信息均需在编译时可见。该原则带来两个基本问题:如何确定封闭的边界?如何处理 Java 的动态特性?

静态分析

Java bytecode 编译为 Native code 时,代码抽象性降低体积增大,如若编译所有代码,Native Image 体积将过于庞大,因此需确定封闭边界。SVM 通过静态分析上实现了从给定入口开始确定程序可达范围的功能。

 

该技术应用广泛,例如 main 函数调用 Virtue call 必须先明确其 type,type 和 Virtue call 有时可唯一绑定,但通常不能唯一绑定。此时使用静态分析技术,可明确 Virtue call type 的可能范围,实现封闭。

怎么让 Java 冷启动提速两个数量级?


受静态分析本身的特性和能力所限,静态分析得到的可达代码集合(蓝色)略大于实际执行代码集合(绿色)。静态分析精度越高、冗余越少、image 越小。

 

静态分析无法分析出 Java 的许多动态特性运行时的行为,如反射、动态代理、JNI、序列化(阿里巴巴贡献,从 21.0 开始支持)、动态类加载(阿里巴巴贡献,patch 已经通过评审)等。此时需提前获取所需信息,方可封闭此类动态特性的触达范围——即需基于配置进行动态特性支持。

 

怎么让 Java 冷启动提速两个数量级?


以反射为例。SVM 提供了 native-image-agent,可记录 APP 运行时所有的反射。编译时只需解析配置文件,即可注册反射目标,扩大编译范围;同时获取反射信息后可放入 ReflectionData 缓存中,将反射调用替换为直接调用。运行时如遇反射可查找 ReflectionData,获取目标值,通过 Method.invoke 直接调用目标函数。

 

下图为通过静态编译和传统 Java 两种方式,分别用反射调用空函数 30 次性能对比测试结果:


怎么让 Java 冷启动提速两个数量级?


由于峰值过高,该图进行了对数修正。传统 Java 编译空函数耗时(深蓝色)为 3000ns,峰值由于反射开销为 4000ns,静态编译后(深蓝色)稳定在 150ns 内。

 

SVM 的静态编译实现的编译优化包括标准优化如:Method inlining, constant folding and arithmetic optimization, loop optimization, partial escape analysis 等。


此外还有因为静态分析而引入的新的编译优化,例如未被标为 final 的 field 通过静态分析发现只读不写,即可当作常量处理,做常量折叠等优化;又如,静态分析出某虚函数 type 唯一绑定,即可优化为直接调用,进而实现 inline;再如,消除部分编译时已知变量类型状态的类型检查和空指针检查。

主要运行时组件

静态编译由于所有的类均已被编译因此只有一个类加载器,实际只执行类查找功能。

 

传统 Java 一边检查异常一边运行,如遇异常直接处理即可。SVM 考虑到在不同平台兼容性,异常处理采用非信号处理机制:检测无错方可正常运行。该检测对性能影响小。

 

此外,静态编译的 GC 为 Oracle 开源版本中的单线程 stop-and-copy 顺序 GC,性能一般。

性能对比-实验室数据

下图为 Graal VM 官方的实验数据:

 

怎么让 Java 冷启动提速两个数量级?


如上图所示,在只执行 Hello world 程序时,Native Image 性能次于 C,与 Go 相当,远快于传统 JDK;内存使用次于 C,只有 Go 的一半,远低于传统 JDK,具有高性能低内存占用的优点。图中红色数据为受测语言数据除以 Native Image 数据所得比值。

性能对比-实际场景数据

Javac 为 Java 编写的编译器:可以在 Java 程序中来调用 API 编译,也可用 stand alone 工具编译。通过 API 调用,实际上已完成 VM 启动,因此两者对比可观察冷启动带来的性能差异。


通过 API 调用 Javac 耗时 250ms,使用 Native Image 后耗时达到 35ms,实现了 1 个数量级的飞跃。

 

Javac 中使用的反射、Serverless 较少,其他项目静态分析性能提升效果更加显著。下图为基于 spring boot 的应用 greeting-service 部署在阿里云函数计算平台上的数据。greeting-service 收到请求会返回“ hallo”,功能简单但需要 spring boot 全流程支持。

 


如图所示,Native Image 静态编译(橙色)相比传统 Java(蓝色):内存占用从 128MB 降至 21MB;实际第一次调用耗时从 454 ms 降至 4.27 ms,提升了两个数量级;阿里云服务计费从 500ms 降至 100ms,事半功倍。

静态编译局限性



静态编译的局限性如上表所示:

  • 为实现封闭性,反射、动态代理、JNI、序列化、动态类加载均需要通过配置支持;

  • 不支持 InvokeDynamic(开发人员使用)、Method Handles(开发人员使用)、Security Manager、多 classloader、Finalizers、过时 Thread 函数(如 Thread.stop())等;

  • Java 程序被静态编译后不再保留 bytecode,因此存在监控、调试方面的问题:不支持 JVMTI、JMX、agent,只能使用 GDB 调试,无法通过 Eclipse IDE、IntelliJ IDEA 等调试。

GraalVM 生态发展

GraalVM 静态编译目前生态如下:

  • 阿里云:通过阿里云函数计算平台进行支持部署 serverless Native Image 应用,通过 Apache RocketMQ 为 C++客户端提供使用静态编译的 Java 共享库;

  • Spring 社区:发布了针对于静态编译 Spring-Native beta 版本,完全支持 Spring 的运算;

  • MICRONAUT:实现了支持 Native Image 的去反射微服务框架;

  • Facebook&Twitter:均在生产环境下使用 Graal 编译器代替 C2 编译器。

 

总之,在 Serverless 场景下 Java 的冷启动问题与应用对快速响应、实时扩展的需求形成突出矛盾。阿里巴巴一方面在现有技术上不断改进,最终形成突破:EagerAppCDS 提升最多 45%的启动速度;另一方面积极参与开源社区探索创新型的前沿技术,打磨成熟用于实践:GraalVM 静态编译技术最多提升百倍启动速度。但 GraalVM 存在兼容性和改造成本的问题,适合新项目。

 

外部链接

Java 应用的生命周期:https://shipilev.net/talks/j1-Oct2011-21682-benchmarking.pdf

Graal VM 平台:https://www.graalvm.org/

实际场景 Graal VM 测试应用源码: https://github.com/micronaut-projects/micronaut-spring/tree/master/examples/greeting-service

实际场景 Graal VM 部署平台: https://www.aliyun.com/product/fc