本文改自 Alibaba JVM 团队近期录取于 CCF A 类会议 ASE 2021 中的 Industry Showcase 的一篇论文。作者:Alibaba JVM 团队
Java 是最流行的编程语言之一,广泛应用开发大型 Web 应用,例如电商、金融、物流以及娱乐等领域。然而,在云原生和 Serverless 计算的热潮下,Java 由于其启动慢、预热慢的原因,显得格格不入。
Java 程序为什么启动慢和预热慢?
Java 源程序会被首先编译成平台无关的字节码 (bytecode),然后字节码被 Java 虚拟机 (JVM)加载执行,经过一段时间的解释执行和收集运行时信息,最终 JVM 会把值得编译的方法通过即时编译器 (Just-in-time)将其字节码转成性能更好的机器码。具体而言,Java 字节码在 JVM 中主要经过如下执行相关的阶段:
1、动态类加载(Dynamic Class Loading):Java 程序加载的基本单元是类文件。JVM 通过类加载器根据类的名称找寻类对应的二进制文件,之后读取并解析这些文件,在 JVM 内部创建一个元对象,该元对象记录了类相关的一些运行时信息,例如对象大小、对象运行时类型。大多数类加载的时机是程序执行到特定指令(例如创建该类的对象),去解析常量池 (Constant Pool) 中的类型符号时触发的。JVM 将所有常量池中解析到的类存储到一个全局的类型字典中,通过类加载器和类名来查询该词典来判断一个类型是否被已经被加载。
2、解释执行 (Interpretation):Java 字节码首先需要解释执行一段时间,这是由于一些仅发生一次的操作(例如类加载)不适合放置在经过 JIT 高度优化后的机器码里面。常量池中的符号大多会在解释执行时被解析,解析后的符号会被映射到运行时对应的元数据。
3、运行时信息收集 (Profiling):在解释执行的过程中,JVM会收集程序执行的一些信息,例如分支的频率,这些信息会指导后续的即时编译优化。
4、即及时编译 (Just-in-time Compilation):JVM 会把“热”方法,也就是执行频繁的方法,在线的编译成机器码,以加速 Java 方法的执行。由于即时编译发生在程序运行时,考虑到编译本身消耗 CPU 资源,因此即时编译其独有的难点在于确定各个方法编译的时机。
5、退优化 (De-optimization):JVM 会根据收集的运行时信息(例如分支的执行频率,方法调用动态分派处的类型信息)做一些更加激进的投机性优化,这些优化会在生成的代码中引入一些假设(例如假设某个分支不会被执行)。若后续的执行中假设被发现不成立,例如假设不会被执行的分支被执行到了,JVM 会立即退优化到解释器中执行,之后再根据后续的运行情况,重新对该方法进行编译。
然而,Java 开发者饱受 JVM 启动慢以及预热慢的问题。本文针对 Web 应用的场景,对 Java 程序的启动和预热做一个简单的定义:
1、启动慢:Java 程序启动慢的原因主要是由于动态类加载和程序启动时的初始化,Java 程序可能在一次执行中加载成千上万的类。针对一个 Web 应用,我们定义启动是指从 JVM 启动到程序初始化完成能响应第一个请求的阶段。
2、预热慢:Java 程序需要经过一段的解释执行,等待 JIT 将值得编译的方法编译完成,才能达到性能峰值。针对一个 Web 应用,我们定义预热是指从 JVM 启动到程序优化完成、达到性能峰值的阶段。
启动慢会影响程序的响应度,减慢 Web 应用扩容,而预热慢会导致 Web 应用无法及时的处理完用户请求,造成大量请求超时。
Alibaba Dragonwell 中的技术
Alibaba 编译器团队一直探索如何让 Java 在 Serverless 的时代继续绽放光彩。经过多年对 CDS、AOT 以及自研的 JWarmup 技术的研究、改进和打磨,逐渐摸索出一系列先进、实用的实现 Serverless Java 的方案和路线。本文介绍的技术是基于Alibaba Dragonwell 11 开发出的,使用了 JDK 11 的一些特性,例如 AOT。
我们的观察
由于在启动阶段也需要执行 Java 代码来对应用初始化,因此加速预热也能加速启动。针对 Java 程序启动慢和预热慢的问题,我们有如下观察和想法:
1、通过加速动态类加载,优化类初始化方法执行的效率,可以加速 Java 程序的启动。
2、通过缩短收集运行时信息的时间,尽快的将热方法编译,可以加速 Java 程序的预热。
为此,我们基于 Alibaba Dragonwell,实现了一系列技术用于加速 Java 程序的启动和预热。我们观测到,从技术上来讲,JVM 启动和预热慢主要是需要在启动和预热过程中不断的重新创建或者收集一些数据。这些数据在同一个程序的多次执行中存在高度相似性或者某种程度上的等价。若我们可以在一次执行中复用上一次执行的各种数据,那么当前执行就可以避免从无到有的去重新创建和收集这些数据,进而加速 Java 程序的启动和预热。
简单来说,JVM 运行时主要重复创建以下数据:
1、类型元数据 (Class Metadata)
2、程序运行时信息 (Method Profile Data)
3、JIT编译后的机器代码 (JIT Compiled Code)
JVM 的开发者在很早之前就注意到这些问题,开发出一些技术来尝试通过复用 JVM 启动之前的数据来加速 JVM 的启动和预热。例如,Class Data Sharing (CDS) 相关技术用来避免重复创建类元数据,Ahead-of-Time (AOT) 编译技术可以在程序启动时就执行优化后的预编译的机器码,减少在解释器中执行的代码。然而,我们发现,这些技术都是独立开发,相互之间没有很好的互相协作,甚至互相干扰,因此在实际使用中往往无法取得比较好的效果。
图二 Alibaba Dragonwell 中的记录和重放技术的概况
数据记录
提到 JVM 中的数据记录技术,就不得不提 JWarmup。JWarmup 是阿里巴巴自研的技术,从技术实现上来看是一个典型的记录和重放 (record-and-replay)技术,目标是加速 JVM 预热。事实上,记录和重放技术可以大规模运用于现代的应用部署场景。简单了来说,一个应用在正式发布进入生产环境之前会在预发环境中小规模测试观察一段时间,而生产环境和预发环境高度相似,可以假设能够接收相同的流量。
图三 JWarmup 系统概况
JWarmup 的记录模块记录一次 JVM 执行时 JIT 编译的一些核心信息,并写入到文件中,而 JWarmup 的重放则发生在后续的、另外一次 JVM 执行中。JWarmup 的重放模块从文件中读取之前记录的信息,回馈到此次 JVM 的 JIT 中,以便对记录的热点方法提前做出即时编译。值得注意的是,JWarmup,作为一个实现稳定的工具,其输出的信息还可以作为输入方便其他分析工具。
JWarmup 的最新实现采用 Java Flight Recorder (JFR) 来记录数据,而阿里巴巴是 OpenJDK 中 JFR 的活跃维护者,一些 JFR 的补丁也用于增强 JWarmup 事件记录的完整性。此外,由于记录的事件基本发生在程序启动和预热过程中,因此 JWarmup 的记录本身并没有带来可观测的性能影响,甚至可以直接应用于生产环境中记录实时的线上数据。
在 JVM 中提前编译一个方法并没有想象中的那么简单。JIT 要求当前 JVM 执行到一个合理的状态,这样 JIT 才能产生稳定高效的代码。例如,由于 JIT 在编译时不会做动态类加载和类初始化,因此如果一个方法依赖的另一个类没有被加载,则 JIT 无法生成高效的代码,取而代之的是假设该类不会被加载,并插入代码当检测到相关代码被执行时触发退优化。
有的读者可能会提起 JVM 选项 -Xcomp,事实上,该选项是经常被误解的一个选项,其意义是在首次执行一个方法时先编译该方法再执行,而不是完全禁用解释器。实际上由于类加载等原因,-Xcomp 选项会导致大量退优化,相比默认模式会浪费时间在编译和等待编译,因此在启动速度上可能反而不如默认的混合模式。
JWarmup 是一个完全遵从 JVM Specification 的实现,其自身并没有做出一些例如改变程序原有语义的行为。为此,JWarmup 记录每一个被编译的方法需要满足的依赖。JWarmup 记录如下三种依赖:
- 类型依赖 (Type Dependency):类型依赖是由那些访问常量池 (Constant Pool) 中类型信息的指令引入的,例如 invokevirtual 指令。简单来说,同一个类型一旦在其他类的常量池中被解析成功,那么被解析的类就会被存储在全局类型字典中,当前类中的常量池就可以在编译时直接使用。此外,常量池中的符号解析只发生一次。一旦类型被解析成功,那么依赖常量池中该类型的所有指令都不需要再单独解析符号。
- 对象依赖 (Object Dependency):对象依赖是由那些需要在运行时访问常量池中一个运行时对象的指令引入的,例如 invokedynamic。这些指令的特点是需要调用一个 bootstrap 方法来解析对象,不同的指令可能通过不同的 bootstrap 方法访问不同的对象。此外,每个类的常量池都需要自行解析对象依赖,因此 JWarmup 需要将其和类型依赖区分开来,特别的进行记录。
- 记录依赖 (Profiling Dependency):记录依赖指的是程序运行时收集执行信息中的类型,这些类型是指令访问的对象的实际类型,因此不一定存在当前类的常量池中,编译时只需要存在全局的类型字典中即可。
JIT 中的一些优化除了依赖该方法和类本身的状态,还依赖于当前 JVM 的一些全局状态,例如内联 (Inlining) 会根据被调用者是否被编译、编译后代码大小来做内联决策。因此,JWarmup 也记录 JIT 编译过程中的内联树,用于在重放时做出正确的内联决策。
除了记录 JIT 编译过程中的数据,我们还记录运行时被执行到的方法列表,这些方法列表会用于指导 AOT 编译器,避免编译那些不会被执行到的方法。此外,我们还采用 AppCDS 技术记录运行时使用到的类,并将其类的元对象写入到一个二进制 Archive 文件中。
加速应用启动
加速应用启动首先就是开启 JVM 自带的 AppCDS 和 AOT 技术,然而我们发现 AOT 和 AppCDS 没有最大化的利用各自的优点。我们在实际中观测到 AOT 生成的代码有时候会频繁触发对运行时元数据的符号解析,而 AppCDS 可以弥补这个问题。此外,AOT 由于缺少运行时信息,无法做一些投机性的优化,极大的影响了生成的机器代码的性能。
我们针对 AOT,实现了一种类预先解析的技术。该技术的主要是考虑到 AppCDS 是通过内存映射将 Archive 文件加载到内存中的。因此在程序启动时,我们是可以提前知道将来被解析的类的元数据的地址,进而可以将 AOT 代码中对这些元数据的访问提前链接好,避免了在执行中触发 AOT 代码中的符号解析。需要注意的是,考虑到 Java 特有的运行时类型安全问题,我们只针对 builtin 类加载器加载的类做预先解析,这和 CDS 技术的使用的假设条件一致,即假定 builtin 类加载器中的类都是从 Archive 中直接获取。
针对 AOT 编译器缺少运行时信息的缺点,我们利用 JWarmup 记录的运行时信息,提供给 AOT 编译器,并针对 AOT 的特点做了多处优化。这里简单罗列其中两种: 1、虚方法内联:在缺少运行时信息的情况,AOT 编译器无法有效的判定一处动态方法调用处的实际类型,因此无法做出有效的内联决策。在使用 JWarmup 提供的运行时信息后,AOT 编译器可以投机性的做出激进的内联。考虑到 AOT 代码有其自身对类型使用的限制,我们特别的针对 AOT 实现了一种激进内联的代码。 2、类型检测快速路径:同样,在缺少运行时信息时,AOT 编译器无法对类型检查指令处的可能的对象实际类型做出优先级排序,类型检查可能需要经过多重判断才能给出最终的结果。在使用 JWarmup 提供的类型信息之后,我们可以将记录的信息优先进行类型检查比对,以此加速类型检查。 通过提高 AOT 代码的执行效率,我们在既有的 CDS 和 AOT 技术之上,进一步提升了程序启动的速度。
加速应用预热
加速应用预热主要采用的是 JWarmup 的重放功能。在运行时,JWarmup 会监听一系列相关的 JVM 内部事件,例如类加载事件、常量池填充事件。当这些事件发生时,JWarmup 会去查看有没有记录的方法的依赖被完全满足。一旦有这种情况发生,JWarmup 就立即将该方法放进 JIT 的编译队列中。通过判定依赖是否全部满足,JWarmup 可以有效降低提前编译的方法触发退优化的比例,达到有效提前预热的目的。
记录和重放中的类型安全问题
本文着重介绍 Alibaba Dragonwell 中相关技术带来的性能提升,此外我们还在尝试解决一些记录和重放中的类型安全的问题。具体而言,在一个运行的 JVM 中,类型实际是由类名和类加载器的二元组组成。换句话说,JVM 中是可能存在同名但是不同类加载的两个类型,分别对应不同的类元数据。在记录时,我们无法将一个类加载器写入文件,因为类加载是一个 Java 对象,没有标准的序列化标准。因此,实际我们只能记录类型的静态信息,例如类型名称、创建该类型的类文件的校验值。在重放时,我们需要从类的静态文件信息中恢复出正确的类加载器信息。若此时存在同名(或者相同静态信息)的不同类型,则这需要一些技术去解决,此外,AOT 代码中也不适合做类加载,因此当前的 AOT 编译器会对类加载器的使用有一些假设。虽然这些假设绝大部分场景是满足的,但是若无任何机制保证而盲目使用 AOT,则很容易造成线上故障。我们将在今后的文章中重点讨论这些问题。
应用场景和实验评估
对于分布式的 Java 企业级应用,在面临突发流量导致的扩容时,启动慢和预热慢往往会带来难以预料的后果。一方面,当因为 JVM 启动慢导致应用无法及时启动时,应用的响应度造成影响,终端用户无法访问应用,影响终端用户体验。同样,启动慢会导致用户提交的请求响应超时,同样对用户使用体验造成极大影响,最终带来经济上的损失。为此,改进 JVM 的启动和预热速度有着实际的应用需求。我们用实际的 Web 应用来评估 Alibaba Dragonwell 中针对应用启动和预热的改进。
加速启动
我们用 Spring PetClinic 来验证我们的加速启动技术。我们设计五种模式进行比较:
1、Baseline: JVM 默认模式
2、AppCDS: JVM 默认模式和 AppCDS
3、AOT: JVM 默认模式和 AOT
4、AppCDS + AOT: JVM 默认模式和 AppCDS 及 AOT
5、Alibaba Dragonwell: 我们的改进版本的 AOT 和 AppCDS
从表中可见,由于之前讨论的相比解释器 AOT 会触发更多的运行时符号解析,因此 AOT 并不能一定带来启动加速,与 AppCDS 集成在一起时也不能显著带来性能提升。当使用我们改进的 AOT 时,我们可以发现应用启动明显加速,有接近 41.35%的提升。
加速预热
我们采用一个阿里巴巴内部的线上系统来验证 JWarmup 是否可以提前编译,达到加速预热的效果。简单来说,我们通过 JFR 来采集整个 Java 进程的 CPU 消耗,以及编译器进程的 CPU 的消耗。通过 CPU 消耗的峰值变化,来反映当前是否有大量编译请求以及用户请求。
图四 JIT进程CPU消耗
图五 JVM进程CPU消耗
从图四和图五中可以明显看出,当开启 JWarmup 后,无论是编译线程的 CPU 峰值和整个 Java 进程的 CPU 峰值都明显提前。此外,开启 JWarmup 之后,整体的 CPU 消耗明显下降,CPU 峰值短暂发生后迅速回落(100秒之后)。
总结
这篇文章中我们介绍了Alibaba Dragonwell 中的若干用于加速 JVM 中应用启动和预热的问题。总体的解决思路是采用记录和重放技术,同时多种技术协作,各取长短,互相弥补,最终实验证明采用 Alibaba Dragonwell 可以显著加速 JVM 应用的启动和预热。这些技术在实际生产环境下有着广泛的应用场景,尤其可以缓解分布式 Java 企业级应用在扩容中遇到的一些问题。
—— 完 ——
加入龙蜥社群
加入微信群:添加社区助理-龙蜥社区小龙(微信:openanolis_assis),备注【龙蜥】拉你入群;加入钉钉群:扫描下方钉钉群二维码。欢迎开发者/用户加入龙蜥社区(OpenAnolis)交流,共同推进龙蜥社区的发展,一起打造一个活跃的、健康的开源操作系统生态!
关于龙蜥社区
龙蜥社区(OpenAnolis)是由企事业单位、高等院校、科研单位、非营利性组织、个人等按照自愿、平等、开源、协作的基础上组成的非盈利性开源社区。龙蜥社区成立于2020年9月,旨在构建一个开源、中立、开放的Linux上游发行版社区及创新平台。
短期目标是开发龙蜥操作系统(Anolis OS)作为CentOS替代版,重新构建一个兼容国际Linux主流厂商发行版。中长期目标是探索打造一个面向未来的操作系统,建立统一的开源操作系统生态,孵化创新开源项目,繁荣开源生态。
龙蜥OS 8.4已发布,支持x86_64和ARM64架构,完善适配Intel、飞腾、海光、兆芯、鲲鹏芯片,并提供全栈国密支持。
欢迎下载:https://openanolis.cn/download
加入我们,一起打造面向未来的开源操作系统!