- 我们开发了一种名为 Nullsafe 的新静态分析工具,Meta 使用它来检测 Java 代码中的 NullPointerException (NPE) 错误。
- 与遗留代码的互操作性和逐步部署模型是 Nullsafe 广泛采用的关键,并使我们能够在数百万行代码库中的其他 null 不安全语言的上下文中恢复一些 null 安全属性。
- Nullsafe 帮助显着减少了 NPE 错误的总数并提高了开发人员的工作效率。这显示了静态分析在大规模解决现实世界问题中的价值。
Null 取消引用是 Java 中常见的编程错误类型。在 Android 上,NullPointerException (NPE) 错误是 Google Play 上应用程序崩溃的最大原因。由于 Java 不提供表达和检查空不变量的工具,开发人员不得不依靠测试和 动态分析 来提高代码的可靠性。这些技术是必不可少的,但在信号发送时间和覆盖范围方面有其自身的局限性。
2019 年,我们启动了一个名为0NPE的项目,目标是在我们的应用程序中解决这一挑战,并通过静态分析显着提高 Java 代码的空安全性。
在两年的时间里,我们开发了 Nullsafe,这是一种用于检测 Java 中 NPE 错误的静态分析器,将其集成到核心开发人员工作流程中,并进行了大规模代码转换,使数百万行 Java 代码符合 Nullsafe 标准。
图 1:随时间变化的空安全代码百分比(大约)。
以Meta 最大的 Android 应用程序之一 Instagram 为例,我们观察到在 18 个月的代码转换期间,生产 NPE 崩溃减少了 27%。此外,NPE 不再是导致 alpha 和 beta 通道崩溃的主要原因,这直接反映了开发人员体验和开发速度的改善。
null的问题
空指针因导致程序错误而臭名昭著。即使是像下面这样的一小段代码,也可能会以多种方式出错:
清单 1 : 错误 的 getParentName 方法
Path getParentName(Path path) {
return path.getParent().get File Name();
}
- getParent ()可能会产生null并在getParentName(…)中 本地 引发NullPointerException 。
- getFileName ()可能会返回null ,这可能会进一步传播并在其他地方导致崩溃。
前者相对容易发现和调试,但后者可能具有挑战性——尤其是随着代码库的增长和发展。
在像上面这样的玩具示例中,找出值的空值和发现潜在问题很容易,但在数百万行代码的规模上变得非常困难。然后每天添加数千个代码更改使得无法手动确保没有任何单个更改会导致其他组件中的NullPointerException 。结果,用户遭受崩溃,应用程序开发人员需要花费过多的精力来跟踪值的无效性。
Java 和 nullness
为了应对这些挑战,Java 8 引入了java.util.Optional<T>类。但它的性能影响和遗留 API 兼容性问题意味着Optional不能用作可空引用的通用替代品。
同时,注释已成功用作语言扩展点。特别是,将@Nullable和@NotNull等注释添加到常规可为 null 的引用类型是一种可行的方式来扩展具有显式 nullness 的 Java 类型,同时避免Optional的缺点。但是,这种方法需要外部检查器。
清单 1 中代码的注释版本可能如下所示:
清单 2 : 正确且带注释的 getParentName 方法
// (2) (1)
@Nullable Path getParentName(Path path) {
Path parent = path.getParent(); // (3)
return parent != null ? parent.getFileName() : null;
// (4)
}
与空安全但未注释的版本相比,此代码在返回类型上添加了一个注释。这里有几点值得注意:
- 未注释的类型被认为是不可空的 。此约定大大减少了注释负担,但仅适用于第一方代码。
- 返回类型被标记为@Nullable ,因为该方法可以返回null 。
- 局部 变量parent没有注释,因为它的 空性必须 由静态分析检查器推断出来。这进一步减少了注释负担。
- 检查一个值是否为null 将其类型细化 为在相应的分支中不可为 null。这称为 流敏感类型, 它允许以惯用的方式编写代码并仅在真正需要的地方处理 空值 。
注释为 nullness 的代码可以静态检查 null 安全性。分析器可以保护代码库免受回归,并允许开发人员自信地更快地移动。
Kotlin 和 nullness
Kotlin是一种现代编程语言,旨在与 Java 进行互操作。在 Kotlin 中,类型中的 nullness 是显式的,编译器会检查代码是否正确处理了 nullness,从而为开发人员提供即时反馈。
我们认识到这些优势,事实上,在 Meta 中大量使用 Kotlin。但我们也认识到这样一个事实,即有许多业务关键型 Java 代码不能——有时也不应该——在一夜之间迁移到 Kotlin。
Java 和 Kotlin 这两种语言必须共存,这意味着仍然需要针对 Java 的空安全解决方案。
大规模无效检查的静态分析
Meta 成功构建了其他静态分析工具,例如Infer、Hack和Flow并将它们应用于现实世界的代码库,这让我们相信我们可以为 Java 构建一个 nullness 检查器,它是:
- 符合人体工程学: 理解代码中的控制流,不需要开发人员竭尽全力使他们的代码符合要求,并增加最少的注释负担。
- 可扩展: 能够从数百行代码扩展到数百万行。
- 与 Kotlin 兼容: 实现无缝互操作性。
回想起来,实现静态分析检查器本身可能是比较容易的部分。真正的努力是将此检查器与开发基础设施集成,与开发人员社区合作,然后使数百万行生产 Java 代码成为 null-safe。
我们将第一个版本的 Java 空值检查器作为 Infer 的一部分来实现,它是一个很好的基础。后来,我们转向了基于编译器的基础架构。与编译器更紧密地集成使我们能够提高分析的准确性并简化与开发工具的集成。
分析器的第二个版本称为 Nullsafe,我们将在下面介绍它。
引擎盖下的空检查
Java 编译器 API 是通过JSR-199引入的。该 API 允许访问已编译程序的编译器内部表示,并允许在编译过程的不同阶段添加自定义功能。我们使用此 API 通过运行 Nullsafe 分析的额外通道来扩展 Java 的类型检查,然后收集并报告空性错误。
分析中使用的两个主要数据结构是抽象语法树 (AST) 和 控制流图 ( CFG )。有关示例,请参见清单 3 以及图 2 和图 3。
- AST 表示源代码的句法结构,没有标点符号等多余细节。我们通过编译器 API 获得程序的 AST,以及类型和注释信息。
- CFG 是一段代码的流程图:用箭头连接的指令块表示控制流的变化。我们正在使用Dataflow库为给定的 AST 构建 CFG。
分析本身分为两个阶段:
- 类型推断 阶段负责找出各种代码片段的无效性,回答如下问题: 此方法调用能否在程序点 X 处 返回null ? 这个变量在程序点 Y可以为 空吗?
- 类型检查 阶段负责验证代码没有做任何不安全的事情,例如取消引用可为 null 的值或在不期望的地方传递可为 null 的参数。
清单 3 : 示例 getOrDefault 方法
String getOrDefault(@Nullable String str, String defaultValue) {
if (str == null) { return defaultValue; }
return str;
}
图 2:清单 3 中代码的 CFG。
图 3:清单 3 中代码的 AST
类型推断阶段
Nullsafe 根据代码的 CFG 进行类型推断。推理的结果是在不同的程序点从表达式到空扩展类型的映射。
state = expression x program point → nullness – 扩展类型
推理引擎遍历CFG并根据分析规则 执行 每条指令。 对于清单 3 中的程序,它看起来像这样:
- 我们从<entry>点 的映射开始:{str → @Nullable String, defaultValue → String}。
- 当我们执行比较str == null时,控制流分裂,我们产生两个映射:THEN: { str → @Nullable String, defaultValue → String } 。否则:{ str → String , defaultValue → String } 。
- 当控制流加入时,推理引擎需要生成一个映射,该映射过度逼近两个分支中的状态。如果我们在一个分支中有@Nullable String而在另一个分支中有String ,则过度近似的类型将是@Nullable String 。
图 4:带有分析结果的 CFG
使用 CFG 进行推理的主要好处是它允许我们使分析流敏感,这对于像这样的分析在实践中有用是至关重要的。
上面的示例演示了一个非常常见的情况,其中根据控制流改进了值的空值。为了适应现实世界的 编码 模式,Nullsafe 支持更高级的功能,从我们使用 SAT 求解的契约和复杂不变量到过程间对象初始化分析。但是,对这些功能的讨论超出了本文的范围。
类型检查阶段
Nullsafe 根据程序的 AST 进行类型检查。通过遍历 AST,我们可以将源代码中指定的信息与推理步骤的结果进行比较。
在清单 3 的示例中,当我们访问return str节点时,我们获取str表达式的推断类型(恰好是String ),并检查该类型是否与声明为String的方法的返回类型兼容。
图 5:在 AST 遍历期间检查类型。
当我们看到对应于对象取消引用的 AST 节点时,我们检查接收器的推断类型是否排除了null 。以类似的方式处理隐式拆箱。对于方法调用节点,我们检查参数的推断类型是否与方法的声明类型兼容。等等。
总的来说,类型检查阶段比类型推断阶段简单得多。这里的一个重要方面是错误呈现,我们需要使用上下文来增加类型错误,例如类型跟踪、代码来源和潜在的快速修复。
支持仿制药的挑战
上面给出的 nullness 分析示例仅涵盖了所谓的根 nullness,或值本身的 nullness。 泛型 为语言增加了一个全新的表现力维度,同样,可以扩展空值分析以支持泛型和参数化类,从而进一步提高 API 的表现力和精度。
支持泛型显然是一件好事。但额外的表现力是有代价的。特别是,类型推断变得更加复杂。
考虑一个参数化类Map<K, List<Pair<V1, V2>>> 。在 非通用 nullness 检查器的情况下,只有根 nullness 可以推断:
// NON-GENERIC CASE
␣ Map<K, List<Pair<V1, V2>>
// ^
// --- Only the root nullness needs to be inferred
一般情况需要 在已经很复杂的流量敏感分析之上填补更多空白:
// GENERIC CASE
␣ Map<␣ K, ␣ List<␣ Pair<␣ V1, ␣ V2>>
// ^ ^ ^ ^ ^ ^
// -----|----|------|------|------|--- All these need to be inferred
这还不是全部。分析推断出的泛型类型必须严格遵循Java 本身推断出的类型 的形状,以避免伪造错误。 例如,考虑以下代码片段:
interface Animal {}
class Cat implements Animal {}
class Dog implements Animal {}
void targetType(@Nullable Cat catMaybe) {
List<@Nullable Animal> animalsMaybe = List.of(catMaybe);
}
List.<T>of(T…)是一种通用方法,孤立地List.of(catMaybe)的类型可以推断为List<@Nullable Cat> 。这会产生问题,因为 Java 中的泛型是不变的,这意味着List<Animal>与List<Cat>不兼容并且赋值会产生错误。
此代码类型检查的原因是 Java 编译器知道赋值目标的类型,并使用此信息来调整类型推理引擎在赋值上下文(或相关方法参数)中的工作方式。此功能称为 目标类型 ,虽然它改进了使用泛型的人体工程学,但它不能很好地与我们之前描述的那种基于前向 CFG 的分析一起使用,并且需要格外小心处理。
除了上述之外,Java 编译器本身也存在缺陷(例如this),需要在 Nullsafe 和其他使用类型注释的静态分析工具中采取各种变通办法。
尽管存在这些挑战,我们仍然看到 支持仿制药的巨大价值 。尤其是:
- 改进的人体工程学 。如果不支持泛型,开发人员就无法以空感知的方式定义和使用某些 API:从集合和功能接口到流。他们被迫绕过无效检查器,这会损害可靠性并强化坏习惯。我们在代码库中发现许多地方缺少 null-safe 泛型导致 脆弱的代码和错误 。
- 更安全的 Kotlin 互操作性 。Meta 是 Kotlin 的重度用户,支持泛型的 nullness 分析缩小了两种语言之间的差距,显着 提高了互操作的安全性 和异构代码库中的开发体验。
处理遗留和第三方代码
从概念上讲,Nullsafe 执行的静态分析向 Java 添加了一组新的语义规则,试图将 null-safety 改进为其他 null-unsafe 语言。理想情况是所有代码都遵循这些规则,在这种情况下,分析器提出的诊断是相关且可操作的。现实情况是,有很多对新规则一无所知的 null 安全代码,而且还有更多的 null 不安全代码。对此类遗留代码或什至调用遗留组件的新代码运行分析会产生过多的噪音,这会增加摩擦并破坏分析器的价值。
为了在 Nullsafe 中处理这个问题,我们将代码分为三层:
- 第 1 层:Nullsafe 兼容代码。 这包括标记为@Nullsafe并检查没有错误的第一方代码。这还包括已知良好的带注释的第三方代码或我们为其添加了无效性模型的第三方代码。
- 第 2 层:不符合 Nullsafe 的第一方代码。 这是编写的内部代码,没有考虑明确的 nullness 跟踪。Nullsafe 乐观地检查了这段代码。
- 第 3 层:未经审查的第三方代码。 这是 Nullsafe 一无所知的第三方代码。使用此类代码时,会悲观地检查用途,并敦促开发人员添加适当的空性模型。
这个分层系统的重要方面是,当 Nullsafe 类型检查调用 Tier Y代码的 Tier X 代码时,它使用 Tier Y 的规则。尤其是:
- 从第 1 层到第 2 层的调用被乐观地检查,
- 悲观地检查从第 1 层到第 3 层的调用,
- 根据第 1 层组件的无效性检查从第 2 层到第 1 层的调用。
这里有两点值得注意:
- 根据 A 点,第 1 层代码可能具有不安全的依赖项或不安全地使用的安全依赖项。这种不健全是我们在代码库中简化和逐步推出和采用 Nullsafe 所必须付出的代价。我们尝试了其他方法,但额外的摩擦使它们极难扩展。好消息是,随着更多的第 2 层代码迁移到第 1 层代码,这一点变得不再那么令人担忧。
- 对第三方代码的悲观处理(B 点)增加了无效检查器采用的额外阻力。但根据我们的经验,成本并不高,而第 1 层和第 3 层代码互操作性的安全性提高是真实的。
图 6:三层空值安全规则。
部署、自动化和采用
单独的空值检查器不足以产生真正的影响。检查器的效果与符合此检查器的代码量成正比。因此,迁移策略、开发人员采用和防止回归成为主要关注点。
我们发现三个要点对我们的计划取得成功至关重要:
- 快速修复 非常有用。代码库充满了琐碎的空安全违规。教授静态分析不仅可以检查错误,还可以提出快速修复,可以涵盖很多领域,并为开发人员提供进行有意义的修复的空间。
- 开发人员的采用 是关键。这意味着检查器和相关工具应该与主要开发工具很好地集成:构建工具、 IDE 、 CLI 和 CI。但更重要的是,应用程序和静态分析开发人员之间应该有一个有效的反馈循环。
- 数据和指标 对于保持势头很重要。了解您所在的位置、您取得的进展以及接下来要解决的最佳问题确实有助于促进迁移。
长期可靠性影响
例如,查看 Instagram Android 应用程序 18 个月的可靠性数据:
- 应用代码中符合 Nullsafe 的部分从 3% 增长到 90%。
- 所有发布渠道中NullPointerException (NPE) 错误的相对数量显着减少(参见图 7)。特别是在生产中,NPE 的数量减少了 27%。
此数据针对其他类型的崩溃进行了验证,并显示了应用程序的可靠性和空安全性方面的真正改进。
同时,个别产品团队还报告说,在解决了 Nullsafe 报告的无效错误后,NPE 崩溃的数量显着减少。
生产 NPE 的下降因团队而异, 改善幅度 从 35% 到 80%不等 。
结果的一个特别有趣的方面是 alpha 通道 中 NPE 的急剧下降 。这直接反映了使用和依赖 nullness 检查器带来的开发人员工作效率的提高。
我们的北极星目标和理想场景是完全消除 NPE。然而,现实世界的可靠性是复杂的,并且有更多因素在起作用:
- 实际上,仍然存在不安全的空代码,这导致了大部分顶级 NPE 崩溃。但现在我们所处的位置是,有针对性的零安全改进可以产生重大而持久的影响。
- 崩溃的数量并不是衡量可靠性改进的最佳指标,因为进入生产环境的一个错误可能会变得非常严重,并单枪匹马地扭曲结果。一个更好的指标可能是每个版本新的独特崩溃的数量,我们看到了 n 倍的改进。
- 并非所有 NPE 崩溃都是由应用程序代码中的错误单独引起的。客户端和服务器之间的不匹配是生产问题的另一个主要来源,需要通过其他方式解决。
- 静态分析本身具有局限性和不合理的假设,从而导致某些错误进入生产环境。
重要的是要注意,这是 数百名工程师使用 Nullsafe 来提高其代码安全性以及 其他可靠性举措的效果的 总和,因此我们不能将改进仅归因于 Nullsafe 的使用。然而,根据过去几年的报告和我们自己的观察,我们相信 Nullsafe 在减少与 NPE 相关的崩溃方面发挥了重要作用。
图 7:按发布渠道划分的 NPE 崩溃百分比。
超越元
上面列出的问题几乎不是 Meta 特有的。意想不到的 null – dereferences 在不同的公司引起了无数的问题。像 C# 这样的语言演变成在它们的类型系统中具有显式的 nullness,而其他语言,如 Kotlin,从一开始就有它。
谈到 Java,从JSR-305开始,曾多次尝试添加空性,但没有一次取得广泛成功。目前,有许多很棒的 Java 静态分析工具可以检查 nullness,包括 CheckerFramework、SpotBugs、ErrorProne 和 NullAway 等。特别是, Uber 通过使用 NullAway 检查器使他们的 Android 代码库 null-safe走上了同样的道路。但最终,所有检查员都以不同且微妙地不兼容的方式执行无效性分析。缺乏具有精确语义的标准注解限制了整个行业对 Java 静态分析的使用。
这个问题正是JSpecify 工作组旨在解决的问题。JSpecify 始于 2019 年,是代表 谷歌 、 JetBrains 、 优步 、 甲骨文 等公司的个人之间的合作。自 2019 年底以来,Meta 也已成为 JSpecify 的一部分。
尽管nullness 标准尚未最终确定,但规范本身和工具方面已经取得了很大进展,很快就会有更多令人兴奋的公告发布。参与 JSpecify 也影响了我们在 Meta 对 Java 的 nullness 和我们自己的代码库演变的看法。
作者:Artem Pianykh、Ilya Zorin、Dmitry Lyubarskiy
出处: