Ohhnews

分类导航

$ cd ..
foojay原文

为什么集群中的每个JVM都要独自预热

#jvm#jit编译#虚拟机预热#java性能#aot缓存

我们生活在一个分布式系统的世界里。我们通过简单地增加同一服务的实例或副本,并在它们之间分配流量来处理更多的流量。

这个服务最终运行在数十、数百或数千个 JVM 上。相同的 JAR,相同的 JDK,相同的实例类型,相同的流量模式。实例 1 冷启动,在最初的几分钟里找出其数千个方法中哪些是热点方法,并针对观察到的流量对它们进行编译优化。实例 1000 在几小时后启动,做着完全相同的事情。从零开始,第 1000 次。

[LOADING...]

这些都不是 bug。这就是 JVM 的工作方式,而且它这样工作有充分的理由:JIT 编译器编译它所观察到的东西,并在观察到它的 JVM 中进行编译。但浪费是真实存在的,而且一个显而易见的问题挥之不去。实例 1 已经完成了这项工作。为什么实例 1000 不能直接采用答案呢?

天真的做法可能是——只需将每个方法的编译代码存储在某个地方,并在下次启动时,将那个“某个地方”提供给 JVM,并恢复已经编译好的代码,而不是即时编译,例如仅通过匹配方法的全限定名。毕竟,它是相同 JAR、相同应用程序、相同流量的副本。这能有多难呢?

在这篇文章中,你将了解到为什么实际上并没有那么简单。我们将涵盖:

  • 编译器实际假设了什么。 JIT 发出的机器码不仅仅是你字节码的翻译。它是对一个特定 JVM 在特定时刻的状态的下注,而速度正是来自于这个下注。
  • 一旦你试图移动它,就会继承的三个问题。
    • 身份。传入的代码到底在谈论这个 JVM 的哪些类、字段和方法?名称并不是你可能认为的那种唯一标识符。
    • 有效性。每个方法有几千条记录的假设,每一条都必须成立,而你必须在远少于自己编译该方法所需的时间内检查完它们。
    • 盈利性。代码可以被证明是正确的,但仍然可能是这个实例的错误代码。

这就是本文的内容。稍后,在第 2 部分中,我们将探讨两种解决方案:Project Leyden 如何回答这个问题,即使用从训练运行构建的称为提前编译(AOT)缓存的本地构件,以及我们为什么选择了另一条路。

编译后的代码是一种条件性产物

为了避免在本节中迷失方向,这里有一个剧透警告:仅仅存储 JIT 编译后的代码,然后试图通过名称天真地将其与相同的方法匹配是行不通的——JIT 编译后的代码只有在与假设一起时才有效,这些假设需要在恢复代码时进行验证,而且——假设有很多。让我们深入探讨具体的例子。

让我们从 JIT 编译器实际做了什么开始,因为“将字节码转换为机器码”只是其中一小部分,而且是这里最不重要的部分。

JIT 编译器观察你的程序运行,然后根据它所看到的内容下注。看看这个:

Codec c = registry.lookup(name);
return c.decode(buf);

如果 Codec 在编译器运行时恰好只有一个已加载的实现,那么就没有理由发出虚拟分派(即根据实际使用的实现来决定要执行的方法)。编译器会将调用去虚拟化,将 decode 的主体直接内联到调用者中,然后应用由于调用边界而之前无法实现的优化——常量折叠、死分支消除、通过内联代码进行寄存器分配等。输出的内容不包含分派,而且很可能完全没有 decode 的可识别痕迹。

关键在于,代码之所以快,是因为所做出的观察或假设(即只观察到一个 Codec 实现),而不是尽管有它们。

现在让应用程序再运行一会儿。一个以之前没人请求过的格式到达的请求,注册表加载了第二个 Codec,假设就被打破了。假设只有一个实现的机器码现在不仅不是最优的,而且是错误的,而且它的错误方式会导致崩溃,而不是在延迟图中显现出来。

JVM 每天都在处理这种情况,而你从未注意到。它在编译方法时记下了假设,所以当第二个类加载时,它会找到受影响的代码,将其丢弃,并回退到解释器,为恰好在该时刻正在该代码内执行的任何线程重建解释器帧。然后它重新编译,这次带有分派。这就是去优化,而该机制之所以存在,正是因为优化后的代码在构造上就是有条件的。

不过,事情是这样的:你的 JVM 已经在做所有这些记录工作了。就在现在,在生产环境中,对于它安装的每一个优化方法。这正是去优化之所以可能的原因,而其背后的依赖跟踪并不是一个小系统。所以“你必须记录编译器的假设”并不是难点,也不是新问题。你的 JVM 一直在这样做。

当代码移动到不同的 JVM 时,改变的不是记录突然变得必要。而是记录不再是私人的自我笔记。在一个 JVM 内部,这些假设是针对同一个 JVM 所控制的世界进行跟踪的,并监视它自己将要做的更改。将它们发送到其他地方,每一个假设都变成了关于这个 JVM 一无所知的进程的声明。

那么记录中到底有什么?这类声明:

  • 类层次关系
  • 字段偏移量和类型
  • 方法字节码和访问标志
  • 常量值
  • 已解析调用的目标

列表的长度部分是一个设计决策。细粒度的记录会产生大量小条目;在第 2 部分中我们将查看的实现中,单个方法就有几千条,而更粗粒度的设计会产生更少的条目。数量并不是有趣的部分。

有趣的是,如果你看那个列表,大部分似乎是静态的。一个类实现了哪些接口,一个字段在对象中的位置,一个方法包含什么字节码:如果两个实例加载了字节完全相同的类文件,那么所有这些都保证是一致的。检查它们开始看起来像是偏执。

它确实会是偏执,但除了一个问题。没有什么告诉接收方 JVM 两个实例确实加载了相同的文件。“它是同一个应用程序”是你从构建和部署流水线中知道的事情。JVM 无法访问这些信息。对类文件进行指纹识别,你可以一次性确定它,然后就不再单独检查那些事实了。

那是一个真实的选择,这使得问题的静态部分,至少在原则上,得到了解决。所以看看剩下什么。

一个常量值在两次运行之间不一定要相同。一个用当前日期和时间初始化的 static final 字段对于能看到它的编译器来说是一个编译时常量,而它所看到的值并不是这个实例所拥有的值。

然后还有一个完全不在那个列表上的,因为它不是代码的属性。它是 JVM 的属性——最好的例子是类初始化状态。一个已经初始化的类在访问它的代码中不需要初始化检查,所以编译器不会发出检查。把那个代码拿到一个类尚未初始化的 JVM 中安装,你就破坏了程序。

相同的 JAR,相同的 JDK,相同的机器类型,相同的流量模式。这些都无济于事。类在接收实例中没有被初始化,因为本应初始化它的路径在那里还没有运行。它是一个更年轻的进程。相同的输入不会给你相同的运行时状态,而且没有任何指纹能告诉你不是这样。

这就是不会消失的部分。而且记录中的每一个条目,无论是否静态,都对一段代码起着承重作用,而这段代码不再包含本可以捕获问题的检查。

总之,仅仅基于方法的全限定名来匹配编译代码的天真做法在到达文件系统之前就已经行不通了。转储代码缓存保存了答案,却丢弃了问题。没有记录其假设的机器码不是缓存条目,而是一个等待类加载器的崩溃。

方法必须改变形态。你移动的不是编译后的代码,而是编译后的代码加上数千条关于世界的声明。而接收方 JVM,我们指的是集群中某个地方新启动的实例,而不是生成代码的那个实例的重启,必须在运行任何该代码之前,对照自身检查每一条声明。这就引出了一个听起来像形式问题、结果却是另一个难点的疑问:在你检查一个关于字段的声明之前,你必须弄清楚这个声明到底在谈论哪个字段。

身份:这个声明到底关于什么?

检查工作分为三个问题,在开始之前值得给它们命名。这个 JVM 能判断出一个声明是关于什么的吗?这个声明在这里是真的吗?如果它是真的,它所属于的代码对这个实例来说实际上有任何好处吗?第一个是没人预料到会成为问题的问题。

记录中的单个条目大致如下:class com.example.Codec 的字段 buffer 位于偏移量 40 处,类型为 byte[]。要检查它,这个 JVM 需要找到它自己的 com.example.Codec 并去查看。按名称查找类是一次方法调用,这是显而易见的做法,但却是错误的做法。

类名在运行中的 JVM 中并不是唯一标识符。一个类由其名称和定义它的类加载器来标识。两个加载器可以各自定义一个名为 com.example.Codec 的类,而它们是两个真正不同的类:不同的字段位于不同的偏移量,不同的方法体。如果你曾经发布过插件系统、在应用服务器中运行过,或者有两个模块各自捆绑了自己版本的同一个库,那么你就曾让同名的类在同一个 JVM 中共存而从未注意到,因为类加载器将它们分开了。

按名称查找并不能回答问题,它只是在猜测。它会很乐意返回错误的 Codec,我们会针对一个与编译器所看到的类毫无关系的类来验证我们的声明,然后那个机器码中的每个字段偏移量都会指向某个看似合理但错误的地方。

所以名称被排除了。还剩下什么?

答案是停止查找,而是开始推导它们。

记录中谈论的所有内容都获得自己的编号,仅在该记录内有意义。类 24。字段 100。方法 201。这些数字不是任何 JVM 中的地址。它们是本地句柄,接收方 JVM 的工作就是逐一弄清每个句柄指向这个进程中的哪个真实事物。

其中一小部分从一开始就是明确的:请求其代码的方法,声明它的类。这些是给定的。其他所有内容都必须从已经标识的东西出发来抵达。

这就是使其工作的部分。检查声明和建立身份是同一个行为。假设记录断言类 24 实现了三个接口,编号为 153、173 和 69。要检查这一点,你必须已经知道活动类 24 是哪个。所以你转到那个类,查看它实际实现的接口,并进行比较。如果它们一致,那么两件事就发生了:声明被验证,而且你现在知道活动接口 153、173 和 69 指的是哪些。以后的声明可以谈论 153,你就会知道它是什么意思。

每一次检查都通过产生新身份来为自己付出代价,而且记录的顺序使得这总是可行:在任何较早的条目确定它之前,不会有任何东西被引用。接收方 JVM 从前到后遍历列表一次。

名称确实出现在这一切中,并且它们做有用的工作。那些接口是按名称匹配的。但看看名称在哪里被使用:不是用来在 JVM 中搜索一个名为 Constable 的类,那是模糊的操作,而是从我们已经标识的类的接口中挑选正确的那个。结构将你带到正确的附近,然后名称才告诉你哪扇门。

这是思考整个方案的有用方式。一个名称本身就是一个邮政地址,而同一个地址存在于一百个城市中。记录给你的反而是方向:从你已经站着的门开始,从那里开始的每一步都锚定在前一步上。

这使得整个过程是一遍完成的:没有搜索,没有探测,没有回溯。这不是优雅的论点。它至关重要,下一节将解释原因:整个操作是在相当紧张的时间预算下运行的。

有效性:全有或全无,在严格预算下

识别声明指的是什么只是工作的前半部分。检查分为两个不同的阶段:首先是解析,即上一节描述的所有内容,然后是验证,即当你真正拿到东西时发生的事情。

验证是概念上无聊的一半。你找到了字段。记录说偏移量 40,类型 byte[]。看看你自己的字段:偏移量 40,类型 byte[]?那么这个声明成立。下一个。

有趣的是它所遵循的标准。接受标准是绝对的:每个依赖都必须解析,并且每个解析后的依赖都必须匹配,然后才能应用编译。不是大多数,不是看起来重要的那些。是全部。

这听起来像是偏执,直到你想起我们之前建立的内容。优化代码中的错误假设不是性能退化,而是正确性失败,而编译器已经删除了本可以捕获它的检查。不存在大部分有效的编译方法。你不能安装 90% 并解释其余部分。要么整个方法,要么没有,这意味着几千个条目中有一个失败就会丢弃整个编译。

现在计算工作量。一个方法有几千个条目。一个启动实例需要大量的方法,因为这就是全部意义所在。所以接收方 JVM 正在执行数百万次单独的检查,并且它在启动期间进行,而启动已经是一个进程生命中最拥挤、最不愉快的时刻:类加载、应用程序自身的初始化运行、CPU 配额比以往任何时候都更紧张。

而这里有一个塑造整个设计的约束。只有当验证一个方法比在本地编译该方法显著更便宜时,这一切才有意义。如果两种成本相当,你就构建了一个更慢的 JIT。所以验证不仅需要正确,而且需要在与优化编译相比可以忽略不计的成本下正确。

这就是为什么上一节中的推导方案看起来是那样的。没有按名称搜索类,没有哈希查找,没有探测,没有重试,没有回溯。记录按依赖顺序到达,JVM 从前到后遍历它们一次,每一步要么恰好落在一个实体上,要么失败。一个必须搜索正确类的设计将是完全正确的,但也完全无用。

失败,当它到来时,也必须是便宜的。记录没有完全检查通过的方法会被简单地丢弃,JVM 会像往常一样编译它。这就是使开启它安全的原因:最坏的情况不是崩溃,也不是慢路径,只是一个普通的 JVM 在做普通的 JIT 编译。你失去了那一个方法的领先优势,仅此而已。

到此为止的一切都是关于不要出错。这就留下了一个结果证明是微妙的问题:代码可以完全检查通过,每一条声明都验证了,但仍然可能是要安装的错误代码。## 收益性:正确的代码,却仍是错误的代码

到目前为止,一切都是为了不犯错。验证回答的是“这段代码会不会出错?”,而且它的回答是绝对的:每一条断言都成立,否则代码就会被丢弃。

还有第二个问题,它完全没有回答。这段代码在这里会 快 吗?

它们看起来像是同一个问题,其实不是,因为编译后的代码携带两种截然不同的假设。有些是关于 JVM 结构的事实:这个字段位于这个偏移量,这个类有这个父类,这个方法有这个字节码。这些是可以检查的。你去看一看,答案就是是或否。

另一些则不是关于结构的事实,而是 关于行为的统计信息。这个分支有 98% 的时间被走到。这个调用点只见过一种接收者类型。这个循环通常运行 12 次。这条路径从未被执行过,所以不要在上面浪费优化精力。编译器使用这些观察结果的积极程度,和使用结构事实时一样,而生成代码的速度也同样严重依赖它们。

尴尬之处在于:没有什么可以用来检验它们。一个刚启动的 JVM 没有性能画像。它还没有运行过任何东西。这正是它想要别人的编译代码的全部原因。因此,你可以把记录中的每一条结构断言验证到最后一个条目,却仍然完全无法知道那些行为假设是否成立,因为唯一能告诉你答案的,正是你想要跳过的执行过程。

拿前面那个调用点来说。在编译它的那个实例中,decode 只接收过一种类型,所以编译器为这种类型生成了一条带守卫的快速路径,并为其他类型准备了慢速回退路径。现在把这段代码安装到一个实例中,而该实例的第一批流量恰好命中了另一种编解码器。每次调用都无法通过守卫,只能走回退路径。代码完全正确。它也比普通 JIT 为这个实例生成的代码更慢,因为普通 JIT 会观察这批流量,并针对它实际看到的情况进行优化。

你可能会合理地反驳:相同的实例处理相同的流量,应该产生相同的性能画像。在稳态下,大体如此。但收益性是在实例尚未进入稳态的窗口内评判的。一个全新实例看到的最初请求是健康检查、连接建立、缓存预热、模式加载,或者负载均衡器碰巧先路由过来的任何东西。生产者的性能画像来自一小时的实时流量。消费者的最初几秒并不是那一小时的样本,而最初这几秒正是所安装代码发挥作用的时候。

还有一点不对称,在于出错所付出的代价。一条验证失败的断言几乎不产生成本:扔掉代码,正常编译,只损失抢先起步的优势。而一段通过验证却根本不匹配的代码,已经被安装并正在运行,而且它会一直运行下去,直到有什么东西注意到并替换掉它。

OpenJDK 在 JEP 544 中坦率地谈到了这一点。它指出,AOT 代码和 JIT 代码可能不同,因为训练运行和生产运行可能不同,并用一句值得记住的话收尾:在运行时,编译器可以生成 JIT 代码来替换那些“未能优雅老去”的 AOT 代码。它明确的假设是,缓存代码有利于相似的生产运行,“同时不会对差异较大的生产运行造成伤害,后者可以使用 JIT 编译来生成不同的代码”。

这是一个合理的论点,而且它依赖于 JVM 一直拥有的机制。但它没有告诉你的是,从安装不匹配的代码到运行匹配的代码之间,窗口有多长。

所以问题不只是“这段代码在这里有效吗”。而是“我可能发送的代码中,哪些才真正值得发送”。这是一个预测问题,而不是验证问题;与验证不同,预测没有绝对的接受标准可以躲在后头。

接下来是什么

这就是如实陈述的问题:三个问题,其中只有两个有可以检查的答案。

在第 2 部分中,我们会具体讨论两条出路。首先是 Project Leyden,它通过把工作提前到时间上更早的阶段,而不是转移到另一台机器,规避了这里的大部分问题,但代价是所能生成的代码质量。然后是我们自己的答案,它走的是另一条路:把已编译代码从正在处理生产流量的一群 JVM 中流式传输到仍在启动的实例中。这种方法无法把这三个问题中的任何一个绕过去,这正是它花了这么长时间才构建出来的原因。我们会逐一说明它实际如何处理这三个问题,以及它能给你带来什么。

分享此页面

发现错误,或有补充?在 GitHub 上编辑此页

[LOADING...]
作者

Jiří Holuša

Jiří 最初是一名热爱性能的质量工程师。他曾在 Red Hat(Infinispan)从事低延迟数据网格工作,后来加入 Hazelcast,并在那里转到了产品管理的“黑暗面”。深入技术战壕的渴望驱使他……

相关文章

Java 核心 [LOADING...]
Simon Ritter 2025年10月13日 4,471 次浏览

当 10,000 个 JVM 协作时会发生什么

Java 核心

Java [LOADING...]
María Arias de Reyna Domínguez 2026年3月17日 3,884 次浏览

Leyden 如何提升 Java 性能?第 1 部分(共 3 部分)

Java

Java 核心 [LOADING...]
Frank Delporte 2023年3月9日 9,402 次浏览

Java 性能:提前编译与即时编译

Java 核心

CRaC [LOADING...]
Frank Delporte 2025年6月6日 3,891 次浏览

更快的 Java 预热:CRaC 与 ReadyNow 对比

CRaC

DevOps [LOADING...]
2 位作者 2023年4月1日 7,088 次浏览

分析与调优预热:Azul Zulu Prime 构建的 OpenJDK

DevOps

参与讨论

Java 核心 [LOADING...]
Simon Ritter 2025年10月13日 4,471 次浏览

当 10,000 个 JVM 协作时会发生什么

Java 核心

Java [LOADING...]
María Arias de Reyna Domínguez 2026年3月17日 3,884 次浏览

Leyden 如何提升 Java 性能?第 1 部分(共 3 部分)

Java

Java 核心 [LOADING...]
Frank Delporte 2023年3月9日 9,402 次浏览

Java 性能:提前编译与即时编译

Java 核心

CRaC [LOADING...]
Frank Delporte 2025年6月6日 3,891 次浏览

更快的 Java 预热:CRaC 与 ReadyNow 对比

CRaC

DevOps [LOADING...]
2 位作者 2023年4月1日 7,088 次浏览

分析与调优预热:Azul Zulu Prime 构建的 OpenJDK

DevOps