深入探讨 Gradle 如何实现更快的编译

目录

引言

使 Gradle 构建工具快速且可扩展的众多性能优化之一是编译避免。Gradle 通过判断即使上游依赖项已更改,编译结果是否会相同来尽可能避免重新编译。

这种情况可以这样说明:如果类 MyAppNumberUtils 在不同的项目中,并且 MyApp 的项目在编译时依赖于 NumberUtils 的项目,那么对 NumberUtils 的任何内部更改都不需要重新编译 MyApp 的项目。无论更改前后,MyApp 都会编译成相同的字节码,因此 Gradle 可以继续使用它已经构建的 MyApp.class 文件。

A GitHub Gist showing MyApp.java which uses NumberUtils.sum, and a NumberUtils.java.diff which shows a change from an imperative for-loop summation to a Stream-based reduce. On the right, it says "Does MyApp need to be recompiled? No!"

Gradle 足够智能,当且仅当两个条件都满足时才避免重新编译一个类:1)它自己的代码没有改变,2)它编译所依赖的类的任何改变都是 ABI 兼容的。正如之前在《编译避免博客文章》中讨论的,这是一个与增量编译互补的功能,并且无需生成一些其他构建系统所知的“header JAR”或“ABI JAR”。

在这篇文章中,我们比较了两种编译避免方法(有和没有生成 header JAR),并衡量了跳过 header JAR 生成如何让 Gradle 实现卓越的构建性能。

定义 #

在我们深入性能比较之前,让我们澄清几个重要概念。

什么是 ABI? #

ABI 代表“应用程序二进制接口”。

在 Java 中,这意味着库暴露给消费者的部分,例如大多数公共类、公共方法和公共字段。库的 ABI 不包括方法体、私有类、私有方法或私有字段。

如果一个库改变了它的 ABI,这可能会导致下游消费者的编译失败或运行时错误。如果一个库的 ABI 在两个版本之间没有改变,我们称这些版本为“ABI 兼容”或“二进制兼容”。

二进制兼容性的完整定义可以在《Java 语言规范,第 13 章》中找到。

什么是 JAR 文件? #

JAR 文件是一个压缩档案,包含库的 .class 和资源文件,使用与 ZIP 文件相同的格式和 .jar 扩展名。

JAR 文件由 javac 用于针对库的 ABI 编译 Java 源代码。

什么是 Header JAR? #

通过获取原始的类或源文件集并删除不属于 ABI 的部分来创建 Header JAR(也称为“ABI JAR”)。这意味着 Header JAR 的文件内容本身代表了库的 ABI。

什么是编译避免? #

编译避免是构建系统进行的一项优化,旨在避免在输出不会改变的情况下进行昂贵的编译工作。如果输入类的 ABI 保持不变,则可以避免编译。

这是安全的,因为即使库的内部正在更改,也没有必要重新编译未更改的下游消费者,除非其 ABI 发生更改。对库实现的内部更改可能会导致消费者在运行时出现不同的行为,但不能更改消费者编译的结果。

方法比较 #

一些构建系统,如 Bazel,为了实现编译避免而生成 Header JAR。另一方面,Gradle 在不生成 Header JAR 的情况下实现编译避免。本节比较这两种方法。

使用 Header JAR 的构建系统如何实现编译避免? #

构建系统通常依赖于比较上一个构建和当前构建之间的精确文件内容来确定是否应该执行工作。如果文件以任何方式更改,旧的输出将被作废,并且必须重新构建。通过使用 header JAR 作为输入来编译下游项目(而不是包含方法体等实现代码的完整 JAR 文件),依赖项中 ABI 兼容的更改将为 header JAR 生成相同的文件内容。这允许构建系统使用文件内容来确定是否应避免编译。

这种方法的一个缺点是没有更细粒度的避免——即使下游项目从未使用过的内部库类中的 ABI 更改也会导致重新编译,因为 header JAR 的内容仍然会改变。

此外,由于 Header JAR 通常不分发,这些构建系统必须在首次下载库 JAR 时生成它们,并且通常存储 JAR 的两个副本。

对于本地依赖项,Header JAR 可以与实际编译并行生成,这可以使下游消费者更快地开始编译。实际上,我们注意到 Header JAR 的生成比编译慢得多,导致了净损失。

Gradle 如何在没有 Header JAR 的情况下实现编译避免? #

Gradle 不仅依赖文件内容来检测更改,它还分析用于编译类路径的 JAR 和目录。这种分析类似于创建 Header JAR 的过程,但它不生成新的 JAR 文件(这会需要大量的磁盘 I/O 并占用额外空间),而是直接检查 ABI 与之前的 ABI 是否一致。通过这种方式,Gradle 仍然可以获得 Header JAR 的好处,而无需额外的成本。

此外,由于 Gradle 拥有完整的类分析,它还精确地知道哪些类发生了变化,并且可以避免重新编译不依赖于这些类的任何文件,从而比使用 Header JAR 避免了更多的编译。

除了直接在类目录中使用编译结果而不是将它们打包到 JAR 中之外,Gradle 对本地依赖项的处理与远程依赖项没有任何区别。

过程详情 #

想象一下,你有一个构建 Java 应用程序的项目,该应用程序有一个 Java 库依赖1。典型的工作流程是编辑源文件,然后重新运行应用程序。如果你编辑的源文件在库中,那么构建系统将需要执行以下步骤

A graph showing the order of work done by Gradle and Bazel. It shows two execution columns, connected from left to right by dotted arrows. Dashes surround the application-related compilation and JAR steps.

虚线依赖仅存在于 Gradle 中,因为 Bazel 分析本地项目依赖项的源代码而不是使用库编译的输出,并且也针对 Header JAR 进行编译。这意味着此图的两侧可以完全并行地为 Bazel 完成,只有在需要运行应用程序时才结合在一起。

如果新的 ABI 与之前的 ABI 相同,则跳过虚线节点。

尽管 Bazel 可以并行执行更多工作,但由于写入新类文件、将它们压缩成新 JAR 并将其写入磁盘需要大量时间,因此由于 Header JAR 的生成,它最终会慢得多。

性能比较 #

使用 Gradle Profiler,我们可以捕获 Gradle 和 Bazel 的构建时间操作配置文件。有关如何重现这些测量的说明,请参阅此处使用的实验项目

我们研究了两种场景。一种涉及 ABI 变更,另一种涉及非 ABI(内部)变更。这些场景旨在突出与使用 Header JAR 避免不必要的重新编译相关的性能差异,而不是作为 Gradle 和 Bazel 的全面比较。

这些测量是在 Apple M2 Max MacBook Pro(64 GB,运行 macOS 13.4.1,使用 Temurin 17.0.7 JDK)上使用 Bazel 最新可用版本(6.2.1)和 Gradle 近期版本(8.0+)进行的。通过将 Gradle Profiler 设置为生成低级Chrome 跟踪并解释结果,可以发现 Gradle 的构建时间具体花费在哪里。

项目结构 #

我们的实验使用了一个合成的 1000 个项目构建,每个项目有 10 个源文件和项目间依赖。这是一种更典型的 Bazel 项目结构,而不是 Gradle 项目。对于任一构建工具在构建大型项目时的典型结构方式,都存在各种权衡,例如构建系统在项目数量增加时必须考虑的项目间依赖数量。

与 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();

项目间依赖的大致情况如下所示

A graph of the project layout. project3 depends on project0-2, project7 depends on project4-6, project4 depends on project0, and project8 depends on project0. An arrow indicates that the class Production0 will be changed.

这种项目和类依赖模式对于编号较高的项目重复出现。红色项目(编号可被 4 整除)包含诸如 Production44 之类的类,这些类在 Production0 发生 ABI 更改时必须重新编译。图中黄色标记的各种其他项目只传递依赖于 project0,不应需要重新编译,因为它们不以任何方式使用更改后的 Production0 类型。大多数白色标记的项目与 project0 没有依赖关系。

相反,当存在非 ABI 更改时,只有 project0 中的 Production0 需要重新编译。其他具有直接依赖(例如 project4)的项目不受影响,因为 project0 的 ABI 保持不变,因此无需重新编译它们。

结果 #


比较 Bazel 和 Gradle 时,立即引人注目的一点是,在处理非 ABI 更改与 ABI 更改时,Bazel 和 Gradle 都显示出构建时间的显著改进。然而,在处理 ABI 更改时,Bazel 比 Gradle 慢得多。对于非 ABI 更改,Bazel 实际上比 Gradle 8.0 略微。但是,最新版本的 Gradle 在非 ABI 更改方面也比 Bazel 更快。

在 ABI 更改场景中,Bazel 较慢的主要原因是它需要为 ABI 更改生成新的 header JAR。由于许多项目直接依赖于包含 ABI 修改的项目,因此在使用 Bazel 构建时,这些项目也需要重新编译其 header JAR。这是因为 Bazel 无法知道 ABI 更改是否影响了它们。

Gradle 可以提取新的 ABI 以验证无需进行这些重新编译,而无需生成和打包 Header 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 未更改时重新编译下游消费者来加快构建速度。

在这篇博客文章中,我们讨论了两种编译避免方法:一种依赖于某些构建系统中使用的 Header JAR 的方法,以及 Gradle 使用的计算 ABI 并短路 Header JAR 生成的方法。

Gradle 能够直接分析类路径,而不是为每次更改构建一个 Header JAR,这意味着它无需花费额外时间创建和编译部分类文件以及将另一个 JAR 写入磁盘。此优势随着构建中子项目的数量而扩展。

对于我们测量的包含许多互联项目的大型构建,Gradle 的类路径分析和增量编译已经在 ABI 更改场景下实现了更快的构建。对于非 ABI 场景,Gradle 凭借配置缓存(在 Gradle 8.1 中稳定)和持久化编译器守护进程(在 Gradle 8.3 中默认启用)等最新优化而更快。

请务必尽快升级,以利用这些改进及更多,并继续构建幸福!

感谢 Tom Tresansky,他与我共同撰写了这篇博客文章,以及 Stepan Goncharov,他提供了关于 Bazel 的关键见解。

注释 #

  1. 假设是非 JPMS 构建,这可能会影响某些细节。 

讨论