Ubuntu 22.04 配置集成 Apache NullAway 0.12.7 指南

在 Ubuntu 22.04 上从零配置 JDK 17 与 Maven,并完整集成 NullAway 0.12.7 到 Maven 工程的实操指南,含模块开放参数、插件选项与真实排障记录。

Apache NullAwayNullAwayApache安装部署Ubuntu 22.04Java2026/10/15

Ubuntu 22.04 配置集成 Apache NullAway 0.12.7 指南

状态提示:NullAway 处于 Apache 孵化器孵化阶段。其实用安装路径是 Maven Central 上的 com.uber.nullaway:nullaway:0.12.7(Apache 官方发行渠道尚未建立)。本文全部命令、配置与回显均来自 Ubuntu 22.04 服务器实测(JDK 17.0.20 + Maven 3.9.9 + Error Prone 2.36.0)。

一、集成目标与总体思路

在一台 Ubuntu 22.04 服务器上,从零完成:

  1. JDK 17 与 Maven 3.9.9 安装;
  2. Maven 阿里云仓库加速配置;
  3. 新建演示工程,把 NullAway 0.12.7 挂接到 maven-compiler-plugin(annotationProcessorPaths + Error Prone 插件参数);
  4. 验证:缺陷代码产生 [NullAway] 告警、修复后编译通过、@NullUnmarked 豁免生效;
  5. 沉淀三类真实排障记录与完整卸载方法。

NullAway 不是独立 CLI 工具,没有"安装"环节——它是编译器插件,所有安装动作都围绕"把 jar 放进注解处理器路径 + 给 javac 正确的参数"展开。

1.1 为什么选编译期检查这条路线

在 NullAway 出现之前,团队对抗空指针通常有三条路,各有明显短板。第一条路靠运行期测试兜底:单元测试、集成测试写得再密,也只能覆盖被想到的输入组合,而空值恰恰擅长从没被想到的分支钻进来;且问题暴露时制品已经发布,修复成本最高。第二条路靠字节码工具在运行期织入检查逻辑,例如各类断言增强框架:这类方案把开销转移到了每一次生产请求上,还要求团队维护一套与业务无关的增强配置。第三条路靠 IDE 的静态提示:IntelliJ 的数据流检查确实强大,但它随个人环境浮动,告警无法进入流水线门禁,"我这边没报错"与"流水线报错"之间没有可审计的一致性。

编译期检查把防线前移到 javac 内部:检查发生在每一次编译里,CI 流水线天然继承;零运行时开销,制品字节码与不挂检查时完全一致;告警以编译输出形式存在,可以被 -Werror 升级为硬门禁。代价是检查必须足够快、误报必须足够低,否则开发者会想方设法绕开——这正是 NullAway 的设计目标:针对注解标注的包做低误报、高速度的检查,用 JSpecify 注解把"哪些值可能为空"显式建模。理解了这个定位,本文后面每一个配置项的动机都会变得直观。

1.2 本文的五个里程碑

全文按以下顺序推进,每一环都有可照抄的命令与实测回显:系统准备(第二章)安装 JDK 17 与 Maven 并配置仓库加速,为编译环境打底;创建演示工程(第三章)给出一份经过实测校准的完整 pom.xml,重点是模块开放参数、插件参数与双类路径的正确摆放;首次编译验证(第四章)故意注入两处缺陷,拿到 NullAway 与 Error Prone 内置检查的原始告警,再用 -Werror 演示硬门禁形态;修复与豁免(第五章)演示 @Nullable 显式建模与 @NullUnmarked 圈出遗留代码两种手段,并实测豁免代码在运行期的真实下场;排障与卸载(第六、八章)沉淀四个真实故障的现场与解法。跟着走完,得到的不是"能跑的演示",而是一套可直接套用到业务仓库的接入模板。

二、系统准备

2.1 安装 JDK 17

apt-get update
apt-get install -y openjdk-17-jdk-headless curl wget unzip
java -version

实测回显:

openjdk version "17.0.20.1" 2026-08-18
OpenJDK Runtime Environment (build 17.0.20.1+0-Ubuntu-122.04)
OpenJDK 64-Bit Server VM (build 17.0.20.1+0-Ubuntu-122.04, mixed mode, sharing)

三条回显各有信息量:openjdk version 17.0.20.1 是 Ubuntu 22.04 安全更新通道内的当前版本(随补丁滚动,读者执行时小版本号可能更新,属正常现象);build 串尾部的 Ubuntu-122.04 标识这是发行版打包的构建而非 Oracle 上游包;mixed mode, sharing 表示热点编译与类数据共享均启用,是正常的运行姿态。openjdk-17-jdk-headless 相比完整包省去了图形界面相关依赖,服务器场景足够——javac、jar、javadoc 等开发工具都在 headless 包内。若机器上还装有其他版本的 JDK,可用 update-alternatives --config java 核对默认指向,保证 mvn -version 输出中的 Java version 与预期一致,这一点在多 JDK 服务器上尤其值得验证,因为 Error Prone 2.36 明确要求 JDK 17+。

2.2 安装 Maven 3.9.9

Ubuntu 22.04 apt 源中的 Maven 版本较老,用 Apache 官方归档二进制包:

cd /opt
curl -sSL -o maven.tar.gz https://archive.apache.org/dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz
tar -xzf maven.tar.gz
mv apache-maven-3.9.9 maven
ln -sf /opt/maven/bin/mvn /usr/local/bin/mvn
mvn -version | head -3

实测回显:

Apache Maven 3.9.9 (8e8579a9e76f9d8ed1367c56369f8e2f8f8c0df1)
Maven home: /opt/maven
Java version: 17.0.20.1, vendor: Ubuntu, runtime: /usr/lib/jvm/java-17-openjdk-amd64

mvn -version 的三行输出构成一次自检:Maven home 指向 /opt/maven,说明软链生效、走的是官方二进制而非发行版包;Java version 行证明 Maven 正运行在 17.0.20.1 上,且 runtime 路径指向系统 JDK——这决定了 .mvn/jvm.config 的模块开放参数将作用于这个 JVM。选择 archive.apache.org 的归档包而非 apt 源,除了版本新旧,还有可复现性考量:归档包的版本号永远固定,团队所有成员与 CI 拿到的是字节级一致的构建工具;apt 源里的版本则随发行版策略变化。下载后建议用发布目录随附的校验文件核对摘要再解压,这是供应链安全的基本动作。

2.3 配置阿里云 Maven 仓库加速

编辑(新建)/root/.m2/settings.xml:

<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">
  <mirrors>
    <mirror>
      <id>aliyunmaven</id>
      <mirrorOf>central</mirrorOf>
      <name>Aliyun Maven Central Mirror</name>
      <url>https://maven.aliyun.com/repository/public</url>
    </mirror>
  </mirrors>
</settings>

首次编译时 error_prone_core 及其传递依赖(数十个 jar)会经此仓库下载。 mirrorOf=central 表示只拦截中央仓库的请求,业务自建的私服或其他仓库不受影响——这是生产工程推荐的写法,避免把所有仓库流量都劫持到一个外部地址。工作原理一句话说清:Maven 解析依赖时把对 central 的下载请求重写到 mirror 的 url 上,构件坐标不变,本地仓库的落盘路径也不变,因此 mirror 的切换对构建结果完全透明。error_prone_core 自身连同 Guava、数据流分析框架等传递依赖共数十个 jar,国内网络直连 central 时这些下载往往是首次编译的主要耗时来源,配置加速后首次构建的等待时间会明显缩短。

三、创建演示工程

3.1 工程结构

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

这个最小结构是刻意选择的:两个源文件足以覆盖本文要验证的全部行为——App.java 负责制造并修复告警,LegacyUtil.java 负责演示注解建模与豁免。pom.xml 与 .mvn/jvm.config 是接入 NullAway 仅有的两处工程级改动,也意味着把这套配置移植到任何现有 Maven 工程时,改动面就是这两份文件加若干注解,不涉及源码结构重组。

3.2 编写 pom.xml(完整可用版本)

这是实测通过的全部配置,要点随后逐条解释:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>nullaway-demo</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>

  <properties>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <errorprone.version>2.36.0</errorprone.version>
    <nullaway.version>0.12.7</nullaway.version>
  </properties>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.13.0</version>
        <configuration>
          <fork>false</fork>
          <showWarnings>true</showWarnings>
          <compilerArgs combine.children="append">
            <arg>-XDcompilePolicy=simple</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
            <arg>--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
            <arg>--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
            <arg>--should-stop=ifError=FLOW</arg>
            <arg>-Xplugin:ErrorProne -XepOpt:NullAway:AnnotatedPackages=com.example</arg>
            <arg>-Xmaxwarns</arg><arg>100</arg>
          </compilerArgs>
          <annotationProcessorPaths>
            <path>
              <groupId>com.google.errorprone</groupId>
              <artifactId>error_prone_core</artifactId>
              <version>${errorprone.version}</version>
            </path>
            <path>
              <groupId>com.uber.nullaway</groupId>
              <artifactId>nullaway</artifactId>
              <version>${nullaway.version}</version>
            </path>
          </annotationProcessorPaths>
        </configuration>
      </plugin>
    </plugins>
  </build>
  <dependencies>
    <dependency>
      <groupId>org.jspecify</groupId>
      <artifactId>jspecify</artifactId>
      <version>1.0.0</version>
    </dependency>
  </dependencies>
</project>

配置要点逐条说明:

  • fork=false + .mvn/jvm.config:Error Prone 在 JDK 16+ 上要求 javac 进程开放 jdk.compiler 的内部包。fork=false 时编译在 Maven 的 JVM 进程内进行,把模块开放参数放在工程根目录的 .mvn/jvm.config 中(见 3.4 节),Maven 启动时自动应用,进程内 javac 直接继承;
  • --add-exports 十项:compilerArgs 中保留同样十项,让 javac 作为编译参数亦持有一份(javac 17 接受 --add-exports 参数;--add-opens 在编译参数中会提示 has no effect,无害,也可从 compilerArgs 中去掉只留 jvm.config);
  • --should-stop=ifError=FLOW:Error Prone 2.36 的要求,保证在出错时仍推进到数据流分析阶段(缺失时插件会明确报错提示补上);
  • -Xplugin 合并参数:Error Prone 插件名与其选项必须在同一个 -Xplugin 参数中(以空格分隔)。把 -XepOpt 拆成独立 javac flag 会得到 invalid flag 报错(见排障实录二);
  • annotationProcessorPaths:error_prone_core 与 nullaway 都在这里,nullaway 通过 ServiceLoader(BugChecker 服务)被 Error Prone 自动发现;
  • jspecify 依赖:注解要参与编译,必须在 <dependencies> 中(只在 annotationProcessorPaths 里时编译期类路径没有它们,代码 import 会报 package org.jspecify.annotations does not exist);
  • -Werror:迁移期建议先不加(本文配置用 -Xmaxwarns 100 显示告警);存量清零后再加,阻断新增问题。

3.3 双类路径模型:理解配置的关键

上面这份 pom 之所以能把每一行都写对,靠的是一个清晰的心智模型:javac 运行时同时面对两条相互隔离的类路径。第一条是编译类路径,由 <dependencies> 提供,被编译的业务代码能看到这里的所有类型——jspecify 注解必须在这里,因为 App.java 与 LegacyUtil.java 要 import 它们。第二条是处理器类路径,由 <annotationProcessorPaths> 提供,编译器插件与注解处理器只从这里加载——error_prone_core 与 nullaway 必须在这里,它们是编译器的一部分而不是业务代码的一部分。

两条路径的分工对应一个实用的判断口诀:源码里 import 的进 dependencies,编译器运行时加载的进 annotationProcessorPaths。混放的后果是具体的:把 error_prone_core 放进 dependencies,它的几百个类会顺着编译类路径泄漏进业务制品的编译期可见范围,还可能与工程已有的 Guava 版本冲突;把 jspecify 只放进 annotationProcessorPaths,则会出现排障实录四中 package org.jspecify.annotations does not exist 的编译错误。maven-compiler-plugin 3.13.0 会把 annotationProcessorPaths 声明的构件连同其传递依赖自动拼成 -processorpath 参数,这正是它比手写 javac 参数省心的地方。

3.4 编写 .mvn/jvm.config

3.3 编写 .mvn/jvm.config

在工程根目录建 .mvn/jvm.config 文件(Maven 自动读取并追加给自身 JVM):

--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED
--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED
--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED
--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED

3.4 编写示例代码

带空安全缺陷的首版 src/main/java/com/example/App.java:

package com.example;

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

    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(firstChar(null));
    }
}

四、首次编译与验证

4.1 触发检查

cd /opt/nullaway-demo
mvn compile

首次编译会从阿里云仓库拉取 error_prone_core、nullaway 等依赖,随后输出实测告警:

[INFO] Compiling 1 source file with javac [debug target 17] to target/classes
[WARNING] /opt/nullaway-demo/src/main/java/com/example/App.java:[5,44] [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 "Hello, " + name.toUpperCase(Locale.ROOT) + "!";' or 'return "Hello, " + name.toUpperCase(Locale.getDefault()) + "!";' or 'return "Hello, " + Ascii.toUpperCase(name) + "!";'?
[WARNING] /opt/nullaway-demo/src/main/java/com/example/App.java:[17,38] [NullAway] passing @Nullable parameter 'null' where @NonNull is required
    (see http://t.uber.com/nullaway )
[INFO] BUILD SUCCESS

两个告警的含义:

  • 第 5 行 44 列:Error Prone 自带的 StringCaseLocaleUsage 检查(toUpperCase 未指定 Locale);
  • 第 17 行 38 列:NullAway 核心检查——firstChar(null) 把 null 传给了默认非空的 String 参数。

两处告警的行列定位精度值得注意:StringCaseLocaleUsage 指向第 5 行第 44 列,正好落在 toUpperCase 的调用点;NullAway 指向第 17 行第 38 列,正好落在 null 字面量上。检查器定位的是"违约表达式"而非整条语句,这让 IDE 点击告警即可跳到修改点。另一个值得注意的现象是首次编译就同时出现两个来源的告警——Error Prone 平台的内置检查与 NullAway 插件没有先后或开关关系,一次编译全部执行,这在 3.2 节的配置里没有出现任何"启用内置检查"的参数,因为内置检查默认就在。

[INFO] Compiling 1 source file 的计数说明此时工程只有 App.java 一个源文件,LegacyUtil.java 要到第五章才登场。首次编译耗时主要花在从仓库加速地址拉取 error_prone_core 的传递依赖上,之后 clean compile 都在秒级完成,检查器对日常构建节奏的影响实测可以忽略。

4.2 加 -Werror 阻断

把 pom.xml 中的 -Xmaxwarns/100 两项换成 -Werror 再编译:

[ERROR] warnings found and -Werror specified
[INFO] BUILD FAILURE

告警升级为编译失败。建议团队在存量告警清零后固定使用该形态。 注意 -Werror 的作用范围是"所有 javac 告警"而不只 NullAway:包括 StringCaseLocaleUsage 在内的内置检查告警、以及普通的编译告警都会一并升级。这通常正是门禁想要的语义——要么全绿合入,要么全部修完再合入,避免"只对某些告警严格"造成的灰色地带。若团队确需分级管控,可用 -Xep:NullAway:ERROR 把单个检查器升到错误级而保持全局 WARNING,两种粒度按团队治理成熟度选择。实测中从告警形态切到 -Werror 形态,只需替换 compilerArgs 里的一项参数,无需改动工程其他部分,这个低成本切换点也是"先告警后门禁"渐进策略可行的前提。

五、修复与豁免

5.1 修复代码

针对两个告警的修复:toUpperCase 加 Locale.ROOT;main 中不再传 null。同时新增两个演示方法(修复后的完整 App.java 与新增 LegacyUtil.java):

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(firstChar("maka"));
    }
}
cd /opt/nullaway-demo
rm -rf target
mvn compile

实测回显:

[INFO] Compiling 1 source file with javac [debug target 17] to target/classes
[INFO] BUILD SUCCESS

告警清零。

5.2 用 @NullUnmarked 豁免旧代码

现实中大量旧方法不能立刻修复,用 @NullUnmarked(org.jspecify.annotations)标注豁免。新增 src/main/java/com/example/LegacyUtil.java:

package com.example;

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

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

    public static String describe(@Nullable String input) {
        if (input == null) {
            return "<empty>";
        }
        return "len=" + input.length();
    }
}

App.main 中改为调用 LegacyUtil.firstChar(null)。编译:

[INFO] Compiling 2 source files with javac [debug target 17] to target/classes
[INFO] BUILD SUCCESS

@NullUnmarked 方法传 null 不再告警,@Nullable 参数的 null 传参同样合法。

5.3 豁免不等于安全

@NullUnmarked 只是关闭编译期检查。运行编译产物验证:

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)

被豁免的方法运行时照常崩溃——豁免的责任边界要写进团队规范。

5.4 一手观察:流敏感分析如何放过 describe

describe 方法体内 input.length() 之所以零告警,是 NullAway 流敏感分析的直接体现:编译器沿着控制流图推进,记录每个程序点上每个引用的空值状态。进入方法时 input 的状态是 @Nullable(参数注解声明);执行到 if (input == null) { return "<empty>"; } 的出口后,null 可能性已被该分支吸收,此后的 input 在分析器眼中是确定的非空值,length() 调用合法。这个推断不需要任何额外注解标注,是分析器自带的推理。

同一套推理也解释了修复后 App 的 firstChar:虽然参数未标注 @Nullable,但所有调用点传的都是确定非空的值(字面量 "maka"),检查器从调用侧汇聚的实参状态推断形参非空,方法体内的 isEmpty() 同样无告警。换言之,NullAway 的告警永远出现在"分析器证明不了非空"的位置,而不是"没有注解"的位置——这也是它误报率低到可以常开的根本原因。给团队做培训时,用 describe 与 firstChar 这对方法演示正向推断与反向约束,比任何抽象讲解都有效。

六、排障实录

6.1 实录一:JDK 17 模块开放参数不到位

现象:编译报出 Error Prone 初始化堆栈(BaseErrorProneJavaCompiler.addTaskListener → ErrorProneJavacPlugin.init),BUILD FAILURE。原因与对策:JDK 16+ 强封装下 Error Prone 必须能访问 jdk.compiler 内部包。把 3.3 节的十项参数放进 .mvn/jvm.config(进程内编译方案),编译恢复。注意 maven-compiler-plugin 的 fork=true 模式下,compilerArgs 中的 --add-exports/--add-opens 需带 -J 前缀才能传给 fork 的 JVM,且部分版本组合下插件初始化仍可能异常;进程内(fork=false)+ jvm.config 是实测最稳的组合。 补充一个工程化细节:jvm.config 的生效范围是"执行 mvn 命令时所在目录的 .mvn 目录",Maven 启动脚本会自动读取并追加这些参数给自身 JVM。因此它应该提交进版本库、随工程走——团队成员克隆仓库后无需任何手动配置,CI 环境同样零配置。这比在每个人 shell 里 export MAVEN_OPTS 的做法可靠得多:后者依赖开发者各自的终端环境,漏配一个人的环境就复现一次故障,且全局变量会波及机器上所有 Maven 构建。

6.2 实录二:插件选项拆成独立参数报 invalid flag

现象:把 -XepPlugin:NullAway 或其他 -XepOpt 选项写成独立 <arg> 时,javac 报:

error: invalid flag: -XepPlugin:NullAway

原因:Error Prone 插件的选项必须跟在 -Xplugin: 参数内(空格分隔)。同时要说明:NullAway 0.12.7 是以 BugChecker 服务注册的(实测其 jar 内 META-INF/services 注册的是 com.google.errorprone.bugpatterns.BugChecker),由 Error Prone 自动发现,根本不需要 -XepPlugin:NullAway 这样的开关。正确写法:

<arg>-Xplugin:ErrorProne -XepOpt:NullAway:AnnotatedPackages=com.example</arg>

6.3 实录三:缺 --should-stop=ifError=FLOW

现象:手动 javac 直调(不带该参数)时报:

Exception in thread "main" com.sun.tools.javac.util.PropagatedException: com.google.errorprone.InvalidCommandLineOptionException: The default --should-stop=ifError policy (INIT) is not supported by Error Prone, pass --should-stop=ifError=FLOW instead

Maven 编译场景下同样失败,报错经 maven-compiler-plugin 包装后为:

[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.13.0:compile (default-compile) on project nullaway-demo: Fatal error compiling: --should-stop=ifError=INIT is not supported by Error Prone, pass --should-stop=ifError=FLOW instead -> [Help 1]

两份报错说的是同一件事:Error Prone 2.36 的检查依赖数据流分析阶段,若 javac 在出错时按默认策略于 INIT 阶段提前停机,检查无法进行,因此插件强制要求 --should-stop=ifError=FLOW。对策:在参数中加入该参数(Maven 写进 compilerArgs,手动 javac 直接作为选项传递)。

6.4 实录四:jspecify 只在处理器路径时报 package does not exist

现象:代码 import org.jspecify.annotations.NullUnmarked 后编译报:

package org.jspecify.annotations does not exist

原因:jspecify 只出现在 annotationProcessorPaths(处理器类路径),不在编译类路径。对策:在 <dependencies> 加入 jspecify 1.0.0(Error Prone 会传递引入它,但编译期类路径需要显式声明),编译通过。 这个故障是 3.3 节双类路径模型最直观的反例:import 语句在源码里,源码编译走编译类路径,而处理器类路径上的 jar 对业务代码不可见。四个注解类本身不足两 KB,加入 dependencies 的构建成本可以忽略;换来的是 @Nullable 与 @NullUnmarked 在全工程可用。顺带说明注解的保留策略:JSpecify 注解会随编译写进 class 文件(CLASS 保留级别),运行期不参与任何行为,制品体积影响为零——这也是注解模型对运行时完全无侵入的具体含义。

七、日常使用与 Gradle 迁移提示

  • IDE 配合:IntelliJ IDEA 安装 Error Prone 插件后可在编辑器内看到同类告警(编译仍以 Maven 为准);IDE 提示与编译期检查的关系是互补而非替代——编辑器告警响应快,适合写码时即时修正;Maven 编译告警权威且可审计,是流水线门禁的依据。两者基于同一套 JSpecify 注解语义,正常情况下结论一致;当个别场景出现分歧时,以 mvn compile 的输出为准;
  • 扩大范围:把 AnnotatedPackages 扩到更多包、逐步移除 @NullUnmarked,是标准的迁移路径;
  • Gradle 工程:等价配置为 dependencies 中 annotationProcessor "com.google.errorprone:error_prone_core:2.36.0" 与 annotationProcessor "com.uber.nullaway:nullaway:0.12.7",options.errorprone 部分传入同样的 jvm 参数与插件参数(本文未实测 Gradle,命令以官方文档为准)。

CI 流水线的接入姿势与本地完全一致:流水线脚本里执行的就是 mvn compile(配合 -Werror 即为门禁形态),不需要任何专门的检查步骤或额外工具。一个实用的分阶段建议是:第一个月以告警模式运行并在流水线日志中保留告警输出,让团队熟悉告警的形态与误判率;存量清零后切换 -Werror,此后任何引入新空安全缺陷的提交都会被编译阶段直接拒绝。切换时点最好配合一次全量告警的存档快照,便于审计"切换前后"的基线差异。

多模块工程的原则同样简单:配置收敛到父 pom 的 pluginManagement,子模块自动继承编译插件与全部 compilerArgs;AnnotatedPackages 写业务代码的顶级包,一次覆盖所有子模块;jvm.config 放在仓库根目录的 .mvn 下,从根目录执行 mvn 即可让所有模块的编译受益。唯一需要逐模块对待的是 @NullUnmarked:它天然是代码级标记,跟着需要豁免的类走,不集中在配置里。

八、卸载方式

NullAway 与工程的耦合只在 pom.xml 与 .mvn/jvm.config,卸载分两层:

# 1. 从工程移除 NullAway(演示工程整体删除)
rm -rf /opt/nullaway-demo

# 2. 清理本地仓库缓存(可选,删除已下载的 NullAway/Error Prone 构件)
rm -rf /root/.m2/repository/com/uber/nullaway
rm -rf /root/.m2/repository/com/google/errorprone
rm -rf /root/.m2/repository/org/jspecify
rm -rf /root/.m2/repository/org/checkerframework

# 3. 移除 JDK 与 Maven(可选)
rm -rf /opt/maven /opt/maven.tar.gz /usr/local/bin/mvn
apt-get remove -y openjdk-17-jdk-headless

验证卸载是否干净的方法:执行 mvn -version 应提示命令不存在(软链已删);重新用一个最小工程 mvn compile,编译不再输出 [NullAway] 告警且不再访问 com/uber/nullaway 仓库路径,说明插件层已完全移除。本地仓库清理属于可选动作——这些构件在磁盘上约数十兆,留着不影响任何构建,删掉后若其他工程仍引用 Error Prone 则会重新下载。工程自身的卸载(第 1 步)与工具链卸载(第 3 步)相互独立:通常只需回滚工程内的 pom.xml 与 .mvn/jvm.config 两处改动,即可让仓库回到接入前的状态。

九、常见问题(FAQ)

1. annotationProcessorPaths 和 dependencies 里都要加 nullaway 吗? 只加 processorPaths 即可让检查工作(实测验证);dependencies 里的 jspecify 是为了源码 import 注解。不要把 error_prone_core/nullaway 放进 dependencies,避免与编译产物打包混淆。

2. 报 invalid flag: -XepPlugin:NullAway? 该 flag 在 Error Prone 2.36 组合下不存在也不需要。NullAway 以 BugChecker 服务被自动加载,删除该参数,把 -XepOpt 合并进 -Xplugin 参数(见排障实录二)。

3. 报 Exception in plugin ErrorProne 或初始化堆栈失败? JDK 17 模块开放不完整。确认 .mvn/jvm.config 十项齐全(八项 --add-exports、两项 --add-opens),并用 fork=false 让进程内编译继承 Maven JVM 的开放参数。

4. 报 --should-stop=ifError=FLOW is required? 按提示在 compilerArgs 中加入该参数,位置在 -Xplugin 之前。

5. Maven 编译成功但看不到 [NullAway] 告警详情? 确认 showWarnings 为 true 且未加 -Werror;-Werror 形态下详情同样存在,只是 Maven 只显示错误汇总。需要完整告警明细时用不带 -Werror 的形态或直接 javac。

6. 报 package org.jspecify.annotations does not exist? 把 jspecify 1.0.0 加进 <dependencies>(见排障实录四)。

7. 升级 Error Prone 或 NullAway 版本要改哪些地方? pom.xml 中 errorprone.version 与 nullaway.version 两个属性。跨大版本升级时留意 --should-stop 等参数要求的变化,先在演示工程验证再全仓推广。

8. 检查速度影响大吗? 演示工程感知不到(秒级编译)。大规模仓库建议以自身构建的 A/B 数据为准;官方定位即为低开销常开检查。

9. 误报怎么豁免? 方法/类级用 @NullUnmarked(推荐,语义清晰);单处告警可用 @SuppressWarnings("NullAway")。豁免会随代码评审可见,便于后续偿还。

10. 能对整个仓库一键启用吗? AnnotatedPackages 写成顶级包(如 com.yourcompany)即可覆盖全仓;存量告警太多时先不接 -Werror,配合 @NullUnmarked 逐模块迁移。

11. Ubuntu 22.04 的默认 JDK 11 能用吗? Error Prone 2.36 要求 JDK 17+ 编译运行,JDK 11 不满足,需安装 openjdk-17-jdk-headless(本文实测版本 17.0.20.1)。

12. 阿里云仓库会漏构件吗? mirrorOf=central 的 public 仓库聚合了 central 全量构件,error_prone_core/nullaway/jspecify 均可解析(实测首次编译正常下载依赖)。

13. .mvn/jvm.config 与全局 MAVEN_OPTS 二选一怎么选? 推荐 jvm.config:它随工程提交进版本库,克隆即生效,只影响本工程;MAVEN_OPTS 是 shell 环境变量,影响该机器上所有 Maven 构建且依赖每个人的终端配置,团队场景下漏配概率高。两者同时存在时参数会叠加,注意不要重复。

14. NullAway 会检查测试代码吗? 会。检查范围由包名决定而非源码目录:src/test 下属于 AnnotatedPackages 覆盖包的测试类同样受检。测试代码大量使用 null 构造边界用例时,给测试工具方法标注 @Nullable 建模即可,无需把测试包排除在检查之外。

十、参考链接