为什么 Spring Boot 4 能在编译期报空指针:JSpecify 从注解到字节码的整条链
为什么 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 文件 → 运行期
先给一张全景图,后面每一节都是在这张图上放大某一个格子。
flowchart LR
SRC[源码 注解声明] --> IDE[IDE 实时提示]
SRC --> JC[javac 编译]
JC --> EP[Error Prone 插件 NullAway]
EP -->|契约不满足| FAIL[构建失败]
EP -->|契约满足| CLS[class 文件 类型注解]
IDE --> CLS
JC --> KOT[Kotlin 调用方 编译期报错]
CLS --> RT[运行期 Nullness 查询]
RT --> DEC[参数解析 与 契约判断]
这张图想让你看见的是"分叉":同一份注解,往上走进 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 | // 迁移前:契约只存在于作者脑子里 |
1 | // 迁移后:契约写在类型上,工具链能读 |
配套的包级声明,一行文件解决整个包:
1 |
|
这段代码在说明什么?三点。第一,@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 不被支持。
sequenceDiagram
participant Dev as 开发者
participant Build as javac 与 NullAway
participant Class as class 文件
participant Frame as Spring 运行期
Dev->>Build: 提交代码 或执行 mvn compile
Build->>Build: 在 NullMarked 作用域内做数据流检查
alt 契约不满足
Build-->>Dev: 构建失败 报出空性错误
else 契约满足
Build->>Class: 写入类型注解 与 参数注解
Class->>Frame: 启动或解析时反射读取
Frame-->>Dev: 按契约决定参数是否必填
end
我的理解是:这一格才是 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 缩小战场,一个包一个包推进。
八、今天就能动手的清单
- 升级前先确认基线:Spring Framework 7.0 与 Spring Boot 4.0 分别于 2025 年 11 月 13 日和 11 月 20 日 GA,先在一个非核心模块上升级,别全量铺开。
- 把
org.springframework.lang的@Nullable/@NonNull全量替换成org.jspecify.annotations版本,只改 import 和注解位置(注意挪到类型前面)。 - 给自己最核心的一两个包加
package-info.java+@NullMarked,不要一次全加。 - 接 NullAway 时先只开
OnlyNullMarked=true和CustomContractAnnotations=org.springframework.lang.Contract,JSpecifyMode留到第二阶段。 - 在 CI 里把 NullAway 设为阻塞:只警告不阻塞的检查,三个月后一定退化成没人看的日志。
- 给团队写一页规则:override 必须复制注解、数组空性三种写法、以及
@SuppressWarnings必须带理由注释。
待核与来源
- 待核:Kotlin 编译器从哪个具体版本开始完整支持 JSpecify 注解。Spring 文档只说明"Kotlin 中 JSpecify 注解会被自动翻译为 Kotlin 的空安全",未给出起始版本;我也没有找到确切的版本号出处,因此不在文中写死。
- 待核:Spring 内部具体哪些参数解析器调用了
NullnessAPI。Javadoc 只描述了 API 能力,没有列出调用点。 - 有意不写:NullAway、Error Prone 插件的具体依赖版本号。这些版本迭代很快,写死在文章里第二天就可能过期,请以官方发布为准。
核验来源:
- JSpecify 1.0.0 发布公告(2024-07-17,四个注解正式化)→ https://jspecify.dev/blog/release-1.0.0/
- Spring Framework 7.0 GA(2025-11-13,comprehensive null safety through JSpecify)→ https://spring.io/blog/2025/11/13/spring-framework-7-0-general-availability
- Spring Boot 4.0.0 GA(2025-11-20,portfolio-wide null safety improvements with JSpecify)→ https://spring.io/blog/2025/11/20/spring-boot-4-0-0-available-now
- Spring Framework 参考文档 Null-safety 章节(NullAway 构建期检查、@NullMarked 包级用法、数组与泛型语法、老注解废弃)→ https://docs.spring.io/spring-framework/reference/core/null-safety.html
Nullness枚举 Javadoc(7.0 引入,四个查询方法与三种返回值)→ https://docs.spring.io/spring-framework/docs/7.0.10/javadoc-api/org/springframework/core/Nullness.html- NullAway 官方仓库(Error Prone 插件、构建期开销)→ https://github.com/uber/NullAway
- JSpecify 注解源码(TYPE_USE + RUNTIME 保留)→ https://github.com/jspecify/jspecify/blob/main/src/main/java/org/jspecify/annotations/Nullable.java





