技术7/9/2026·约 4 分钟

那个 flag 到了一个 JVM,只是不是你以为在炸的那个

CI 的 docker runner 连 8GiB 都申请不下来。两次用 JVM 参数覆盖去修它,每次都带一个「把堆调到 16m 构建必须失败」的检查,两次都过,两次 CI 依旧 OOM。第三次读的是真正被读的那个值。

申请不下来

CI 的 docker runner 上,Android 构建在申请 8GiB 时直接被杀:

os::commit_memory 8589934592 failed

8GiB 是规则集里给 Android 的 Java 工具写的初始堆——而且是初始堆,不是最大堆。初始堆是在进程启动时就向操作系统要的,于是 worker 一起来就把容器的内存预走了,容器给不出来,进程就没了。

有一件事本身就说明了问题:库的单测过,而应用的构建不过。不是因为单测测了什么,是因为它们的 worker 小。这个 bug 从来不在别的环境里。

两次够不着,两次都测了

第一次在早上四点。想法很直接:给 CI 的构建加上 JVM 参数,把初始堆调小、最大堆调一个够用的数。这是一次必要的证伪动作:

把堆调到 16m,构建必须失败

它失败了。所以「flag 确实到达了 worker」这件事被证明了。但它证明的是别的东西。 一次证伪的要点不在于它失败了,而在于你在哪台进程上看到它失败。这里「构建失败」是一个过于宽泛的症状:任何低于这个数的 Java 进程都会失败,而它们的堆比 8g 小得多,于是 CI 的症状恰好被另一个症状盖住了。

第二次在五点。这次诊断具体多了:这些工具跑在 exec 配置里,而不是目标配置里,所以昨天的 flag 够不着它们。换成 exec 配置的 flag,同样的证伪动作,这次看到的失败点名字都对得上,而且它点名了昨天没点名的东西:

把堆调到 16m → 那个工具立刻 OutOfMemoryError
把堆调到 4g  → 构建通过

它通过了、点名了工具、还报告了大小。但第三次尝试的诊断是:命令行 flag 根本管不到 persistent worker。这个诊断如果是对的,那么两个城市的 16m 都只证明了一件事:这个 flag 确实影响到了某个 JVM——只是未必是你出事的那一个。两个城市的结论相同而理由不同,这本身就是它在描述某件别的事的信号。

第三次终于停了猜测。desugar、d8、r8 都是 worker。worker 的 JVM 参数在工具被声明的那一刻就定死了,不在命令行上。于是改声明它的地方:

  • 在工具定义里把 8g 改成新值,四处
  • 以及 SDK 工具自己的定义,也是一处同样的字面量

最后那个地方额外费了些功夫。它在上游的源码里,而 Bazel 的原生 patch 机制对它反复失败;更麻烦的是,本地 patch 要求 diff 格式,而且要对着一份重新取干净的源码生成——之前取过一次失败,把它留在了半 patch 的状态,于是之后每一次 diff 都是漂的。最后的写法是在我们自己控制的那份文件里拷贝一份再替换。

让替换暴露自己

那个替换值得多说一句,因为它是一次静默失败:

if '"-Xms8g"' not in helper or '"-Xmx8g"' not in helper:
    fail("上游不再含这些字面量——降配失效了")

字符串替换匹配不到东西时不会失败,它会成功,什么也不做。所以在上游哪天不再写这几个字面量的那一天,这个替换会安静地变成空操作,而堆会悄悄回到 8g,没有任何人得到通知。上面那三行把一个静默的回归变成了构建期的一次响。

至于那条 patch 本身:四个文件,每处一改。它把「13 处工具共享一个基线」这件事压成了四个文件里各一处——大部分工具引用同一个基线,改那里就够了。

接受哪一次验证

三个 commit 都写了「本地构建绿」,而这条信息一次都没有帮上忙。它当然绿:本地机器内存充足,那个失败根本不在这台机器上。

第一次:本地绿,CI 依然在申请 8GiB 时被杀。

第二次:本地绿,CI 依然在申请 8GiB 时被杀。

第三次:本地绿,CI 不再被杀。

三次的本地结果一模一样。所以「本地绿」在这条故障链上是一个零信息的观察。它的意思不是「修好了」,它的意思是「我的机器没有那个症状」。这两句话的区别,在决定能不能下结论的那一刻,是全部的区别。

在 bug 根本不生活的机器上验证,等于没有验证。要么造一个能复现它的最小条件,要么在出事的那台机器上验证。

而最终可信的那次验证不是构建的结果。构建的结果是二值的,绿了就是绿了,不告诉你任何关于值的事。真正可信的是这个:

两个定义文件里都写着新的堆

因为它读的是那个真正被读到的值。这和「读进程的命令行」是一回事:旋钮有没有被拧动,是问旋钮的位置,不是问它之后有没有做事。

形状

去掉安卓,去掉 Bazel,剩下的东西跨了三个层次:

第一层:flag 影响的是调用,不是声明。 命令行上的参数属于这一次调用。而常驻 worker、后台服务、单例池里的那些东西,它们的参数在被创建的时候就定死了。你能改一次调用的参数,不等于你能改一个常驻进程的参数。要改后者,改创建它的那个声明。

第二层:证伪要落在正确的进程上。 「调小之后必须失败」是必要的,但只有当你在出事的那一个进程上、带着那一个值看到失败时,它才证明了你以为它证明的东西。一次构建失败是一个症状;你还需要知道是哪个进程、在哪个进程上、因为什么原因。你以为的那次成功验证,通常只是另一个东西碰巧也坏了。

第三层:在 bug 不生活的地方验证,等于没验证。 「本地绿」是一个关于症状而不是关于修复的观察。当症状是「在一台内存紧张的机器上申请不到内存」时,开发机永远看不到它,所以本地绿不构成任何证据。要么在受约束的环境里跑,要么直接用出问题的那套 CI。

小结

每次修复前先问:这个症状在我这台机器上存在吗?如果不存在,任何本地的绿灯都只是关于我这台机器的说法,不是关于修复的说法。三个 commit 都记了「本地绿」,而前两个还是红的——所以本地绿一直是一句没有信息的话。

在动手之前,先弄清楚旋钮管到哪一层。命令行参数管每一次调用,而常驻 worker 的参数在被创建时就定死了。够不着的地方,去改创建它的那一行声明。

然后,别用症状来验证旋钮,用旋钮来验证症状。 把它调小,构建失败,这只说明这个值影响了某个进程——而这里影响的进程不是出事的那一个。读那个真正被读到的值:那个定义文件里写着什么。这比任何构建结果都更有说服力,因为它说的就是你拧的那个东西。

最后,别让静默成功溜过去。一处替换在它本该生效的地方不匹配时,什么也不会发生,也不会有人知道。把那种「什么都没发生」变成一次构建期的失败。

继续阅读