Apache NullAway 0.12 空安全检查实战详解

基于 Ubuntu 22.04 实测 Apache NullAway 0.12 空安全检查实战——注入缺陷代码逐条解读告警、与 Error Prone 协同并利用 Did you mean 修复建议、以 @Nullable 与 @NullUnmarked 处置问题代码并验证豁免方法的运行期 NPE 堆栈,再用 javac 直挂插件复现告警,最后经 javap 反编译与 META-INF/services 注册文件揭示其被 javac 加载的机制。

Apache NullAwayNullAwayApache静态分析Java2026/10/15

一、实战目标与工程准备

本文在第 2 篇《Ubuntu 22.04 配置集成 Apache NullAway 0.12.7 指南》搭建的 Maven 工程之上,走完一条完整的空安全检查实战链路:故意注入缺陷代码让 NullAway 报告问题,逐字解读告警;与 Error Prone 内置检查器协同工作并利用 Did you mean 修复建议;用 @Nullable 与 @NullUnmarked 两种手段处置问题代码,并实证豁免检查的遗留方法在运行期抛出 NullPointerException 的真实堆栈;随后脱离 Maven,用 javac 命令行直接挂载 Error Prone 插件复现同样的告警,途中踩过的三个坑(插件找不到、依赖闭包缺失、模块未开放)全部给出可复现的解法;最后透过 javap 反编译与 META-INF/services 注册文件,看清楚 NullAway 是以何种机制被 javac 加载的。

1.1 演示工程回顾

实验环境为 Ubuntu 22.04(OpenJDK 17.0.20.1、Apache Maven 3.9.9),工程位于 /opt/nullaway-demo,groupId 为 com.example,artifactId 为 nullaway-demo。工程只包含两个源文件,一个代表"新写的、受 NullAway 全量约束的代码",另一个代表"带缺陷的遗留代码"。完整的 pom.xml 配置已在第 2 篇给出,其中与检查行为直接相关的三处为:

  • compilerArgs 中 -Xplugin:ErrorProne -XepOpt:NullAway:AnnotatedPackages=com.example,把 com.example 声明为受检查的注解包;
  • annotationProcessorPaths 引入 error_prone_core 2.36.0 与 nullaway 0.12.7;
  • dependencies 引入 org.jspecify:jspecify:1.0.0,提供 @Nullable、@NonNull、@NullMarked、@NullUnmarked 四个注解。

工程目录结构如下:

/opt/nullaway-demo/
├── pom.xml
├── .mvn/
│   └── jvm.config
└── src/
    └── main/
        └── java/
            └── com/
                └── example/
                    ├── App.java
                    └── LegacyUtil.java

其中 .mvn/jvm.config 存放了十项 --add-exports/--add-opens 参数,让 Maven 进程内的 javac 有权访问 jdk.compiler 模块的内部 API,这是 Error Prone 体系在 JDK 17 上运行的前提,原理在第 2 篇已详细展开。

1.2 两份源文件全文

App.java 代表常规业务代码,其中 firstChar 方法对入参直接调用 isEmpty() 而没有任何判空——这是留给后文验证"运行期异常"的伏笔:

package com.example;

import java.util.Locale;

public class App {
    public static String greet(String name) {
        return "Hello, " + name.toUpperCase(Locale.ROOT) + "!";
    }

    public static String firstChar(String input) {
        if (input.isEmpty()) {
            return "?";
        }
        return input.substring(0, 1);
    }

    public static void main(String[] args) {
        System.out.println(greet("world"));
        System.out.println(LegacyUtil.firstChar(null));
        System.out.println(LegacyUtil.describe(null));
        System.out.println(firstChar("maka"));
    }
}

LegacyUtil.java 则演示 JSpecify 注解的两种用法:firstChar 方法标注 @NullUnmarked,表示"该方法退出 NullAway 的检查范围";describe 方法标注 @Nullable 参数,表示"调用方可以传 null,方法体内部必须自行处理":

package com.example;

import org.jspecify.annotations.NullUnmarked;
import org.jspecify.annotations.Nullable;

public class LegacyUtil {
    // Opt out of NullAway checking for this legacy method.
    @NullUnmarked
    public static String firstChar(String input) {
        if (input.isEmpty()) {
            return "?";
        }
        return input.substring(0, 1);
    }

    // Explicitly nullable parameter: callers may pass null.
    public static String describe(@Nullable String input) {
        if (input == null) {
            return "<empty>";
        }
        return "len=" + input.length();
    }
}

在干净状态下编译该工程,BUILD SUCCESS,没有任何 NullAway 告警。因为 greet("world") 传的是非空字面量,firstChar(null) 调用点位于 @NullUnmarked 豁免方法的调用侧不受约束(注意:NullAway 对"调用被豁免方法"这一动作本身不检查,但把 null 字面量传给一个声明为非空参数的方法仍会被拦截,此处 firstChar 的参数未加注解且方法被 @NullUnmarked 覆盖,所以编译期放行),describe(null) 的参数声明为 @Nullable,传 null 完全合法。这正是"编译通过、运行崩溃"的典型温床,后文第五节会用真实堆栈验证。

二、注入缺陷:让 NullAway 抓住第一个隐患

2.1 构造违反空安全契约的调用

把 main 方法第 18 行的 greet("world") 改为 greet(null)。greet 的参数 name 声明为 String——在 NullAway 的默认模型里,未标注 @Nullable 的参数一律视为 @NonNull,因此传入 null 字面量构成违约。重新编译:

mvn clean compile

输出中的关键片段(照抄自实测回显):

[WARNING] /opt/nullaway-demo/src/main/java/com/example/App.java:[18,34] [NullAway] passing @Nullable parameter 'null' where @NonNull is required
    (see http://t.uber.com/nullaway )
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS

2.2 逐字段解读这条告警

这条告警信息量很大,值得拆开逐项理解:

  • 前缀 [WARNING]:Error Prone 的检查按严重级别分为 ERROR 与 WARNING 两级。NullAway 报告的空安全违约默认是 WARNING 级别,因此构建不会失败,只在日志中留下告警行。工程化实践中通常配合 -Werror(把告警升级为错误)形成硬门禁,第 2 篇已演示过 -Werror 下 BUILD FAILURE 的效果。
  • 文件路径与 [18,34]:告警定位到 App.java 第 18 行第 34 列。第 18 行是 System.out.println(greet(null));,第 34 列正好落在 null 字面量的起始位置,说明检查器精确定位到了"违约的表达式"而非整个语句。
  • [NullAway]:告警来源标识。Error Prone 内置检查的标识是检查器名(如 StringCaseLocaleUsage),而这里显示 NullAway,说明该告警由 NullAway 插件产生,二者在同一份编译日志中可以并行出现。
  • passing @Nullable parameter 'null' where @NonNull is required:一句话说清契约与违约——把可能为空的值传给了要求非空的位置。措辞里的 @Nullable 指的是"null 字面量在类型上属于可空范畴",@NonNull 指 greet 参数的隐式约定。
  • (see http://t.uber.com/nullaway ):官方短链,指向 NullAway 的错误说明页。链接文本末尾的空格是告警模板的原始输出,照抄如实保留。

2.3 为什么是 BUILD SUCCESS 而不是失败

初次接触的团队常在这里困惑:检查器明明发现了问题,为什么构建还绿着?原因是 Error Prone 把"发现缺陷"与"阻断构建"解耦了。默认 WARNING 只提示;想让它真正拦截合并,需要在 compilerArgs 里追加 -Werror。这样做的现实意义在于:团队可以先以告警模式接入,把存量告警清零,再切换为 -Werror 硬门禁,避免接入第一天就面对成百上千条红色报错。本实战为了展示两种形态,既保留了 WARNING 模式的原始回显,也在第 2 篇演示过 -Werror 模式下的失败输出。

三、与内置检查器协同:StringCaseLocaleUsage 实录

3.1 第三方插件与内置检查同场工作

Error Prone 不只是 NullAway 的宿主,它自带上百个内置检查器,同一个编译过程会同时执行。为了验证协同效果,往工程里临时加入一个新类 CaseDemo.java,其中 String.toUpperCase() 未指定 Locale——这是 Error Prone 内置检查 StringCaseLocaleUsage 要抓的问题:

package com.example;

public class CaseDemo {
    public static String shout(String s) {
        return s.toUpperCase();
    }
}

重新编译,日志中出现了两条并列的告警(第一条是上一节注入的 NullAway 告警,第二条是内置检查器的新告警):

[WARNING] /opt/nullaway-demo/src/main/java/com/example/CaseDemo.java:[5,29] [StringCaseLocaleUsage] Specify a `Locale` when calling `String#to{Lower,Upper}Case`. (Note: there are multiple suggested fixes; the third may be most appropriate if you're dealing with ASCII Strings.)
    (see https://errorprone.info/bugpattern/StringCaseLocaleUsage)
  Did you mean 'return s.toUpperCase(Locale.ROOT);' or 'return s.toUpperCase(Locale.getDefault());' or 'return Ascii.toUpperCase(s);'?
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS

3.2 Did you mean 修复建议的解读

第二条告警展示了 Error Prone 生态最具工程价值的特性之一:可执行的修复建议。逐项解读:

  • [StringCaseLocaleUsage]:内置检查器名,可在 errorprone.info 的 bugpattern 索引中按名检索,链接直接给出该模式的确切定义、风险场景与修复示例。
  • 告警正文:要求在调用 String.toUpperCase 或 String.toLowerCase 时显式指定 Locale。其背景是土耳其语等问题 Locale 下大小写转换会产生与直觉不符的结果(例如 i 转大写变成 İ),未显式指定 Locale 的代码在国际化场景中是真实缺陷。
  • Did you mean 后面给出三个候选修复,都是语法上可直接替换的代码片段:
    1. return s.toUpperCase(Locale.ROOT); —— 用 ROOT Locale,行为与 JVM 默认无关,适合机器可读的规范化场景;
    2. return s.toUpperCase(Locale.getDefault()); —— 显式采用系统默认 Locale,语义上等于承认"跟随宿主环境";
    3. return Ascii.toUpperCase(s); —— 引入 Guava 的 Ascii 工具类,仅在确认输入是 ASCII 时使用,最快且无 Locale 陷阱。
  • 告警自带 Note 提示"第三个修复可能最适合 ASCII 字符串场景",这种"建议附带适用条件"的风格让修复决策有据可依。

对 NullAway 使用者而言,这一节的意义在于:接入 Error Prone 平台后,NullAway 的空安全检查与平台内置检查是一次接入、同时生效的,不需要为内置检查额外引入任何依赖或参数。

验证完成后,删除 CaseDemo.java,工程恢复为 App.java 与 LegacyUtil.java 两个源文件的状态,后续章节继续在干净基线上操作。

四、修复路径一:@Nullable 参数与显式判空

4.1 正确的建模方式

NullAway 的哲学不是"消灭 null",而是"让 null 出现在它被声明允许的地方"。LegacyUtil.describe 方法是标准示范:参数显式标注 @Nullable,方法体第一步就处理 null。这种建模下,调用方传 null 是合法的,编译器不会告警;而方法体内 input.length() 之所以不触发告警,是因为 NullAway 的数据流分析认定 input == null 分支已经提前 return,走到该行时 input 必然非空。这就是所谓的流敏感(flow-sensitive)检查:它跟踪每一个判空分支、每一次赋值、每一条提前返回路径,只对"分析之后仍可能为空"的解引用报错。

修复一条 NullAway 告警时的决策顺序通常如此:

  1. 这个位置真的应该接受 null 吗?如果应该,把参数(或返回值、字段)显式标注 @Nullable,并在方法体开头处理空值;
  2. 如果不应该接受 null,那么违约点在调用侧,修改调用方不要传 null,或对调用侧的值先做判空;
  3. 若违约来自确实无法改造的遗留代码,才考虑第五节的 @NullUnmarked 豁免。

4.2 @Nullable 的传染性及其边界

需要正视 @Nullable 的"传染性":一旦某个参数标注 @Nullable,所有下游使用它的代码都必须处理空值;一旦某个方法返回 @Nullable,所有调用点都要判空。很多团队因此担心注解会蔓延成灾。实际经验是:传染只在受检查的包内发生(本工程即 com.example),而 AnnotatedPackages 之外的第三方库代码默认被视作返回非空——NullAway 对外部世界持乐观假设,这既是它低误报的原因,也是使用者要留心的盲区。对确实可能返回 null 的第三方 API,可以写一层薄封装,在封装边界显式声明 @Nullable,把不确定性收敛到一处。

五、修复路径二:@NullUnmarked 豁免的收益与代价

5.1 豁免的语义

LegacyUtil.firstChar 标注了 @NullUnmarked,含义是"这个方法整体退出空安全检查体系":方法体内不检查(input.isEmpty() 直接解引用不报错),调用侧也放宽(把不确定是否为空的值传进来不告警)。它适合两类场景:一是正在迁移的庞大遗留代码库,用 @NullUnmarked 圈出暂不治理的区域,让增量代码全部受检;二是确实依赖隐式契约的老代码,其空值约定文档化成本高于治理成本。

jspecify 1.0.0 的注解家族共四个,能力互补:@Nullable 标记单个元素可空;@NonNull 标记单个元素非空(显式写出时用于覆盖包级默认);@NullMarked 标记一段作用域(包、类)内默认全部非空,未标注 @Nullable 即非空;@NullUnmarked 则相反,把一段作用域从检查中摘除。本工程的 AnnotatedPackages=com.example 实际上让 NullAway 把 com.example 当作 @NullMarked 区域处理。

5.2 代价实证:运行期 NullPointerException

豁免把问题从编译期推迟到了运行期。直接运行编译产物:

java -cp target/classes com.example.App

实测回显如下,程序在第一行输出后即崩溃:

Hello, WORLD!
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.isEmpty()" because "input" is null
        at com.example.LegacyUtil.firstChar(LegacyUtil.java:10)
        at com.example.App.main(App.java:19)

对照源文件行号可以完全对上:App.java 第 19 行正是 System.out.println(LegacyUtil.firstChar(null));,LegacyUtil.java 第 10 行正是 if (input.isEmpty()) {。异常消息 Cannot invoke "String.isEmpty()" because "input" is null 是 JDK 14 引入、JDK 15 转正的 Helpful NullPointerExceptions 特性给出的精准描述——它告诉开发者"哪个变量是 null、在调用什么方法",但前提是程序已经跑到生产环境里炸了一次。

这就是 @NullUnmarked 的完整代价链:编译期静默放行,运行期由 JVM 兜底报错,且报错发生在测试覆盖不到的输入分支时,损失已经造成。因此豁免应当是过渡态而非常态,每个 @NullUnmarked 都值得在代码评审中被追问"什么时候摘掉"。

5.3 describe 与 firstChar 的对照总结

同一份 LegacyUtil 里两个方法构成一组完美对照:describe 用 @Nullable 建模,null 是被允许的输入,运行期安全;firstChar 用 @NullUnmarked 豁免,null 是被忽视的输入,运行期崩溃。两种选择都让编译通过,差别只在风险何时暴露。工程上的结论:能用 @Nullable 建模的,永远不要用 @NullUnmarked 逃避。

六、脱离 Maven:javac 命令行直接挂载插件

6.1 为什么要会手动调用

CI 系统迁移、排查构建插件冲突、或者只想在单个文件上快速验证告警时,脱离 Maven 直接调 javac 是必备技能。手动调用的核心是两件事:把插件 jar 放进 javac 能看到的位置,把 JDK 17 的模块开放参数传给 javac 所在的 JVM。下面的路径全部来自实测,三个坑依次踩过并给出解法。

6.2 第一次尝试:plug-in not found

先用最直觉的命令行——只加 -Xplugin 参数(为节约篇幅,以下命令中模块开放参数的位置用真实参数名表示,完整十项与第 2 篇 .mvn/jvm.config 相同):

javac -XDcompilePolicy=simple --should-stop=ifError=FLOW \
  -Xplugin:'ErrorProne -XepOpt:NullAway:AnnotatedPackages=com.example' \
  -cp jspecify-1.0.0.jar -d /tmp/jc-out \
  src/main/java/com/example/App.java src/main/java/com/example/LegacyUtil.java

实测报错:

warning: [options] --add-opens has no effect at compile time
error: plug-in not found: ErrorProne
1 error
1 warning

两个信息:--add-opens 作为 javac 编译选项被忽略(它是 JVM 参数,不是编译参数,第一条线索);插件找不到,因为 javac 只在处理器路径(-processorpath)上查找插件,此时没有提供任何路径。

6.3 第二次尝试:processorpath 与依赖闭包

把 error_prone_core 与 nullaway 两个 jar 拼进 -processorpath 再试,插件这次被成功定位并加载,但立刻抛出新的异常:

Exception in thread "main" java.lang.NoClassDefFoundError: com/google/errorprone/bugpatterns/BugChecker$MethodInvocationTreeMatcher

原因:error_prone_core 的 4582511 字节 jar 里虽然有 1665 个 class 和 META-INF/services/com.sun.source.util.Plugin 注册文件,但 BugChecker 接口本身在另一个模块 error_prone_check_api 中。Error Prone 是多模块工程,插件运行需要完整依赖闭包。逐个手拼 jar 不可取,正确做法是用 Maven 生成闭包。注意一个实测发现的陷阱:在业务工程里直接执行

mvn dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt

生成的 cp.txt 只包含 dependencies 声明的依赖(本工程只有 jspecify),不含 annotationProcessorPaths 里引用的 error_prone_core 与 nullaway。因此需要单独建一个临时工程来生成闭包,pom 片段如下:

<dependencies>
  <dependency><groupId>com.google.errorprone</groupId><artifactId>error_prone_core</artifactId><version>2.36.0</version></dependency>
  <dependency><groupId>com.uber.nullaway</groupId><artifactId>nullaway</artifactId><version>0.12.7</version></dependency>
  <dependency><groupId>org.jspecify</groupId><artifactId>jspecify</artifactId><version>1.0.0</version></dependency>
</dependencies>

在该临时工程目录执行同样的 build-classpath 命令,得到 2361 字节的 epcp.txt,内含 error_prone_core、error_prone_check_api、nullaway 及其全部传递依赖的路径清单。

6.4 第三次尝试:模块开放要用 -J 前缀

带着完整闭包再次执行,插件加载却抛出:

Exception in thread "main" java.lang.IllegalAccessError: class com.google.errorprone.BaseErrorProneJavaCompiler (in unnamed module @0x1e4a7dd4) cannot access class com.sun.tools.javac.api.BasicJavacTask (in module jdk.compiler) because module jdk.compiler does not export com.sun.tools.javac.api to unnamed module @0x1e4a7dd4
        at com.google.errorprone.BaseErrorProneJavaCompiler.addTaskListener(BaseErrorProneJavaCompiler.java:85)
        at com.google.errorprone.ErrorProneJavacPlugin.init(ErrorProneJavacPlugin.java:34)

这条 IllegalAccessError 把机制暴露得很清楚:写在 javac 命令行上的裸 --add-exports 是编译期选项(影响被编译代码的模块可见性),而插件在 javac 自己的 JVM 里运行,需要的是 JVM 级别的模块开放——javac 启动器要求这类参数加 -J 前缀。这也反过来解释了第 2 篇的两个设计:Maven 进程内编译(fork=false)时模块开放必须写在 .mvn/jvm.config 里,因为那才是 Maven JVM 的参数;若用 fork=true 则由 compilerArgs 传递但实测会踩到插件初始化空指针。手动 javac 的最终可用命令如下(CLASSPATH 变量 CP 的值来自 epcp.txt):

CP=$(cat /tmp/epcp.txt)
javac -J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
  -J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
  -J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \
  -J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED \
  -J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
  -J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \
  -J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
  -J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \
  -J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \
  -J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \
  -XDcompilePolicy=simple --should-stop=ifError=FLOW \
  -processorpath $CP \
  -Xplugin:'ErrorProne -XepOpt:NullAway:AnnotatedPackages=com.example' \
  -cp $CP -d /tmp/jc-out \
  src/main/java/com/example/App.java src/main/java/com/example/LegacyUtil.java

在 App.java 注入 greet(null) 后执行,实测回显:

src/main/java/com/example/App.java:18: warning: [NullAway] passing @Nullable parameter 'null' where @NonNull is required
        System.out.println(greet(null));
                                 ^
    (see http://t.uber.com/nullaway )
1 warning

与 Maven 日志中的同一条告警完全等价,且手动模式的回显更"javac 原生":带源码行与插入符 ^ 指向第 34 列的 null 字面量。验证完毕后把第 18 行改回 greet("world"),工程回到干净基线。

七、编译产物与运行期行为对照

7.1 干净基线的编译回显

把 App.java 恢复后重新编译,实测输出关键片段:

[INFO] Compiling 2 source files with javac [debug target 17] to target/classes
[WARNING] --add-opens has no effect at compile time
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  2.443 s
[INFO] Finished at: 2026-10-14T21:09:15Z
[INFO] ------------------------------------------------------------------------

其中那条 [WARNING] --add-opens has no effect at compile time 是 javac 对 compilerArgs 中两项 --add-opens 的提示:编译阶段它们确实不起作用,真正发挥作用的位置是 .mvn/jvm.config 定义的 Maven JVM 启动参数。之所以仍保留在 compilerArgs 里,是因为它们在 fork=true 场景下会被透传,且对参数做一处维护便于两种模式对照。双源文件 2.443 秒完成编译,其中包含插件初始化与全部检查器执行的开销,对于日常增量开发,这个量级几乎无感。

7.2 三种代码形态的最终行为矩阵

把本次实战覆盖的三种形态汇总成一张行为对照表,这是向团队推广时最有说服力的一页:

代码形态编译期 NullAway运行期适用场景
greet(null) 传 null 给未标注参数WARNING(可升 ERROR 阻断)未执行到(已被拦截)新代码,全部受检
describe(@Nullable String) 判空后使用无告警输出 <empty>,安全明确允许空值的边界
firstChar @NullUnmarked 直接解引用无告警(豁免)NullPointerException 崩溃过渡期遗留代码

表中"未执行到"的含义值得强调:当 -Werror 生效时,含违约调用的代码根本进不了制品库,运行期行为无从谈起;豁免形态则是把风险完整传递给运行期。三种形态在同一工程、同一份配置下共存,正是 NullAway 渐进式治理模型的直接体现。

7.3 运行期对照的补充实验

describe(null) 在运行期返回 <empty> 而不是崩溃,本工程 main 方法中若把 firstChar(null) 一行注释掉再运行,将依次输出 Hello, WORLD!、<empty>、m。这组输出与 firstChar("maka") 返回首字符 m 的行为共同说明:判空建模正确的方法,空输入只是众多合法输入中的一种,走专门的处理分支而已;而未建模的方法,空输入就是一条通向线程终止的路径。

八、机制透视:插件如何被 javac 发现

8.1 服务注册文件

第 2 篇曾遇到一个反直觉现象:Error Prone 已在 annotationProcessorPaths 中声明,日志里也确认加载,却报 invalid flag: -XepPlugin:NullAway。当时给出的结论是 NullAway 0.12.7 以 BugChecker 服务注册机制被自动发现,无需(也不接受)-XepPlugin 参数。本节用实测证据把这件事讲透。

第一步,查看 error_prone_core jar 内的服务注册文件。解包清单显示:

META-INF/services/
META-INF/services/com.sun.source.util.Plugin

这正是 JDK 编译器插件的标准注册方式:javac 启动时扫描处理器路径上所有 jar 的 META-INF/services/com.sun.source.util.Plugin 文件,把其中列出的实现类注册为可用插件。ErrorProneJavacPlugin 类就登记在这个文件里,这就是手动 javac 实验(6.3 节)中"jar 一进 processorpath 插件就能被找到"的机制根源。

第二步,NullAway 自身如何挂进 Error Prone。查看 nullaway jar:

META-INF/services/com.google.errorprone.bugpatterns.BugChecker

该文件中登记了 com.uber.nullaway.NullAway。Error Prone 启动时会加载全部 BugChecker 实现,因此 NullAway 作为"一个实现了二十四类语法树匹配接口的检查器"自动并入检查列表——不是通过 -XepPlugin 显式装载,而是服务发现。这也解释了为什么 -XepPlugin:NullAway 是无效 flag:对该版本的 NullAway 而言这个参数根本不在它的加载路径上。

8.2 javap 证实继承体系

用 javap 直接反编译确认(jar 路径为本地 Maven 仓库中的 nullaway-0.12.7.jar):

javap -classpath nullaway-0.12.7.jar com.uber.nullaway.NullAway

实测输出(节选头部):

Compiled from "NullAway.java"
public class com.uber.nullaway.NullAway extends com.google.errorprone.bugpatterns.BugChecker implements com.google.errorprone.bugpatterns.BugChecker$MethodInvocationTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$AssignmentTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$MemberSelectTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$ArrayAccessTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$ReturnTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$ClassTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$MethodTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$VariableTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$NewClassTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$BinaryTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$UnaryTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$ConditionalExpressionTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$IfTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$WhileLoopTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$ForLoopTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$EnhancedForLoopTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$LambdaExpressionTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$IdentifierTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$MemberReferenceTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$CompoundAssignmentTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$SwitchTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$TypeCastTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$ParameterizedTypeTreeMatcher,com.google.errorprone.bugpatterns.BugChecker$SynchronizedTreeMatcher {

一个类实现了 24 个 TreeMatcher 接口,覆盖方法调用、赋值、成员访问、数组访问、返回语句、三元表达式、各型循环、Lambda、方法引用、switch、类型转换等语法形态——空指针可能出现的每一种语法位置都有一个对应的匹配器在盯着。这也从侧面说明了为什么 --should-stop=ifError=FLOW 是必需参数:这些匹配器依赖数据流分析(FLOW 阶段)产出的空值状态,若编译在流分析前就因错误中止,检查无从进行。

8.3 jspecify 注解包的内容

查看 jspecify-1.0.0.jar 中 org.jspecify.annotations 包的完整内容:

org/jspecify/annotations/
org/jspecify/annotations/NonNull.class
org/jspecify/annotations/NullMarked.class
org/jspecify/annotations/NullUnmarked.class
org/jspecify/annotations/Nullable.class

四个注解类、四个字节数(434、498、488、436 字节)都很小——注解本身只是元数据标记,全部空安全判断逻辑都在 NullAway 的分析器里。这种"注解与检查器分离"的设计让代码可以携带 JSpecify 注解而不强绑定任何检查工具,与 IntelliJ、JUnit 等工具链的空安全模型互认。

九、FAQ

1. NullAway 告警级别是 ERROR 还是 WARNING,如何阻断构建? 默认 WARNING。在 compilerArgs 追加 -Werror 后所有 Error Prone 告警升级为错误,编译失败。如需只对 NullAway 硬阻断而对其他检查保持告警,可用 -Xep:NullAway:ERROR 显式提升该检查器的级别。

2. 告警里 (see http://t.uber.com/nullaway ) 末尾为什么有空格? 这是 NullAway 告警模板的原始输出,实测回显如此,如实保留即可,不影响链接跳转。

3. 把 null 传给 @NullUnmarked 方法为什么不告警,运行却崩了? @NullUnmarked 把方法从检查体系摘除:调用侧与被调侧都不再约束。运行期 JVM 的 Helpful NullPointerExceptions 兜底报错,实测堆栈 Cannot invoke "String.isEmpty()" because "input" is null,行号与源码完全对应。豁免等于把风险从编译期移到生产期。

4. describe 方法 @Nullable 参数为什么方法体里不用再判空注解? NullAway 是流敏感分析:input == null 分支提前 return 后,后续代码路径上 input 被推断为非空,解引用合法。若去掉那个 if 分支,会立刻收到解引用 @Nullable 值的告警。

5. 手动 javac 报 plug-in not found: ErrorProne 怎么解决? 插件只从 -processorpath 查找。且 processorpath 需要完整依赖闭包:error_prone_core 本体不含 BugChecker 接口(在 error_prone_check_api 模块),缺依赖闭包时会在插件初始化阶段抛 NoClassDefFoundError: com/google/errorprone/bugpatterns/BugChecker$MethodInvocationTreeMatcher。推荐用临时 pom + mvn dependency:build-classpath 生成闭包,注意业务工程自身的 build-classpath 只含 dependencies 不含 annotationProcessorPaths 引用的构件。

6. 手动 javac 报 IllegalAccessError cannot access BasicJavacTask 怎么解决? JDK 17 下模块开放参数必须传给 javac 的 JVM,而不是作为编译选项。javac 启动器语法是 -J 前缀:-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED 等,全部十项(8 个 --add-exports 加 2 个 --add-opens)都要加 -J。Maven 进程内编译(fork=false)时对应物就是 .mvn/jvm.config。

7. --add-opens has no effect at compile time 警告要处理吗? 不用。它只是提醒这两项在编译选项位置不起作用,真正生效处在 jvm.config。保留它们是为了在 fork 模式与手动模式间对照参数,删除也不影响检查结果。

8. mvn dependency:build-classpath 生成的文件里为什么没有 nullaway jar? 该命令只解析 dependencies 元素声明的依赖;nullaway 与 error_prone_core 是 annotationProcessorPaths 引用的编译插件构件,不在其中。所以要为生成闭包单建一个把三者都声明为 dependency 的临时 pom。

9. StringCaseLocaleUsage 的 Did you mean 三条建议怎么选? 机器可读的规范化字符串选 Locale.ROOT;跟随用户环境语义的选 Locale.getDefault();确认纯 ASCII 且追求性能的用 Guava 的 Ascii.toUpperCase。三条都是实测回显中的原文建议,可直接复制替换。

10. 第三方库返回值可能为 null,NullAway 却不报错? AnnotatedPackages 之外的代码默认按非空返回处理,这是 NullAway 控制误报的乐观假设。对已知可能返回 null 的第三方 API,写一层显式声明 @Nullable 的封装方法,把检查边界收敛到封装处。

11. 编译一次要多久,插件开销大吗? 实测双源文件工程 mvn clean compile 全程 2.443 秒,其中包含 Error Prone 平台与 NullAway 的加载执行。开销随工程规模线性增长,主干工程通常在秒级增量范围内,CI 全量构建可通过增量编译与模块拆分控制。

12. 遗留工程如何分阶段接入? 第一周只开 WARNING 模式统计告警量(用 AnnotatedPackages 圈定范围);随后按包清零存量告警,能改的加 @Nullable 判空,暂不能改的圈进 @NullUnmarked;存量清零后上 -Werror 硬门禁,只允许增量代码零违约合入。每月复盘 @NullUnmarked 清单,逐步摘除。

十、参考链接