深入了解 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 兼容的。正如之前在 编译避免博客文章 中所讨论的,这是一个对增量编译的补充功能,并且无需生成一些其他构建系统中已知的“头文件 JAR”或“ABI JAR”。

在这篇文章中,我们将比较这两种编译避免方法(有和没有生成头文件 JAR),并衡量跳过头文件 JAR 生成如何使 Gradle 能够实现更优的构建性能。

定义

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

什么是 ABI?

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

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

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

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

什么是 JAR 文件?

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

JAR 文件由 javac 用于针对库的 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 应用程序。典型的流程是编辑源文件,然后重新运行应用程序。如果您编辑的源文件位于库中,则构建系统需要执行以下步骤

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 分析本地项目依赖项的源代码,而不是使用库编译的输出,并且还针对头文件 JAR 进行编译。这意味着此图的两个方面可以在 Bazel 中完全并行完成,只有在需要运行应用程序时才会合并在一起。

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

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

性能比较

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

我们查看了两种情况。一种涉及 ABI 更改,另一种涉及非 ABI(内部)更改。这些场景旨在突出显示与使用头文件 JAR 避免不必要的重新编译相关的性能差异,而不是作为对 Gradle 和 Bazel 的全面比较。

这些测量是在 Apple M2 Max MacBook Pro 上使用最新的可用 Bazel 版本(6.2.1)和最新版本的 Gradle(8.0+)进行的,该 MacBook Pro 具有 64 GB 内存,运行 macOS 13.4.1,使用 Temurin 17.0.7 JDK。通过将 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 时,立即可以注意到的是,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,以及提供有关 Bazel 的关键见解的Stepan Goncharov

注意

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

讨论