为什么 Spring Boot 4 能在编译期报空指针:JSpecify 从注解到字节码的整条链

先说一个可能让你不太舒服的结论:Spring Boot 4 并没有给你的项目装上一个"编译期 NPE 检查器"。

真实的链路是这样的——Spring 把自家框架和整个 portfolio 的 API 契约,用 JSpecify 注解写进了 class 文件;然后,如果你(或者是你的调用方)在构建里挂了 NullAway,或者你写的是 Kotlin,这些契约才会在编译期变成错误。少了最后一环,mvn compile 依然一声不吭,该在运行时抛 NPE 的还是抛。

这个区别很关键:它决定了你要不要为这件事改构建、改代码、改团队习惯,也决定了你读到"Spring Boot 4 支持编译期空安全"这类宣传语时,该信几分。

一、JSpecify 不是 JSR-305 的续集,是一份能被机器执行的规范

JSpecify 1.0.0 在 2024 年 7 月 17 日发布到 Maven Central,正式定下四个注解:@Nullable、@NonNull、@NullMarked、@NullUnmarked,并承诺不再做不兼容变更。

四个里真正改变工作方式的是 @NullMarked。它的语义是"作用域内默认非空":一个包、类或方法被标记后,里面所有类型使用处默认排除 null,例外必须显式写 @Nullable;@NullUnmarked 则把作用域退回 Java 原生语义,也就是"未知"。Spring 文档给出的典型用法是在 package-info.java 上打包级标记,一个项目几十上百个包,每个包一行,成本不高但收益是全局的。

和 Spring 的老做法对比,能看清它为什么值得换。Spring 从 5.0 起用 JSR-305 的元注解拼出 @NonNullApi / @NonNullFields,那套机制的问题很具体:JSR-305 本身早已休眠,注解的 @Target 指向声明而不是类型使用,依赖是"借来的"(split package 风险)。JSpecify 的注解是 @Target(ElementType.TYPE_USE) 且 @Retention(RUNTIME)——前者让它能标注 List<@Nullable String> 这一类位置,后者让它在 class 文件里留下来,才有了后半段"运行期读取"的可能。

维度 JSR-305 Spring lang 老注解 JSpecify 1.0
注解目标 声明 声明 类型使用 TYPE_USE
泛型内元素 无法表达 无法表达 List<@Nullable String>
包级默认非空 靠元注解拼 靠元注解拼 @NullMarked 一等公民
保留策略 CLASS RUNTIME RUNTIME
维护状态 事实休眠 Spring 7 起废弃 正式 1.0

表格最后一行是硬事实:Spring Framework 7 把 org.springframework.lang 下的 @Nullable、@NonNull、@NonNullApi、@NonNullFields 全部标为 deprecated,并建议迁移到 JSpecify。注意"建议"这个词——它没有在 7.0 里删掉,因为整个生态的迁移需要时间。

二、整条链路:注解 → 检查 → class 文件 → 运行期

先给一张全景图,后面每一节都是在这张图上放大某一个格子。

这张图想让你看见的是"分叉":同一份注解,往上走进 IDE 得到波浪线,往左走进 javac 得到构建错误,往下走进 class 文件在运行期被框架读回来。三条路彼此独立——你把 IDE 的检查关掉,不影响构建;你把 NullAway 从构建里摘掉,不影响运行期框架读注解。很多人以为这是一套联动机制,其实它是三个消费者共用一份输入。

三、到底是谁在编译期说话

这一环最容易含糊,我把它拆成五层分别看:

检查者 阶段 默认开启 对 Spring 7 API 的反应
javac 原生 编译期 是 什么都不做,Java 没有空性类型系统
IntelliJ IDEA 编辑期 是 按 JSpecify 给警告
Eclipse 编辑期 否,需手动配置 警告
NullAway + Error Prone 构建期,挂在 javac 上 否,需显式接入 编译失败
Kotlin 编译器 编译期 是 翻译为空类型,直接报错

这里我不太同意"Spring Boot 4 能在编译期报空指针"这个流行说法(包括我自己的标题)。javac 不认识 @Nullable——它只把注解当成普通的类型注解写进 class 文件,不会因为你在 @NullMarked 包里有解引用就报错。真正报错的是挂在 javac 上的 Error Prone 插件,是编译流程里额外加的一道 lint 层。区别在哪?javac 的空性检查如果存在,会是全语言、不可绕过的基础语义;而 NullAway 是可选的、可配的、能一行配置关掉的静态分析。

这个区别不是文字游戏,它带来两个实际后果。第一,NullAway 默认只检查打了 @NullMarked 的包(NullAway:OnlyNullMarked=true,Spring 官方推荐的配置),因为全量检查老代码会产生海量告警,团队第一天就想关掉它。第二,"编译期报错"的覆盖范围取决于你的配置,而不是取决于你用了 Spring Boot 4——同一个仓库,A 组配了 NullAway,B 组没配,同样的 bug 在 A 组过不了构建,在 B 组上线后半夜报警。

边界我也说清楚:在 Kotlin 项目里,这句话是成立的。Kotlin 编译器直接读 JSpecify 注解并翻译成 Kotlin 的空类型,契约不满足就是编译错误,不需要任何插件。你现在能拿到的"最省事的编译期空安全",其实来自 Kotlin 而不是 Spring。至于 Java 侧,Spring 官方措辞很克制:用支持 JSpecify 的 IDE 会得到警告,可选地接入 NullAway 在构建期强制。

四、Spring Framework 7 真正改变的是契约,不是你的编译结果

那 Spring 到底做了什么?它把"这个方法可能返回 null"从口头约定、Javadoc 注释、以及社区口耳相传的常识,变成了机器可读、可校验、可被工具链消费的声明。这是契约层面的变化,不是编译器的变化。

看一段前后对比。假设老代码里有一个查订单的服务,返回值可能为空,但从来没写清楚:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 迁移前:契约只存在于作者脑子里
package demo.order;

import java.util.Optional;

public final class OrderService {

private final OrderRepository repository;

public OrderService(OrderRepository repository) {
this.repository = repository;
}

// 调用方不知道这里可能返回 null,直接 order.title() 就炸
public Order find(String id) {
return repository.findById(id);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 迁移后:契约写在类型上,工具链能读
package demo.order;

import org.jspecify.annotations.Nullable;

public final class OrderService {

private final OrderRepository repository;

public OrderService(OrderRepository repository) {
this.repository = repository;
}

public @Nullable Order find(String id) {
return repository.findById(id);
}

public String describe(String id) {
Order order = find(id);
return order == null ? "unknown" : order.title();
}
}

配套的包级声明,一行文件解决整个包:

1
2
3
4
@NullMarked
package demo.order;

import org.jspecify.annotations.NullMarked;

这段代码在说明什么?三点。第一,@Nullable 是写在类型前面的(public @Nullable Order find),不是写在方法声明上——这是 TYPE_USE 注解的硬性语法,写错位置在 JSpecify 语义下是无效的。第二,迁移后 describe 里那个 order == null 判断不是可选的风格问题,而是 NullAway 认可的空值收敛:不写这个判断,构建期就会报错。第三,package-info.java 里的 @NullMarked 让这个包内"未标注 = 非空",所以 OrderRepository 的返回值一旦被声明为 @Nullable,一路传到 describe 都会被追踪。

顺带说一个真实的坑:Assert.notNull() 这类断言方法不会自动让分析器"学会"收敛。Spring 给 Assert.notNull 加了 @Contract("null, _ → fail"),而 NullAway 需要你显式配置 NullAway:CustomContractAnnotations=org.springframework.lang.Contract 才认这个注解。不配,你会得到一堆"明明前面断言过了还报"的告警,然后开始怀疑工具。

五、几个坑,我踩过的

我踩过的坑集中在三处,都和「注解写了就完事」的直觉相反。

第一个坑:override 不继承注解。JSpecify 注解不会被继承,你在父类或接口上写了 @Nullable,子类实现必须自己再抄一遍,否则子类的实现按"非空"解释。如果父类来自外部依赖、根本没标,你会看到一堆 @SuppressWarnings("NullAway") // Overridden method does not define nullability。这类抑制注释会越积越多,是迁移期最大的隐性债务。

第二个坑:数组语法反直觉。在 @NullMarked 代码里,@Nullable Object[] 表示元素可空、数组本身非空;Object @Nullable [] 表示元素非空、数组可空;两个都写才是都空。我第一次迁移时写反过一次,编译期没报错——因为语义恰好自洽,只是和我的意图不同。这种"静默的错误契约"比报错更危险。

第三个坑:泛型的空性检查还不完整。Spring 文档里明确写了:泛型类型和泛型方法上的空性还没被 NullAway 完全支持;NullAway:JSpecifyMode=true 这个开关虽然能检查数组、可变参数和泛型的完整 JSpecify 语义,但官方标注它仍在开发中,且要求 JDK 22 以上。所以如果你的代码重度依赖泛型容器,别指望第一天上全量模式。

六、运行期那一段:class 文件里的类型注解怎么被读回来

JSpecify 注解是 RUNTIME 保留 + TYPE_USE,所以它们会作为类型注解留在 class 文件里。这就是 Spring 7 新增的 org.springframework.core.Nullness 枚举能工作的前提:它提供 forField、forParameter、forMethodParameter、forMethodReturnType 四个查询入口,返回 NON_NULL、NULLABLE、UNSPECIFIED 三种结果。官方 Javadoc 写得很清楚:完整支持 JSpecify、Kotlin 空安全和 Java 基本类型,对任意包的 @Nullable 都做务实检查;而 JSR-305 和 org.springframework.lang 的 @NonNullApi / @NonNullFields / @NonNull 不被支持。

我的理解是:这一格才是 Spring 选 JSpecify 而不是自研注解的真正原因——它要的是能被工具链反复消费的元数据,而不是一份好看的文档。

这张时序图想让你看见"字节码"这一格为什么不能省。注解如果没有 RUNTIME 保留,链路在编译结束就断了,框架只能靠"猜"或者靠额外的元数据文件;正因为注解留在了 class 文件里,运行期才有机会把契约当成一等数据来用。至于 Spring 内部具体在哪些参数解析器里调用了 Nullness,我没有逐个核对调用点,这一段属于机制层面的推断——机制是确定的,调用点分布待核。

七、我的判断:它解决什么,不解决什么

我的判断是:这套东西真正的价值不在"抓 NPE",而在"把契约变成可传递的资产",外加 Kotlin 互操作。你自己写的代码,NullAway 只能看到你写了注解的部分;而 Spring 7 把上游几万个方法签名的空性标清楚了,你的分析器第一次能跨库推理——jdbcTemplate.queryForObject 到底会不会返回 null,以前靠试,现在靠注解。这是它最实际的一次性收益。

不解决的部分同样明确,我把它们列成清单,方便你在评审时直接引用:

  • 反射与依赖注入:@Autowired 字段注入、@Value、Bean 后处理器设置的字段,NullAway 看不到,Nullness 也只是把契约读出来,不会替你把值填进去。
  • 框架生成的代理:AOP 代理、CGLIB 子类上的方法,空性注解是否跟着走,取决于代理生成方式,别默认它一定可靠。
  • 反序列化与外部数据:JSON 里的 null、数据库列、HTTP 请求体,注解只是声明,校验还得靠 Bean Validation。
  • 容器与集合语义:Map.get 返回 null 是 API 契约的一部分,JSpecify 只能让你显式标注,不能把它变成 Optional。
  • 你同事的代码:这是一个社会问题,不是技术问题。

落地成本我也算过一笔账,诚实说:中小项目几百个类的量级,接入 NullAway 到"构建能过"大约需要一到两周的集中清理,之后每周零星修;大项目如果历史代码里大量 @Nullable 缺失,前期噪音会非常劝退,务必先用 OnlyNullMarked=true 缩小战场,一个包一个包推进。

八、今天就能动手的清单

  1. 升级前先确认基线:Spring Framework 7.0 与 Spring Boot 4.0 分别于 2025 年 11 月 13 日和 11 月 20 日 GA,先在一个非核心模块上升级,别全量铺开。
  2. 把 org.springframework.lang 的 @Nullable / @NonNull 全量替换成 org.jspecify.annotations 版本,只改 import 和注解位置(注意挪到类型前面)。
  3. 给自己最核心的一两个包加 package-info.java + @NullMarked,不要一次全加。
  4. 接 NullAway 时先只开 OnlyNullMarked=true 和 CustomContractAnnotations=org.springframework.lang.Contract,JSpecifyMode 留到第二阶段。
  5. 在 CI 里把 NullAway 设为阻塞:只警告不阻塞的检查,三个月后一定退化成没人看的日志。
  6. 给团队写一页规则:override 必须复制注解、数组空性三种写法、以及 @SuppressWarnings 必须带理由注释。

待核与来源

  • 待核:Kotlin 编译器从哪个具体版本开始完整支持 JSpecify 注解。Spring 文档只说明"Kotlin 中 JSpecify 注解会被自动翻译为 Kotlin 的空安全",未给出起始版本;我也没有找到确切的版本号出处,因此不在文中写死。
  • 待核:Spring 内部具体哪些参数解析器调用了 Nullness API。Javadoc 只描述了 API 能力,没有列出调用点。
  • 有意不写:NullAway、Error Prone 插件的具体依赖版本号。这些版本迭代很快,写死在文章里第二天就可能过期,请以官方发布为准。

核验来源: