Spring Boot 4 空安全源码剖析:JSpecify 是怎么让全生态 API null-safe 的

本文是 Spring Boot 4 系列第 13 篇 | 基于 Spring Boot 4.1.0 + Spring Framework 7.0.8 + jspecify 1.0.0 源码(含 jar 反编译验证) | 预计阅读 25 分钟

文末附「三层机制闭环图」与「四个核心设计思想」,接入空安全检查时可直接对照。


写在前面

老项目里随手写一个 @Nullable,先要回答一个问题:它是哪个包的?javax.annotation.Nullableorg.springframework.lang.Nullable 还是 org.jetbrains.annotations.Nullable?不同工具对它们的支持完全不一样:IDEA 认一套,Kotlin 编译器认一套,Error Prone 插件认一套。还有一个常见误解:标了 @Nullable 就以为有运行时检查------其实没有,它只是一个编译期提示,运行时 JVM 完全忽略它。

Spring Boot 4 把这件事收口了:整个 Spring 生态统一到 JSpecify (org.jspecify.annotations)一套注解上,并配套了三层机制:

  1. 声明层 :@NullMarked 让"包内默认非空",只有例外才标 @Nullable;
  2. 编译期检查层 :NullAway 等工具按 JSpecify 语义做静态检查,把空指针问题挡在 mvn compile 阶段;
  3. 运行时读取层 :Spring Framework 7 新增的 Nullness 枚举用反射读取注解,让框架在运行时也尊重你的可空声明(比如 actuator 端点参数是否必填)。

本文的源码引用均来自本地 Spring Boot 4.1.0 仓库(v4.1.0 分支)、spring-core 7.0.8(本地 Gradle 缓存 jar 反编译验证)与 jspecify 1.0.0。

内容速览

  • 全景图:空安全三层机制,声明层、编译期检查层、运行时读取层
  • 注解定义层:JSpecify 四个注解的 @Target,TYPE_USE 粒度为什么是本质差异
  • @NullMarked 三个特例:通配符、类型参数上界、可空上界
  • Spring 7 旧注解迁移:org.springframework.lang 全员 @Deprecated 的桥接策略
  • Boot 4.1.0 标注盘点:841 个 @NullMarked 包、1986 处 @Nullable、全仓库仅 1 处 @NonNull
  • NullAway JSpecify 模式:javac 跨编译边界读 TYPE_USE 注解的 JDK 版本矩阵
  • Nullness 枚举源码:运行时反射判定可空性的完整链路
  • actuator 实战:@Nullable 直接决定端点参数是否必填
  • Kotlin 2.1 严格模式:platform types 是怎么被消除的
  • 全家桶覆盖率盘点,以及"null-safe 不等于运行时防御"的边界

一、全景图:一条空安全链路上的三层机制

先看总览:

less 复制代码
       你写的代码(声明层)
        @NullMarked 包 + @Nullable 例外
              │
              ▼
  ┌─────────────────────────────────────────────────┐
  │ 编译期检查层                                       │
  │  NullAway (Error Prone 插件)                     │
  │    -XepOpt:NullAway:JSpecifyMode=true           │
  │    └─ 依赖 javac 能读到 TYPE_USE 类型注解          │
  │         └─ JDK-8225377 修复 / -XD... 标志         │
  │  Kotlin 编译器(2.1 起默认 strict)                │
  └─────────────────────────────────────────────────┘
              │
              ▼
  ┌─────────────────────────────────────────────────┐
  │ 运行时读取层                                       │
  │  Spring Framework 7: Nullness 枚举               │
  │    forParameter() / forMethodReturnType()        │
  │    └─ 包/类级 @NullMarked 链 + @Nullable 简单名    │
  │  Boot 4.1 消费方:                                │
  │    actuator OperationMethodParameter.isMandatory │
  └─────────────────────────────────────────────────┘

从上到下,每个环节都有真实的类、真实的字节码可以验证。下面一层一层拆。


二、第一层:org.jspecify.annotations 的四个注解

2.1 先看 jar 里到底有什么

jspecify 1.0.0 是 Maven Central 上的一个小 jar(实测约 3.7KB),包里只有 4 个注解类:

arduino 复制代码
org/jspecify/annotations/NonNull.class
org/jspecify/annotations/NullMarked.class
org/jspecify/annotations/NullUnmarked.class
org/jspecify/annotations/Nullable.class

反编译四个注解的字节码,它们的 @Target 决定了各自能标在哪里:

注解 @Target 语义
@Nullable TYPE_USE 该类型使用处可以包含 null
@NonNull TYPE_USE 该类型使用处不包含 null
@NullMarked MODULE, PACKAGE, TYPE, METHOD, CONSTRUCTOR 标记代码为"null-marked":内部类型使用默认排除 null
@NullUnmarked PACKAGE, TYPE, METHOD, CONSTRUCTOR 撤销外层 null-marking,恢复"未指定"

四个注解都是 @Retention(RUNTIME) + @Documented------运行时可反射读取,这是第六节的运行时机制能成立的前提。

2.2 @Nullable / @NonNull 为什么只有 TYPE_USE

这是 JSpecify 与老一代注解(JSR-305、JetBrains、Spring 5/6 的 org.springframework.lang)最本质的差异:新注解贴在"类型使用"上,而不是"声明"上

举个例子,同样是"数组可以为 null,但元素不能为 null":

java 复制代码
// 老注解只能标在声明位置,表达不了这种粒度
@Nullable String[] a;      // 是"数组可空"还是"元素可空"?说不清

// JSpecify 的 TYPE_USE 注解可以精确到类型里的每一层
String @Nullable [] a;         // 数组本身可空,元素非空
@Nullable String [] b;         // 数组非空,元素可空
@Nullable String @Nullable [] c; // 两个都可空

List<@Nullable String> 这种"集合元素可空"的表达在老注解下根本无法书写------这也是为什么 3.x 时代"集合里能不能放 null"只能靠文档约定。JSpecify 把这种粒度给了编译器检查器。

2.3 @NullMarked:包级默认非空是怎么实现的

@NullMarked 的 javadoc 原文(jspecify 1.0.0):

"Indicates that the annotated element and the code transitively enclosed within it are null-marked code: there, type usages are generally considered to exclude null as a value unless specified otherwise."

**"transitively enclosed"(传递性地被包含)**是关键词:@NullMarked 打在包上(package-info.java),包下所有类的所有成员都默认非空;打在类上,类内默认非空。所以代码里只需要标注 @Nullable 这一个例外,@NonNull 几乎用不到。

但 javadoc 同时列了三个特例,新手最容易踩:

  1. 通配符不是默认非空 :List<?>List<? super String> 里的 ? 没有上界,必须包含 null ;只有 List<? extends String> 这种有非空上界的通配符才算非空;
  2. 类型参数总是有上界 :class MyList<E> 等价于 class MyList<E extends Object>,所以 MyList<@Nullable Foo> 越界,元素一定非空------null-marked 下类型参数天然非空;
  3. 可空上界的类型参数是"参数化空" :class Foo<E extends @Nullable Bar> 里的 E 既不算可空也不算非空,按"读时视为可空、写时视为非空"严格处理。

这几个特例不是咬文嚼字------NullAway 的 JSpecify 模式检查的就是这些边界,写库的人(Spring 自己)会真实地撞上它们。

2.4 一个例外:Boot 4.1.0 里唯一的 @NonNull

统计 Spring Boot 4.1.0 全仓库:@NullMarked 出现在 841 个文件 (全部是 package-info.java)里,org.jspecify.annotations.Nullable 的 import 有 1986 处 ,而 @NonNull 的 import 只有 1 处 (若只统计 core/ + module/ 两个目录,则前两个数字分别是 642 与 1699)------core/spring-boot/src/main/java/org/springframework/boot/json/JsonWriter.java:

java 复制代码
@FunctionalInterface
interface Extractor<T extends @Nullable Object, R extends @Nullable Object> {

	/**
	 * Extract from the given value.
	 * @param value the source value (never {@code null})
	 * @return an extracted value or {@code null}
	 */
	@Nullable R extract(@NonNull T value);

}

注意这里 T extends @Nullable Object:类型参数的上界被放宽为可空,所以具体调用处 T 可能是 @Nullable 的------泛型场景下 @NullMarked 的"默认非空"不再适用,必须显式写 @NonNull 。这正是 2.3 节特例第 3 条的实战体现:一旦给类型参数放宽了上界,参数化空值的每个使用点都要自己负责。这也是 @NonNull 在 JSpecify 里的主要价值所在------平时几乎用不到,但泛型链路上它是最后一道保险。


三、第二层(上):Spring 7 旧注解迁移与桥接

3.1 org.springframework.lang 全员 @Deprecated(since = "7.0")

Spring Boot 4 自己的源码里已经看不到 org.springframework.lang 的四个空安全注解了(全仓库 grep:NonNull/NonNullApi/NonNullFields 的 import 为 0)。但框架侧并没有直接删掉它们,而是在 Spring Framework 7.0 把它们全部标记废弃。spring-core 7.0.8 的字节码:

kotlin 复制代码
org/springframework/lang/Nullable.class       → @Deprecated(since = "7.0")
org/springframework/lang/NonNull.class        → @Deprecated(since = "7.0")
org/springframework/lang/NonNullApi.class     → @Deprecated(since = "7.0")
org/springframework/lang/NonNullFields.class  → @Deprecated(since = "7.0")

源码里的 javadoc 写得更直白,比如 Nullable.java:

java 复制代码
/**
 * ...
 * @deprecated use {@link org.jspecify.annotations.Nullable} instead
 */
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@CheckForNull
@TypeQualifierNickname
@Deprecated(since = "7.0")
public @interface Nullable {
}

注意它不是删除,而是"废弃 + 保留"------这就是迁移期的桥接策略:旧代码还能编译、旧工具链(按 JSR-305 语义读注解的 IDE、Kotlin 编译器)还能识别,但任何新代码都会收到 deprecation 警告,指引迁往 JSpecify。

3.2 桥接机制的底细

Nullable 上的两个元注解:

  • @CheckForNull (JSR-305 的 javax.annotation):声明"返回值可能为 null"。这是给读不懂 Spring 注解、只懂 JSR-305 的工具看的------通过给注解加"注解上的注解",把 Spring 的 @Nullable 翻译成 JSR-305 语义;
  • @TypeQualifierNickname :JSR-305 的"类型限定符昵称"机制,配合 @TypeQualifierDefault 才能让 @NonNullApi 这种包级注解生效。

NonNullApi 上的元注解更能说明问题:

java 复制代码
@Nonnull
@TypeQualifierDefault({ElementType.METHOD, ElementType.PARAMETER})
@Deprecated(since = "7.0")
public @interface NonNullApi {
}

它用 JSR-305 的 @TypeQualifierDefault 实现"方法参数与返回值默认非空"------这正是 JSpecify @NullMarked 的前身。Spring 7 的迁移路线非常明确:

Spring 6.x(org.springframework.lang) Spring 7(org.jspecify.annotations)
@NonNullApi(包级:参数/返回值默认非空) @NullMarked(包/类级:一切类型使用默认非空)
@NonNullFields(包级:字段默认非空) @NullMarked(合并语义)
@Nullable(声明位置) @Nullable(TYPE_USE,粒度更细)
@NonNull @NonNull(泛型链路兜底)

NonNullApiNonNullFields 的 javadoc 都指向同一个替换目标:@NullMarked

3.3 但 Nullness API 留了个口子

有一个值得注意的细节:Spring 7 新增的 Nullness 枚举(第六节详讲)的 javadoc 明确写:

"JSR-305 annotations as well as Spring null safety annotations in the org.springframework.lang package such as @NonNullApi, @NonNullFields, and @NonNull are not supported by this API. However, @Nullable is supported via the package-less check. Migrating to JSpecify is recommended."

也就是说:运行时读取层对旧注解只认 @Nullable(按简单名匹配,不管包名),不认 @NonNullApi/@NonNullFields/@NonNull 。这对迁移者是个微妙的坑:如果库还在用 @NonNullApi 声明"包默认非空",框架的运行时 API 读不到这个默认值,会把它判定为 UNSPECIFIED------所以框架强烈建议迁到 JSpecify。第六节会看到这个口子具体怎么影响行为。


四、第二层(下):Boot 4.1.0 的 841 个包是怎么标起来的

4.1 全部走 package-info.java

Spring Boot 4.1.0 里 @NullMarked 出现的 841 个文件全部是 package-info.java ,没有任何一个类用类级 @NullMarked(唯一的 1 处 @NullUnmarked 同样在 package-info.java 里)。典型的写法(core/spring-boot/src/main/java/org/springframework/boot/package-info.java):

java 复制代码
/**
 * Core Spring Boot classes.
 *
 * @see org.springframework.boot.SpringApplication
 */
@NullMarked
package org.springframework.boot;

import org.jspecify.annotations.NullMarked;

为什么选包级而不是类级?两个原因:

  1. 迁移成本最低 :每个包一个 package-info.java,不用改动已有类的头部;即使包里有几个历史遗留的不合规方法,也只需给那几个方法/参数补 @Nullable;
  2. 声明与目录结构一一对应 :看到 package-info.java 就一眼判断"这个包是否已经 null-safe",评审和后续增量迁移都方便。

4.2 @NullUnmarked 的用法:第三方代码隔离

全仓库 @NullUnmarked 只有 1 处 ,而且非常典型------cli/spring-boot-cli/src/json-shade/java/org/springframework/boot/cli/json/package-info.java:

java 复制代码
@NullUnmarked
package org.springframework.boot.cli.json;

import org.jspecify.annotations.NullUnmarked;

这个包是 shade(把第三方 jar 的类重新打包进自己 jar)出来的 JSON 库代码 ------第三方代码没有 JSpecify 标注,如果直接放进 @NullMarked 的 Boot 仓库里,NullAway 会把它当"默认非空"检查,产生海量假阳性。@NullUnmarked 就是为这种场景准备的:外部代码的默认状态是"未指定",声明自己不参与 null-marking

4.3 跟着断点看一个真实包:org.springframework.boot.json

JacksonJsonParser 为例,它在 Spring Boot 4.1.0 的 core/spring-boot/.../boot/json/ 包下(注意:它是 Spring Boot 自己的类,不是 Spring Framework 的 ------org.springframework.boot.json 包是 Boot 1.0 就有的 JSON 工具层,4.1 里已经切换到 Jackson 3 的 tools.jackson 包)。

这个包的 package-info.java 标了 @NullMarked,于是包内"默认非空":

java 复制代码
public interface JsonParser {

	Map<String, Object> parseMap(@Nullable String json) throws JsonParseException;

	List<Object> parseList(@Nullable String json) throws JsonParseException;

}

parseMap(null) 是合法调用吗?看注解:@Nullable String json------参数可空 ,所以调用方传 null 是允许的;返回 Map<String, Object> 没有标注,在 @NullMarked 包内默认非空 ------实现类必须保证不返回 null(BasicJsonParser 等实现确实如此)。

再看 JacksonJsonParser 内部的一个字段:

java 复制代码
private @Nullable JsonMapper jsonMapper; // Late binding

一个"延迟绑定"的 JsonMapper:如果容器没配置 Jackson Bean,这个字段就是 null,方法里用 if (this.jsonMapper != null) 分支兜底。没有 @Nullable 标注,NullAway 会在 if 分支外直接报错 ------因为这个字段在 null-marked 代码里被假定非空。这就是 @Nullable 在 null-marked 代码里的日常用法:字段可能为 null,就必须显式标注给检查器立契约。

注解扫描小结 :@NullMarked(841 包)解决"默认",@Nullable(1986 处)解决"例外",@NonNull(1 处)解决"泛型边界",@NullUnmarked(1 处)解决"第三方隔离"------四件套各有各的位置,Boot 4.1.0 的落地方式可以直接参考。


五、编译期检查层:NullAway 怎么读懂这些注解

5.1 NullAway 与 JSpecifyMode

声明写得再规范,没有检查器执行就等于没写。Spring 生态配套的检查器是 Uber 开源的 NullAway------一个跑在 Error Prone 上的编译器插件(javac 插件),把空安全违规变成编译错误。它有两种模式:

  • 标准模式 :认识 @Nullable 基本语义,泛型场景处理保守(JSpecify 语义下的类型实参边界、覆写传递等不检查);
  • JSpecify 模式 :-XepOpt:NullAway:JSpecifyMode=true(Error Prone 的 -XepOpt 家族选项),开启完整的 JSpecify 语义------包括泛型可空性检查 (@Nullable 类型实参需要可空上界、类型参数在赋值/方法调用/返回/覆写中的传递)。

配置长这样(Gradle 侧):

gradle 复制代码
tasks.withType(JavaCompile).configureEach {
    options.errorprone.enabled = true
    options.errorprone.error("NullAway")
    options.errorprone.option("NullAway:AnnotatedPackages", "com.example")
    options.errorprone.option("NullAway:JSpecifyMode", "true")
}

注意两个前提:

  1. JSpecify 模式对 JDK 有硬性要求 (NullAway 0.12.11 起,在不支持的环境下启动 JSpecify 模式会直接报错退出):JDK 22+ 开箱即用(javac 的类型注解修复默认启用);JDK 21.0.8+ 需要加 -XDaddTypeAnnotationsToSymbol=true;JDK 17--21.0.7 不支持------因为它依赖 javac 跨编译边界传播 TYPE_USE 注解(详见 5.2 节);
  2. AnnotatedPackages 必须配:NullAway 只检查声明的包,避免对无标注的第三方代码报海量假阳性。

5.2 javac 的隐藏标志:-XDaddTypeAnnotationsToSymbol

这里有一个大多数人不了解的前提:javac 默认情况下,从 class 文件(跨编译边界)加载的符号上,类型注解对插件/注解处理器是不可见的 。也就是说:项目依赖了 Spring Boot 4.1 的 jar,jar 里 parseMap(@Nullable String)@NullableTYPE_USE 注解,存在 class 文件的 RuntimeVisibleTypeAnnotations 属性里------但 NullAway 通过 javac 内部 API 读符号时,默认读不到它。这正是 OpenJDK bug **JDK-8225377("type annotations are not visible to javac plugins across compilation boundaries")**描述的问题。

修复方案是在 javac 的 ClassReader 里实现 addTypeAnnotationsToSymbol:解析 class 文件 RuntimeVisibleTypeAnnotations 属性中的 target_typetype_path,把类型注解按类型结构重新写回符号上。这个修复经历了多年打磨:

  • JDK 22+ 默认启用,无需任何参数;
  • JDK 21u :21.0.8 引入(JDK-8341779),但后续更新(JDK-8360406)把该逻辑改为默认禁用,需要隐藏标志 -XDaddTypeAnnotationsToSymbol 显式启用(-XD 是 javac 隐藏选项家族,不写进官方文档);
  • JDK 17u:backport 直到 2025 年 12 月才合入(jdk17u-dev PR #4124,对应 JDK-8341779),同样默认禁用、靠该标志启用。

顺带一提,这个修复还有一个副作用:它让 javac 更积极地补全被引用类库 API 上的符号注解,在编译 classpath 不完整时可能冒出 cant.attach.type.annotations 错误------OpenJDK 用 JDK-8370800 讨论把这个诊断从错误降级为警告(该提案后来被撤回)。

所以 NullAway 的 JSpecify 模式对 JDK 有明确的支持矩阵:JDK 17--21.0.7 直接拒绝运行(0.12.11 起),JDK 21.0.8+ 必须加标志,JDK 22+ 免配置。另外澄清一个常见误读:Spring Boot 4.1 的官方系统要求是"至少 Java 17、兼容至 Java 26" (system-requirements.adoc 原文:"requires at least Java 17 and is compatible with versions up to and including Java 26"),并没有"文档建议 JDK 24+"的说法------需要 JDK 22+ 的是 NullAway 的 JSpecify 检查器工具链,不是 Boot 运行时本身,两者不要混淆。

5.3 Boot 自己的构建跑不跑 NullAway?

核对 Boot 自己的构建时有一个值得注意的现状:Spring Boot 4.1.0 仓库的构建里,buildSrc/.../JavaConventions.javaconfigureNullability 方法当前是空实现:

java 复制代码
private void configureNullability(Project project) {
	// Disabled for debugging - NullAway nullability checks
}

也就是说,Boot 4.1.0 自己编译时并没有强制跑 NullAway (至少本地仓库分支如此)------841 个包的标注是"人写 + IDE 提示"维护出来的,而不是 CI 强制的。但同时,buildSrc/.../MavenPluginPlugin.java 里有一段 addNullAwaySuppression:Boot 用 Maven 的 help:describe 目标生成 spring-boot-maven-plugin 的 help Mojo 源码(org/springframework/boot/maven/ 包下的生成类没有任何标注,而该包恰好是 @NullMarked 的),构建时会给这些生成类统一加 @SuppressWarnings("NullAway")------这是 Boot 对自己生成代码的构建侧兜底(将来恢复启用 NullAway 也不会因缺标注而报错),并不是面向使用者生成代码的兼容措施。

结论:"Spring 全家桶 null-safe"目前是 API 契约层面的承诺,不是编译期强制的产物 。框架靠标注声明契约,使用方靠 NullAway/Kotlin 等工具自己执行检查。应用接入 Boot 4.1 后,把 -XepOpt:NullAway:JSpecifyMode=true 配上,才是完整的闭环。


六、第三层:运行时读取------Nullness 枚举源码剖析

这一层是 Spring Framework 7.0 的新代码 (org.springframework.core.Nullness,@since 7.0),它把"注解声明"翻译成"运行时行为"。

6.1 枚举定义与设计取舍

java 复制代码
public enum Nullness {

	/** Unspecified nullness (Java default for non-primitive types and JSpecify @NullUnmarked code). */
	UNSPECIFIED,

	/** Can include null (typically specified with a @Nullable annotation). */
	NULLABLE,

	/** Will not include null (Kotlin default and JSpecify @NullMarked code). */
	NON_NULL;

	// ...
}

三个值对应三种状态:未指定 / 可空 / 非空。javadoc 明确它的能力边界:

  • 支持 JSpecify 四个注解(NullMarked/NullUnmarked/Nullable/NonNull)
  • 支持 Kotlin 空安全(通过 Kotlin 反射读 KType.isMarkedNullable)
  • 支持任意包名的 @Nullable(按简单名匹配)
  • 支持 Java 基本类型(非 void 的基本类型天然 NON_NULL)
  • 不支持 JSR-305 与 spring.langNonNullApi/NonNullFields/NonNull(第三节说过的口子)

6.2 forParameter 的判定链路

以最常用的 forParameter 为例(spring-core 7.0.8 源码):

java 复制代码
public static Nullness forParameter(Parameter parameter) {
	if (KOTLIN_REFLECT_PRESENT && KotlinDetector.isKotlinType(parameter.getDeclaringExecutable().getDeclaringClass())) {
		// ① Kotlin 类:走 Kotlin 反射
		MethodParameter methodParameter = MethodParameter.forParameter(parameter);
		return KotlinDelegate.forParameter(methodParameter.getExecutable(), methodParameter.getParameterIndex());
	}
	// ② Java 类:先按简单名找 @Nullable,再走 JSpecify 判定
	Executable executable = parameter.getDeclaringExecutable();
	return (hasNullableAnnotation(parameter) ? Nullness.NULLABLE :
			jSpecifyNullness(executable, executable.getDeclaringClass(), parameter.getAnnotatedType()));
}

两个分支的细节都值得看:

分支 ① KotlinDelegate :Kotlin 方法先映射成 KFunction,按参数索引找 KParameter,isMarkedNullable() 为 true 即可空;还有一条后路------setXxx setter 找不到对应函数时,反查类属性的 getter 返回值可空性。这是"Kotlin 的 String?/String 被 Java 框架正确理解"的底层实现。

分支 ② jSpecifyNullness(Java 侧核心逻辑):

java 复制代码
private static Nullness jSpecifyNullness(
		AnnotatedElement annotatedElement, Class<?> declaringClass, AnnotatedType annotatedType) {

	if (annotatedType.getType() instanceof Class<?> clazz && clazz.isPrimitive()) {
		return (clazz != void.class ? Nullness.NON_NULL : Nullness.UNSPECIFIED);
	}
	if (annotatedType.isAnnotationPresent(Nullable.class)) {
		return Nullness.NULLABLE;                    // ① 直接标注 @Nullable
	}
	if (annotatedType.isAnnotationPresent(NonNull.class)) {
		return Nullness.NON_NULL;                    // ② 直接标注 @NonNull
	}
	Nullness nullness = Nullness.UNSPECIFIED;
	// ③ 包级 @NullMarked
	Package declaringPackage = declaringClass.getPackage();
	if (declaringPackage.isAnnotationPresent(NullMarked.class)) {
		nullness = Nullness.NON_NULL;
	}
	// ④ 类级 @NullMarked(覆盖包级)
	if (declaringClass.isAnnotationPresent(NullMarked.class)) {
		nullness = Nullness.NON_NULL;
	}
	// ⑤ @NullUnmarked 撤销......(省略)
	return nullness;
}

逐条拆解:

  • 基本类型直接 NON_NULL :int 不可能为 null,不用看注解------但 void 返回 UNSPECIFIED;
  • @Nullable/@NonNull 在类型使用上生效 :因为注解是 TYPE_USE,annotatedType.isAnnotationPresent 读的是类型使用这一层 的注解(AnnotatedType,而不是 AnnotatedElement)------@Nullable String json 这种"参数整体可空"能直接命中,这正是老注解(声明位置)做不到的粒度。但要说明边界:List<@Nullable String> 这种元素级 可空性在类型参数层(getAnnotatedActualTypeArguments()),Nullness 目前并不深入类型参数,运行时层只判定"参数/返回值整体是否可空";
  • @NullMarked 的"包 → 类"两级链 :类级标注覆盖包级默认------与 JSpecify 的语义(最内层声明优先)一致。注意源码里类级/元素级的 @NullMarked@NullUnmarkedif/else-if 关系:同一级同时标两者时 @NullMarked 胜出;@NullUnmarked 真正撤销的是外层 (包级)@NullMarked 设下的默认值;
  • 注意 hasNullableAnnotation 的实现:
java 复制代码
private static boolean hasNullableAnnotation(AnnotatedElement element) {
	for (Annotation annotation : element.getDeclaredAnnotations()) {
		if ("Nullable".equals(annotation.annotationType().getSimpleName())) {
			return true;
		}
	}
	return false;
}

按注解的简单名("Nullable")匹配,不看包名 ------这就是"任意包的 @Nullable 都支持"的实现方式。代价是:如果一个类里同时有不同包的多个 @Nullable,它们一视同仁。这也是为什么迁移到 JSpecify 之前,老代码的空安全声明在 Spring 7 里"部分可用"。

6.3 运行时消费方:actuator 端点参数

声明层和运行时层怎么接上?看 Boot 4.1.0 的 actuator 模块------module/spring-boot-actuator/.../endpoint/invoke/reflect/OperationMethodParameter.java:

java 复制代码
@Override
public boolean isMandatory() {
	return Nullness.NULLABLE != Nullness.forParameter(this.parameter);
}

一行代码把空安全接进行为:端点方法的参数标了 @Nullable,参数就是可选的;没标(默认非空),就是必填的 。对照官方文档(actuator/endpoints.adoc):

"Parameters are required by default. They can be made optional by annotating them with JSpecify's org.jspecify.annotations.Nullable."

换句话说:Boot 4.1 的 actuator 端点,"是否必填"不再是一套单独的配置,而是直接读取方法签名上的空安全注解。写:

java 复制代码
@Endpoint(id = "users")
public class UserEndpoint {

	@ReadOperation
	public String findUser(@Nullable String id) { ... }   // id 可空 → Web 端不再要求传
}

id 参数在 Web 暴露时就是可选的(缺省传 null);改成不带注解,立刻变必填(缺参会 400)。这就是"运行时尊重编译期声明"的完整链路:

less 复制代码
方法签名 @Nullable
  └─ OperationMethodParameter.isMandatory()
       └─ Nullness.forParameter(parameter)     ← spring-core 反射读取
            └─ hasNullableAnnotation / jSpecifyNullness(包→类 @NullMarked 链)

对比 Spring Boot 3.x 与 4.1 :机制上是一脉相承的------老版本 actuator 同样通过方法参数的 @Nullable 判断可选性,但注解来源是 org.springframework.lang(Spring 5/6 时代),语义由 IDE 与 JSR-305 工具链各自解释;4.1 切换到 Nullness 统一读取后,JSpecify、Kotlin 空安全、任意包名 @Nullable 三条路径合并成一套 API,并且和编译期检查(NullAway / Kotlin 编译器)读的是同一份声明------两边不会再出现"IDE 说可空、运行时说必填"的割裂。


七、Kotlin 互操作:platform types 是怎么被消除的

Kotlin 是空安全机制的最大受益者。Java 未标注的返回值在 Kotlin 里是 platform type (String!),既能当 String 用也能当 String? 用------编译器不帮你兜底。Spring 6.x 时代靠 org.springframework.lang 注解(JSR-305 元注解)让 Kotlin 编译器做推断,但 JSR-305 的推断默认并不严格。

Kotlin 2.1 起,org.jspecify.annotations 成为 Kotlin 编译器的原生支持对象 。Boot 4.1 官方文档(features/kotlin.adoc)原文:

"As of Kotlin 2.1, Kotlin enforces strict handling of nullability annotations from the org.jspecify.annotations package."

时间线:

版本 行为
Kotlin 2.1 之前 JSpecify 注解已被编译器读取,但默认只把不匹配报为警告 ;要升级为编译错误需手动加两个编译器参数:-Xjspecify-annotations=strict + -Xtype-enhancement-improvements-strict-mode
Kotlin 2.1 起 默认开启严格模式,JSpecify 注解直接映射为 Kotlin 空安全类型

效果:Spring Boot 4.1 的 parseMap(@Nullable String json) 在 Kotlin 侧看到的是 parseMap(json: String?),返回值 Map<String, Object>(非空);方法声明"能传 null"还是"不能传 null"在 Kotlin 编译器里就成了类型系统的一部分------platform type 被彻底消除 。配合第六节的 Nullness(Kotlin 分支读 KParameter.isMarkedNullable()),Kotlin 写的端点方法参数可空性也会被 actuator 正确识别,两个方向都通了。


八、全家桶覆盖率盘点(截至 4.1.0)

项目 状态 证据
Spring Framework 7.0 已完成,全部迁入 JSpecify spring.lang 四注解 @Deprecated(since="7.0"),官方文档 reference/7.0/core/null-safety.html 给出迁移路线
Spring Boot 4.1.0 已完成:841 个包 @NullMarked、1986 处 @Nullable 本仓库直接统计
Spring Data 4.x 已完成,与 Framework 7 同规范 官方迁移文档确认(仓库方法空处理一节已换 JSpecify 示例)
Spring Integration 已完成(7.0 起) 迁移 epic(spring-projects/spring-integration#10083)已交付:7.0.0-M3 公告确认 "Nullability via JSpecify and NullAway is applied to every single package in the project",构建内跑 NullAway 校验
其他 Spring 生态项目 进行中,逐项目推进 以各项目官方迁移进度为准

一个容易被误读的点:"null-safe"不等于"运行时防御"。JSpecify 注解是编译期契约,JVM 运行时不做任何检查;第三方未标注的库跨过边界时,NPE 依然可能发生。它消灭的是"自己写的代码在边界处放空"这一类错误,把错误发现时间从生产环境提前到编译期。


九、总结

用一张图回顾三层的完整闭环:

less 复制代码
声明层    @NullMarked(841 包) / @Nullable(1986 处) / @NonNull(1 处) / @NullUnmarked(1 处)
             │  TYPE_USE 注解,写进 class 文件的 RuntimeVisibleTypeAnnotations
             ▼
编译期层  NullAway -XepOpt:NullAway:JSpecifyMode=true   → 编译报错
          Kotlin 2.1+ 默认 strict                        → 类型系统强制
             │  依赖 javac 跨编译边界读到类型注解(JDK-8225377 修复 / -XD 标志)
             ▼
运行时层  Spring Framework 7 Nullness 枚举(反射读取)
             └─ Boot 4.1 actuator:@Nullable → 参数可选,未标 → 必填

四个核心设计思想:

  1. 默认非空、只标例外 :@NullMarked 把"每个方法写一遍 @NonNull"的噪音反转成"只标 @Nullable 例外",标注密度下降一个数量级------Boot 4.1.0 的 @NonNull 全仓库只有 1 处就是明证;
  2. 迁移不靠删除靠废弃 :org.springframework.lang 保留 7.0 一整个大版本(@Deprecated(since="7.0") + JSR-305 桥接),给生态留出过渡期------这是大型框架做破坏性变更的标准做法;
  3. TYPE_USE 是粒度革命 :String @Nullable []List<@Nullable String> 这种表达式把"哪里能空"精确到类型结构内部,老注解(声明位置)物理上表达不了;
  4. 一套声明三处消费:同一份注解,NullAway 编译期检查、Kotlin 编译器类型推导、Spring 运行时反射读取------JSpecify 赢在"标准化"而不是"功能多"。

相关推荐
用户852495071841 小时前
一条点赞,六张表:SQL 数据库设计实战
后端
MetaLite1 小时前
SpringBoot底座为什么要接管默认配置-自动装配与安全默认值
spring boot·安全·spring
MetaLite1 小时前
SpringBoot项目Maven-BOM统一版本就不会冲突吗
spring boot·后端·maven
benchmark_cc1 小时前
Claude Code + MCP + QuantDash:打造全自动量化研究流水线的终极指南
人工智能·后端·爬虫·算法·claude·mcp·quantdash
小刘是地理大王1 小时前
Nacos 注册与配置中心实战笔记
后端
李昊哲小课2 小时前
SpringMVC 完整执行流程
spring boot·spring·mvc
Csvn2 小时前
🐍 Day 6: Python 异常处理 — 防御式编程的核心
后端·python
叫我少年2 小时前
Git SSH 配置:从生成密钥到远程连接
git·后端
IT_陈寒2 小时前
Python多进程池的坑:子进程竟然不会退出
前端·人工智能·后端