本文是 Spring Boot 4 系列第 04 篇 | 预计阅读 15 分钟 | 全文约 4000 字
文末附「迁移时间线」和「常见报错速查表」,迁移时可直接对照。
写在前面
写 Java 的人,没被 NullPointerException 坑过的应该不多。
Spring Boot 4 引入了 JSpecify 1.0.0,把空安全检查从运行时挪到了编译期。这篇讲清楚几件事:JSpecify 是什么、Spring Boot 4 怎么用它、NullAway 怎么配置、Spring Boot 3 项目怎么迁移、迁移过程中会踩到哪些坑。代码和结论都基于 Spring Boot 4.1.0 GA 源码。
本文内容速览
- 从一个生产 NPE 说起,以及 Tony Hoare 的"十亿美元错误"
- JSpecify 1.0.0 的由来,以及它和 JSR-305、Spring 旧注解的区别
@NullMarked+@Nullable的用法,以及"默认非空,只标注例外"的设计思路- NullAway + ErrorProne 编译期检查的 Maven / Gradle 配置
- Spring Boot 4 源码里
@NullMarked的实际使用模式 - Spring Boot 3 → 4 迁移步骤,附常见报错速查表
- JSpecify 与 Kotlin 互操作
一、一个 NullPointerException,让整个系统崩了
先看一个生产事故。
kotlin
java.lang.NullPointerException: Cannot invoke "String.isEmpty()"
because the return value of "UserService.getEmail()" is null
调用链是 user.getProfile().getEmail(),getProfile() 返回了 null,调用方没做判空。重启之后没几分钟,同样的异常又报了一次。
NPE 在 Java 里有多常见不用多说,各类异常统计里它常年排第一。Tony Hoare 在 2009 年 QCon 大会上为自己 1964 年引入 null 引用公开道歉,称之为"十亿美元的错误"(The Billion Dollar Mistake)。
Spring Boot 4 的解法是引入 JSpecify 1.0.0,把空安全检查从运行时挪到编译期:

二、JSpecify 是什么?为什么要从 JSR-305 迁移?
2.1 历史的混乱:Java 空安全标注的"战国时代"
在 JSpecify 出现之前,Java 生态中的空安全标注是一片混乱:
| 标注方案 | 注解来源 | 状态 |
|---|---|---|
JSR-305 (@javax.annotation.Nullable) |
FindBugs/Google | 已废弃,2012 年 JCP 投票列为休眠 |
Spring (@org.springframework.lang.Nullable) |
Spring 团队 | 已标记 @Deprecated,建议迁移到 JSpecify |
| FindBugs/SpotBugs | 社区 | 碎片化,各版本不兼容 |
IntelliJ IDEA (@org.jetbrains.annotations) |
JetBrains | IDE 专用,缺乏标准性 |
| Checker Framework | 学术项目 | 学习曲线陡峭 |
Android (@androidx.annotation) |
仅限 Android 平台 |
问题很明显 :同一个 @Nullable,不同来源的语义不同、检查工具不同、互不兼容。你写了 Spring 的 @Nullable,IntelliJ 可能识别不到;换了 Kotlin 互操作,行为又变了。
2.2 JSpecify 1.0.0:Java 空安全的统一标准
JSpecify 由 Google、Oracle、JetBrains、Uber 等多家公司共同制定,目的是给 Java 空安全标注定一个统一标准。
在 Spring Boot 4.1.0 的 spring-boot-dependencies 中可以直接看到 JSpecify 依赖管理:
groovy
// platform/spring-boot-dependencies/build.gradle
library("JSpecify", "1.0.0") {
group("org.jspecify") {
modules = ["jspecify"]
}
}
Spring Boot 4 使用的 JSpecify 标注类型包括 @NullMarked、@Nullable、@NullUnmarked(@NonNull 极少使用),版本为 1.0.0。
三、核心注解:@NullMarked 与 @Nullable
JSpecify 的核心理念只有一句话:默认非空,只标注例外 。实践中有三个关键注解,其中 @NullMarked 和 @Nullable 是绝对主力。
3.1 @NullMarked:包级默认非空声明
这是 JSpecify 最核心的设计。在 package-info.java 中加上一行,整个包内所有参数和返回值默认就是非空的。
在 Spring Boot 4.1.0 的源码中,大量核心包都是这样做的。例如:
java
// core/spring-boot/src/main/java/org/springframework/boot/package-info.java
@NullMarked
package org.springframework.boot;
import org.jspecify.annotations.NullMarked;
org.springframework.boot.context 包也是:
java
// core/spring-boot/src/main/java/org/springframework/boot/context/package-info.java
@NullMarked
package org.springframework.boot.context;
import org.jspecify.annotations.NullMarked;
这意味着什么? 在这两个包下面,你写的任何一个方法:
java
// 这个方法的参数和返回值默认都是非空的
public String getUserName(Long id) {
return userRepository.findById(id).getName();
}
不需要写任何 @NonNull,JSpecify 的分析工具就默认知道 id 不能为 null,返回值不能为 null。
3.2 @Nullable:标注"可为空"的例外
既然默认是非空,那么只在确实可能为 null 的地方手动标注 @Nullable。
以 SpringApplication.java(核心启动类)为例,源码中有大量 @Nullable 标注:
java
// core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java
import org.jspecify.annotations.Nullable;
public class SpringApplication {
private @Nullable Class<?> mainApplicationClass;
private @Nullable Banner banner;
private @Nullable ResourceLoader resourceLoader;
private @Nullable Map<String, Object> defaultProperties;
private @Nullable Banner printBanner(ConfigurableEnvironment environment) { ... }
private RuntimeException handleRunFailure(
@Nullable ConfigurableApplicationContext context,
Throwable exception,
@Nullable SpringApplicationRunListeners listeners) { ... }
}
再看 ApplicationArguments 接口:
java
// core/spring-boot/src/main/java/org/springframework/boot/ApplicationArguments.java
import org.jspecify.annotations.Nullable;
public interface ApplicationArguments {
String[] getSourceArgs(); // 永远不会是 null
Set<String> getOptionNames(); // 至少返回空集合
boolean containsOption(String name); // 基本类型无需标注
// 只有真正可能为 null 时才标注
@Nullable List<String> getOptionValues(String name);
}
再看 JsonWriter 接口中 @NonNull 的精确用法:
java
// core/spring-boot/src/main/java/org/springframework/boot/json/JsonWriter.java
@FunctionalInterface
public interface JsonWriter<T> {
void write(@Nullable T instance, Appendable out) throws IOException;
interface Extractor<T extends @Nullable Object, R extends @Nullable Object> {
@Nullable R extract(@NonNull T value); // 入参非空,返回值可为 null
}
}
3.3 @NullUnmarked:将某个子包排除在外
@NullUnmarked 用于覆盖父包的 @NullMarked,将某个子包从空安全检查中排除出去。在 Spring Boot 4.1.0 的当前源码中,@NullUnmarked 仅在 CLI 模块的 JSON shade 包中使用:
java
@NullUnmarked
package org.springframework.boot.cli.json;
import org.jspecify.annotations.NullUnmarked;
这个包是对第三方 JSON 库的简单封装,不需要空安全检查。
3.4 JSpecify vs 旧注解全面对比
| 对比维度 | Spring Boot 3.x (旧) | Spring Boot 4.x (新) |
|---|---|---|
| 空安全注解来源 | org.springframework.lang.Nullable 等 |
org.jspecify.annotations.* |
| 包级默认 | @NonNullApi + @NonNullFields 两个注解分别控制 |
@NullMarked 一个注解统一控制,且语义更精确 |
| 标准归属 | Spring 内部定义 | 行业共识社区标准 |
| Kotlin 互操作 | 部分支持,platform type 残留 | Kotlin 2.1+ 自动翻译为 Kotlin nullability |
| 编译器检查 | 依赖 IDE 插件 | NullAway + ErrorProne |
| 社区生态 | 仅 Spring 生态支持 | Google、Oracle、JetBrains 联合推动 |
四、"默认非空,只标注例外" ------ JSpecify 的设计哲学
4.1 为什么不学 Kotlin 的可空类型?
有人会问:Kotlin 直接在类型系统上区分了 String 和 String?,为什么 Java 不这样做?
原因很简单:Java 的类型系统没法改了 。三十年的历史包袱,字母 ? 在 Java 泛型中已经有了通配符语义。JSpecify 选择了务实路线 ------ 用编译期标注(Type-Use Annotation)来附着在类型系统之上,不改变 Java 语言本身。
4.2 默认非空 vs 默认可空的决策

这是 JSpecify 一个关键的设计决策。统计表明,在实际项目中,超过 90% 的方法参数和返回值预期是非空的 。如果你采用"默认可空"策略(像旧 JSR-305 那样),你需要给几乎每一个方法都加 @NonNull,标注噪音极大,反而容易遗漏真正需要关注的 null。
Spring Boot 4 大量模块全面采用 @NullMarked 正是这个哲学的体现 ------ 整个包默认非空,只在真正需要的地方加 @Nullable。
4.3 一个真实对比:改造前后
Spring Boot 3 时代 (使用 org.springframework.lang):
java
// === package-info.java ===
@org.springframework.lang.NonNullApi
@org.springframework.lang.NonNullFields
package com.example.service;
// === UserService.java ===
public class UserService {
// 需要显式标注 @NonNull 或无标注(语义模糊)
public @NonNull User findById(@NonNull Long id) { ... }
// 为 null 时才标注 @Nullable
public @Nullable User findByEmail(@NonNull String email) { ... }
}
Spring Boot 4 时代(使用 JSpecify):
java
// === package-info.java ===
@NullMarked
package com.example.service;
// === UserService.java ===
// 不再需要 @NonNullFields/@NonNullApi,整个包默认非空
public class UserService {
// 返回值非空、参数非空,无需任何标注!
public User findById(Long id) { ... }
// 只有真正可能为 null 时才标注 @Nullable
public @Nullable User findByEmail(String email) { ... }
}
代码量更少,意图更清晰,标注噪音从"无处不在"降到了"精确打击"。
五、编译期检查:NullAway + ErrorProne 配置指南
光有注解不够,必须有工具在编译期帮我们检查。Spring 官方推荐 NullAway (Uber 开源)配合 ErrorProne 来做编译期空安全检查,Spring Boot 4 自身也采用这套工具链。
5.1 NullAway 的 JSpecify 模式
NullAway 从 0.10.x 版本开始支持 JSpecify 模式。关键配置是启用 JSpecifyMode(注意:JSpecify 模式要求 JDK 22+):
bash
# 编译参数
-XepOpt:NullAway:JSpecifyMode=true
# JDK 的类型注解支持(必须)
-XDaddTypeAnnotationsToSymbol
-XDaddTypeAnnotationsToSymbol 是一个关键的 javac 内部编译标志,它告诉编译器将 type-use 级别的注解(如 JSpecify 的 @Nullable)附加到类型符号上 ,而不仅仅是元素上。这确保了 NullAway 能够正确分析泛型中的空安全标注(如 T extends @Nullable Object)。
5.2 Maven 配置
xml
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_core</artifactId>
<version>2.27.0</version>
</path>
<path>
<groupId>com.uber.nullaway</groupId>
<artifactId>nullaway</artifactId>
<version>0.11.3</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<arg>-XDaddTypeAnnotationsToSymbol</arg>
<arg>-XepOpt:NullAway:JSpecifyMode=true</arg>
<arg>-XepOpt:NullAway:AnnotatedPackages=com.example</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
5.3 Gradle 配置
groovy
plugins {
id 'net.ltgt.errorprone' version '4.4.0'
}
dependencies {
implementation 'org.jspecify:jspecify:1.0.0'
errorprone 'com.google.errorprone:error_prone_core:2.27.0'
errorprone 'com.uber.nullaway:nullaway:0.11.3'
}
tasks.withType(JavaCompile).configureEach {
options.compilerArgs += [
'-XDaddTypeAnnotationsToSymbol',
'-XepOpt:NullAway:JSpecifyMode=true',
'-XepOpt:NullAway:AnnotatedPackages=com.example'
]
}
配置时把 com.example 换成你的根包名,Maven 和 Gradle 二选一即可。
5.4 NullAway 检查效果演示
下面这段代码,每一处错误在编译期就会被拦下来,根本跑不到生产:
java
@NullMarked // 类级别标注,整个类范围内默认非空
public class OrderService {
// ❌ 编译报错: 返回 null,但返回值未标注 @Nullable
public Order findById(Long id) {
return null; // NullAway: returning null from non-@Nullable method
}
// ✅ 正确:返回 null 时标注了 @Nullable
public @Nullable Order findByOrderNo(String orderNo) {
return null;
}
// ❌ 编译报错: 传入 null 给非空参数
public void process(Long id) {
Order order = findById(null); // NullAway: passing null to non-@Nullable param
}
// ✅ 正确:先检查返回值非空再使用
public void processSafe(Long id) {
Order order = findById(id);
if (order != null) {
order.ship(); // NullAway 推断此处 order 非空
}
}
}
也就是说,以前要等到线上崩了才暴露的空指针,现在 mvn compile 阶段就能拦住。
六、Spring Boot 4 中 JSpecify 空安全的深度实践
6.1 核心模块已全面 NullMarked
在 Spring Boot 4.1.0 源码中,主要模块的源码包均已添加 @NullMarked 标注,覆盖以下领域:
- spring-boot (core):核心 API 全量覆盖
- spring-boot-actuator:Actuator 端点全量覆盖(含 autoconfigure)
- spring-boot-micrometer:监控指标全量覆盖
- spring-boot-data:数据访问层覆盖
- spring-boot-autoconfigure:自动配置覆盖
- spring-boot-security:安全模块覆盖
- spring-boot-test:测试支持覆盖
- 其他子模块:web、grpc、graphql、devtools、buildpack、cli 等
6.2 实际源码中的三种典型使用模式
从 Spring Boot 4.1.0 源码中提炼出三种 JSpecify 使用模式:
模式一:接口方法返回可空值
java
// core/spring-boot/.../SpringApplicationAdminMXBean.java
public interface SpringApplicationAdminMXBean {
boolean isReady();
boolean isEmbeddedWebApplication();
@Nullable String getProperty(String key); // 属性可能不存在
void shutdown();
}
模式二:字段级别标注
java
// core/spring-boot/.../JsonWriter.java
final class Members<T> {
private final List<Member<?>> members = new ArrayList<>();
private final boolean contributesPair;
private final @Nullable Series series; // 系列由构造参数决定
}
模式三:泛型参数的空安全约束
java
// core/spring-boot/.../JsonWriter.java
interface ValueProcessor<T extends @Nullable Object> {
@Nullable T processValue(MemberPath path, @Nullable T value);
}
interface Extractor<T extends @Nullable Object, R extends @Nullable Object> {
@Nullable R extract(@NonNull T value); // 入参必须非空
}
6.3 与旧 org.springframework.lang 注解的关系
在 Spring Boot 4 中,org.springframework.lang 包中的 @Nullable、@NonNullApi、@NonNullFields 等注解仍然保留但已标记为 @Deprecated。Spring Framework 7.0 官方推荐所有新代码使用 JSpecify。
org.springframework.lang.Contract(用于 @Contract("null -> false") 之类的逻辑标注)和 JSpecify 不冲突:
java
import org.springframework.lang.Contract; // 逻辑契约,仍然有效
七、实战:将 Spring Boot 3 项目迁移到 JSpecify 空安全
7.1 迁移步骤全景

Step 1:添加依赖
xml
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>1.0.0</version>
</dependency>
Step 2:为根包添加 @NullMarked
java
// src/main/java/com/example/package-info.java
@NullMarked
package com.example;
import org.jspecify.annotations.NullMarked;
Step 3:配置 NullAway + ErrorProne(参考第五节配置)
Step 4:逐步编译修复
bash
mvn clean compile
# 典型错误: [NullAway] returning @Nullable expression from method with @NonNull return type
修复策略:
java
// 改造前
public class UserService {
public User findByEmail(String email) {
return userRepository.findByEmail(email); // 可能返回 null
}
}
// 改造后 - 方案1: 标注返回值可为空
public class UserService {
public @Nullable User findByEmail(String email) {
return userRepository.findByEmail(email);
}
}
// 改造后 - 方案2: 返回 Optional
public class UserService {
public Optional<User> findByEmail(String email) {
return Optional.ofNullable(userRepository.findByEmail(email));
}
}
迁移时别一上来就全量加 @NullMarked,建议从一个包开始,编译、修错、再扩大范围。
Step 5:CI 流水线集成
yaml
# .github/workflows/build.yml
- name: Build with NullAway
run: mvn clean compile
7.2 常见迁移问题速查
| 问题 | 现象 | 解决方案 |
|---|---|---|
returning null from non-@Nullable method |
非空方法返回了 null | 返回值标注 @Nullable 或改用 Optional |
passing null to non-@Nullable parameter |
传入 null 给非空参数 | 调用前判空,或修改参数签名为 @Nullable |
assignment type incompatible |
将可空值赋值给非空变量 | 添加 null 检查后再赋值 |
generic type incompatibility |
泛型参数空安全约束不匹配 | 使用 T extends @Nullable Object |
NullAway 不识别 @NullMarked |
配置了但不生效 | 检查 JSpecifyMode=true 是否开启,AnnotatedPackages 或 OnlyNullMarked 是否正确 |
7.3 与 Kotlin 互操作
如果你同时使用 Kotlin,JSpecify 的好处会更明显。从 Kotlin 2.1 开始,JSpecify 注解会被 Kotlin 编译器自动翻译为 Kotlin 的空安全类型(2.1 起默认为严格模式,不匹配直接报错),这样 Platform Type 的问题基本就没有了。Spring Boot 4.1.0 的 Kotlin 基线为 2.3,因此默认即享受此特性。
java
// Java 侧定义(package-info.java 已标注 @NullMarked)
public class UserService {
public @Nullable User findByEmail(String email) { ... }
public User findById(Long id) { ... } // 默认非空
}
kotlin
// Kotlin 侧调用(Kotlin 2.1+)
val userService: UserService = ...
val user1: User = userService.findById(1L) // 非空类型,无需 !!
val user2: User? = userService.findByEmail("a") // 可空类型,自动推断
val user3: User = userService.findByEmail("a") // 编译错误:类型不匹配
也就是说,Java 侧的空安全信息可以传递到 Kotlin,调用方不用再写一堆 !! 和防御性判空。
八、总结与展望
核心要点回顾
- JSpecify 1.0.0 是 Java 空安全的统一标准 ,Spring Boot 4 全量采用,主要模块全部
@NullMarked - 设计哲学:默认非空,只标注例外------90% 的代码无需任何空安全标注
- @NullMarked (包级) + @Nullable (例外) 覆盖绝大多数场景,
@NullUnmarked处理兼容排除 - NullAway + ErrorProne 提供编译期检查,在 CI 阶段就能拦截 NPE
- Kotlin 2.1+ 自动翻译 JSpecify 注解,Platform Type 的困扰基本消除
迁移建议时间线
| 阶段 | 时间 | 事项 |
|---|---|---|
| 第 1 周 | 添加 jspecify 依赖 | 不影响现有代码 |
| 第 2-3 周 | 为核心包添加 @NullMarked | 逐步修复 NullAway 报告 |
| 第 4 周 | 为 API/RPC 层添加 @NullMarked | 接口层优先覆盖 |
| 第 5-8 周 | 全量覆盖 + CI 集成 | 余下模块逐包迁移 |
下一篇预告
空安全解决之后,下一篇聊 Spring Boot 4 的虚拟线程:一行配置 spring.threads.virtual.enabled=true,就能把传统平台线程池替换为虚拟线程(SimpleAsyncTaskExecutor)。
yaml
spring:
threads:
virtual:
enabled: true # 就这一行
下一篇: Spring Boot4 第 05 篇 一行配置开启百万并发: 虚拟线程从入门到生产落地*
有问题或迁移中遇到的坑,欢迎在评论区交流。