深入了解 Gradle 加速编译的方法
目录
简介
编译避免是使 Gradle 构建工具快速且可扩展的众多性能优化之一。Gradle 通过确定编译结果是否相同(即使上游依赖项已更改)来尽可能避免重新编译。
这种情况可以这样说明:如果类 MyApp
和 NumberUtils
在不同的项目中,并且 MyApp
的项目在编译时依赖于 NumberUtils
的项目,那么对 NumberUtils
的任何内部更改都不需要重新编译 MyApp
的项目。在更改前后,MyApp
都编译成相同的字节码,因此 Gradle 可以继续使用它已构建的 MyApp.class
文件。
Gradle 足够智能,可以避免重新编译类,当且仅当以下两个条件为真时:1) 自身的代码没有更改,以及 2) 对其编译所依赖的类的任何更改都是 ABI 兼容的。正如之前在 编译避免博客文章 中讨论的那样,这是增量编译的补充功能,并且无需生成某些其他构建系统中已知的“头 JAR”或“ABI JAR”。
在这篇文章中,我们将比较这两种编译避免方法(使用和不使用头 JAR 的生成),并衡量跳过头 JAR 生成如何使 Gradle 获得卓越的构建性能。
定义 #
在深入性能比较之前,让我们先澄清几个重要的概念。
什么是 ABI? #
ABI 代表“应用程序二进制接口”。
在 Java 中,这意味着库中暴露给消费者的部分,例如大多数公共类、公共方法和公共字段。库的 ABI 不包括方法体、私有类、私有方法或私有字段。
如果库更改了其 ABI,这可能会导致下游使用者的编译失败或运行时错误。如果库的 ABI 在两个版本之间没有更改,我们将这些版本称为“ABI 兼容”或“二进制兼容”。
二进制兼容性的完整定义可以在 Java 语言规范,第 13 章 中找到。
什么是 JAR 文件? #
JAR 文件是一个压缩存档,其中包含库的 .class
和资源文件,使用与 ZIP 文件相同的格式和 .jar
扩展名。
javac 使用 JAR 文件来针对库的 ABI 编译 Java 源代码。
什么是头 JAR? #
头 JAR(也称为“ABI JAR”)是通过获取原始类或源代码集并删除不属于 ABI 的部分而创建的。这意味着头 JAR 的文件内容本身代表了库的 ABI。
什么是编译避免? #
编译避免是构建系统完成的一种优化,旨在避免在输出不会更改的情况下进行昂贵的编译工作。如果输入类的 ABI 保持不变,则可以避免编译。
这是安全的,因为即使库的内部结构正在更改,除非其 ABI 更改,否则仍然无需重新编译未更改的下游使用者。对库实现的内部更改可能会导致消费者在运行时的行为不同,但不能更改消费者编译的结果。
方法比较 #
一些构建系统(例如 Bazel)生成头 JAR 以实现编译避免。另一方面,Gradle 在不生成头 JAR 的情况下进行编译避免。本节比较这两种方法。
使用头 JAR 的构建系统如何实现编译避免? #
构建系统通常依赖于比较先前构建和当前构建之间的确切文件内容,以确定是否应完成工作。如果文件以任何方式更改,则旧的输出将失效,必须重新构建。通过使用头 JAR 作为编译下游项目的输入(而不是包含实现代码(如方法体)的完整 JAR 文件),依赖项中 ABI 兼容的更改将为头 JAR 生成相同的文件内容。这允许构建系统使用文件内容来确定是否应避免编译。
这种方法的缺点是没有更细粒度的避免级别——即使下游项目从未使用过的内部库类中的 ABI 更改也可能导致重新编译,因为头 JAR 的内容仍然会更改。
此外,由于头 JAR 通常不分发,因此这些构建系统必须在首次下载库 JAR 时生成它们,并且通常存储 JAR 的两个副本。
对于本地依赖项,头 JAR 可以与实际编译并行生成,这可以使下游使用者更快地开始编译。在实践中,我们注意到头 JAR 的生成速度比编译慢得多,导致净损失。
Gradle 如何在不使用头 JAR 的情况下实现编译避免? #
Gradle 不仅依赖于文件内容来检测更改,还分析在 编译类路径 上使用的 JAR 和目录。这种分析类似于创建头 JAR 的过程,但 Gradle 没有发出新的 JAR 文件(这需要大量的磁盘 I/O 并占用额外的空间),而是直接检查 ABI 与之前的 ABI。通过这种方式,Gradle 仍然可以获得头 JAR 的好处,而无需额外的成本。
此外,由于 Gradle 具有完整的类分析功能,因此它还确切知道哪些类已更改,并且可以避免重新编译任何不依赖于这些文件的文件,从而比头 JAR 避免更多编译。
Gradle 也不会区别对待本地依赖项和远程依赖项,除了直接在类目录中使用编译结果,而不是将它们打包到 JAR 中。
流程详情 #
假设您有一个项目,该项目构建一个具有 Java 库依赖项1的 Java 应用程序。典型的流程是编辑源文件,然后重新运行应用程序。如果您编辑的源文件位于库中,则构建系统将需要执行以下步骤
虚线依赖项仅存在于 Gradle 中,因为 Bazel 分析本地项目依赖项的源代码,而不是使用库编译的输出,并且还针对头 JAR 进行编译。这意味着 Bazel 的图的两个方面可以完全并行完成,仅在需要运行应用程序时才结合在一起。
如果新的 ABI 与之前的 ABI 相同,则会跳过虚线节点。
即使 Bazel 可以并行完成更多工作,但由于编写新的类文件、将它们压缩到新的 JAR 中以及将其写入磁盘需要大量时间,因此由于生成头 JAR,最终速度会慢得多。
性能比较 #
使用 Gradle Profiler,我们可以捕获来自 Gradle 和 Bazel 的构建计时操作配置文件。有关如何重现这些测量的说明,请参阅 此处使用的实验项目。
我们研究了两种情况。一种涉及 ABI 更改,另一种涉及非 ABI(内部)更改。这些场景旨在突出显示与使用头 JAR 避免不必要的重新编译相关的性能差异,而不是作为 Gradle 和 Bazel 的全面比较。
这些测量是在最新的 Bazel 版本 (6.2.1) 和最新的 Gradle 版本 (8.0+) 在配备 64 GB 内存的 Apple M2 Max MacBook Pro 上使用 macOS 13.4.1 和 Temurin 17.0.7 JDK 执行的。通过将 Gradle Profiler 设置为生成低级 Chrome 跟踪 并解释结果,可以发现 Gradle 特别将构建时间花费在哪里。
项目结构 #
我们的实验使用了合成的 1000 个项目构建,每个项目包含 10 个源文件和项目间依赖项。这是一种比 Gradle 项目更典型的 Bazel 项目结构。对于大型构建的两种构建工具的典型结构方式,存在各种权衡,例如构建系统必须考虑的项目间依赖项的数量随着项目数量的增加而增加。
与 Gradle 不同,Bazel 不在项目级别执行增量编译。在此实验中,只有一小部分 (25%) 项目直接依赖于更改的类。但是,包含此类依赖项的项目在每个类中都直接或传递地使用 Production0
,以最大限度地减少 Gradle 的增量编译优势。
在 project0
中定义的类 Production0
被此构建中四分之一的项目(每个编号可被 4 整除的项目)直接使用。
例如,project4
中的类 Production44
包含代码
private Production0 property0 = new Production0();
public Production0 getProperty0() {
return property0;
}
public void setProperty0(Production0 value) {
property0 = value;
}
private String property1 = property0.getProperty1();
项目间依赖项的概况如下所示
项目和类依赖项的这种模式对于编号更高的项目重复出现。红色项目(编号可被 4 整除)包含诸如 Production44
之类的类,只要 Production0
的 ABI 发生更改,就必须重新编译这些类。图表中标记为黄色的各种其他项目仅传递依赖于 project0
,不应需要重新编译,因为它们没有以任何方式使用更改的 Production0
类型。大多数标记为白色的项目与 project0
没有依赖关系。
相反,当发生非 ABI 更改时,只有 project0
中的 Production0
才需要重新编译。具有直接依赖项的其他项目(例如 project4
)不受影响,因为 project0
的 ABI 保持不变,因此无需重新编译它们。
结果 #
在比较 Bazel 和 Gradle 时,首先立即注意到的是,Bazel 和 Gradle 在处理非 ABI 更改与 ABI 更改时都显示出构建时间的显着改进。但是,在处理 ABI 更改时,Bazel 比 Gradle 慢得多。对于非 ABI 更改,Bazel 实际上比 Gradle 8.0 稍快。但是,最新版本的 Gradle 在非 ABI 更改方面也比 Bazel 快。
在 ABI 更改场景中,Bazel 速度较慢主要是因为它需要为 ABI 更改生成新的头 JAR。由于许多项目直接依赖于包含 ABI 修改的项目,因此在使用 Bazel 构建时,这些项目也需要重新编译其头 JAR。这是因为 Bazel 无法知道 ABI 更改是否影响了它们。
Gradle 可以提取新的 ABI 以验证是否无需进行这些重新编译,而无需生成和打包头 JAR,从而避免了在整体构建时间方面可能非常昂贵的工作。Gradle 的增量编译也提高了 ABI 更改场景中的性能,尽管这在此项目中影响较小。
测量结果还表明,最新的 Gradle 版本有了显着改进,这反映了 Gradle 团队致力于提高性能和可扩展性。影响所测量场景的具体优化如下所述。
Gradle 8.1 中的性能改进 #
由于涉及大量子项目,因此大部分 Gradle 构建时间都花费在配置构建和重新计算 任务依赖关系有向无环图 而不是任务执行上。
在 Gradle 8.1 中,配置缓存 变得稳定。启用配置缓存后,后续调用相同任务将跳过配置阶段的工作,直接进入任务执行,从而加快两种场景下的 Gradle 构建速度。
配置缓存在 8.1 和 8.3 测试中已启用。使用此功能是提高整体构建时间的好方法。
Gradle 8.3 中的进一步性能改进 #
当我们分析 Gradle 执行跟踪以了解此实验的结果时,我们注意到 Gradle 在每次构建时启动新的编译器守护程序所花费的时间比编译所花费的时间不成比例地多。
从 8.3 版本开始,Gradle 通过在构建之间保留 Java 编译器守护程序来重用它们,以避免每次编译都重新支付其启动成本。这可以显着加快某些构建速度,并且无需额外的配置。通过此更改,Gradle 8.3 在上述两种场景中甚至比 Gradle 8.1 更快。
持久编译器守护程序优化在 Gradle 8.3 中为 macOS 和 Linux 默认启用。Windows 支持将在 8.4 版本中推出。
结论 #
编译避免是一项重要的构建性能优化,它通过避免在库的 ABI 没有更改时重新编译下游使用者来加快构建速度。
在这篇博文中,我们讨论了两种编译避免方法:一种是依赖于某些构建系统中使用的头 JAR 的方法,另一种是 Gradle 使用的方法,该方法计算 ABI 并短路头 JAR 的生成。
Gradle 直接分析类路径而不是为每次更改构建头 JAR 的能力意味着它不会花费额外的时间来创建和编译部分类文件并将另一个 JAR 写入磁盘。这种优势随着构建中子项目数量的增加而扩展。
对于我们测量的大型构建,其中包含许多互连的项目,Gradle 的类路径分析和增量编译已经为 ABI 更改场景带来了更快的构建速度。对于非 ABI 场景,Gradle 通过更新的优化(如配置缓存(在 Gradle 8.1 中稳定)和持久编译器守护程序(在 Gradle 8.3 中默认启用))更快。
请务必尽快升级以利用这些改进以及更多功能,并继续构建快乐!
感谢 Tom Tresansky,他是这篇博文的合著者,以及 Stepan Goncharov,他提供了关于 Bazel 的关键见解。
注释 #
-
假设是非 JPMS 构建,这可能会影响某些细节。 ↩