Serverless 时代的 Java 探索 | ASE 2021最新论文解读
凌云时刻
写在前面:本文改自 Alibaba JVM 团队近期录取于 CCF A 类会议 ASE 2021 中的 Industry Showcase 的一篇论文,作者为Alibaba JVM 团队。转载自“OpenAnolis龙蜥”。
Java 程序为什么启动慢和预热慢?
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 程序的启动和预热做一个简单的定义:
启动慢:Java 程序启动慢的原因主要是由于动态类加载和程序启动时的初始化,Java 程序可能在一次执行中加载成千上万的类。针对一个 Web 应用,我们定义启动是指从 JVM 启动到程序初始化完成能响应第一个请求的阶段。
预热慢:Java 程序需要经过一段的解释执行,等待 JIT 将值得编译的方法编译完成,才能达到性能峰值。针对一个 Web 应用,我们定义预热是指从 JVM 启动到程序优化完成、达到性能峰值的阶段。
启动慢会影响程序的响应度,减慢 Web 应用扩容,而预热慢会导致 Web 应用无法及时的处理完用户请求,造成大量请求超时。
Alibaba Dragonwell 中的技术
我们的观察
通过加速动态类加载,优化类初始化方法执行的效率,可以加速 Java 程序的启动。 通过缩短收集运行时信息的时间,尽快的将热方法编译,可以加速 Java 程序的预热。
类型元数据 (Class Metadata) 程序运行时信息 (Method Profile Data) JIT编译后的机器代码 (JIT Compiled Code)
图二 Alibaba Dragonwell 中的记录和重放技术的概况
数据记录
提到 JVM 中的数据记录技术,就不得不提 JWarmup。JWarmup 是阿里巴巴自研的技术,从技术实现上来看是一个典型的记录和重放 (record-and-replay)技术,目标是加速 JVM 预热。事实上,记录和重放技术可以大规模运用于现代的应用部署场景。简单了来说,一个应用在正式发布进入生产环境之前会在预发环境中小规模测试观察一段时间,而生产环境和预发环境高度相似,可以假设能够接收相同的流量。
图三 JWarmup 系统概况
类型依赖 (Type Dependency):类型依赖是由那些访问常量池 (Constant Pool) 中类型信息的指令引入的,例如 invokevirtual 指令。简单来说,同一个类型一旦在其他类的常量池中被解析成功,那么被解析的类就会被存储在全局类型字典中,当前类中的常量池就可以在编译时直接使用。此外,常量池中的符号解析只发生一次。一旦类型被解析成功,那么依赖常量池中该类型的所有指令都不需要再单独解析符号。 对象依赖 (Object Dependency):对象依赖是由那些需要在运行时访问常量池中一个运行时对象的指令引入的,例如 invokedynamic。这些指令的特点是需要调用一个 bootstrap 方法来解析对象,不同的指令可能通过不同的 bootstrap 方法访问不同的对象。此外,每个类的常量池都需要自行解析对象依赖,因此 JWarmup 需要将其和类型依赖区分开来,特别的进行记录。 记录依赖 (Profiling Dependency):记录依赖指的是程序运行时收集执行信息中的类型,这些类型是指令访问的对象的实际类型,因此不一定存在当前类的常量池中,编译时只需要存在全局的类型字典中即可。
加速应用启动
加速应用启动首先就是开启 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,则很容易造成线上故障。我们将在今后的文章中重点讨论这些问题。
应用场景和实验评估
加速启动
加速预热
图五 JVM进程CPU消耗