告别十亿美元的错误 : Spring Boot 4 空安全 (JSpecify) 实战

本文是 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) Google 仅限 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 直接在类型系统上区分了 StringString?,为什么 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 是否开启,AnnotatedPackagesOnlyNullMarked 是否正确

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,调用方不用再写一堆 !! 和防御性判空。


八、总结与展望

核心要点回顾

  1. JSpecify 1.0.0 是 Java 空安全的统一标准 ,Spring Boot 4 全量采用,主要模块全部 @NullMarked
  2. 设计哲学:默认非空,只标注例外------90% 的代码无需任何空安全标注
  3. @NullMarked (包级) + @Nullable (例外) 覆盖绝大多数场景,@NullUnmarked 处理兼容排除
  4. NullAway + ErrorProne 提供编译期检查,在 CI 阶段就能拦截 NPE
  5. 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 篇 一行配置开启百万并发: 虚拟线程从入门到生产落地*


有问题或迁移中遇到的坑,欢迎在评论区交流。

相关推荐
糖果店的幽灵1 小时前
langgraph的 MessagesState 解读
java·开发语言·人工智能·windows·langgraph
码栈研说1 小时前
Go 语言大白话入门 10 - 排序与常用数据操作
后端·程序员
JavaGuide1 小时前
再见 Superpowers!很多 Skill 真的可以扔掉了。
后端·ai编程
极光代码工作室2 小时前
基于SpringBoot的在线博客系统
java·springboot·web开发·后端开发
我是唐青枫2 小时前
Java Spring Security 实战详解:从登录认证到 JWT 权限控制
java·spring
用户7713970207063 小时前
我在项目里发现了一个“神秘文件“——.editorconfig
后端
盏灯3 小时前
mac 外接磁盘,热更新失效
前端·后端
ihuyigui3 小时前
海外签收通知短信接口
android·java·开发语言·前端·数据库·后端
海棠Flower未眠3 小时前
SpringBoot 消息死信队列(荣耀典藏版)
java·数据库·spring boot