我在一个 Spring Boot 4.1.1 项目里故意写了 17 处不判空的调用,接上 Error Prone 2.50.0 和 NullAway 0.14.2 并打开 JSpecify 模式后,编译报了 12 个错误,Spring API 上直接标了 @Nullable 的返回值一个没漏。漏掉的 5 处全在 JDK 和第三方库身上:List.get、System.getProperty、往 HashMap 里放 null、RowMapper 里返回 ResultSet.getString,这 4 处要再加 JSpecifyExperimental 才报出来,Jackson 的 JsonNode.get 无论怎么配都能编译通过,测试里照样抛 NPE。代价是 mvn clean test-compile 的耗时中位数从 2248 ms 涨到 4139 ms。
Spring Framework 7.0 起整个代码库改用 JSpecify 注解声明空值语义,Spring Boot 4.0 跟着一起发布,Spring 团队在 Null-safe applications with Spring Boot 4 里列出了已经完成标注的项目。这些注解是什么意思,Spring Boot 4 黑科技: 消灭空指针异常!已经讲过,这篇只做一件事:把检查真正接进 Maven 构建,看它到底拦得住哪些写法。环境是 OpenJDK 21.0.12.1 和 Maven 3.9.9,Spring Boot 4.1.1 带的是 Spring Framework 7.0.9 和 JSpecify 1.0.1。
先看 Spring 7 的方法签名
要测的方法先到 Spring Framework 7.0.9 的源码里确认一遍,下面几行摘自对应的 sources jar:
// PropertyResolver
@Nullable String getProperty(String key);
String getRequiredProperty(String key) throws IllegalStateException;
// HttpEntity
public @Nullable T getBody() {
// HttpHeaders
public @Nullable String getFirst(String headerName) {
// RestClient.ResponseSpec
<T> @Nullable T body(Class<T> bodyType);
// JdbcOperations
<T> List<@Nullable T> queryForList(String sql, Class<T> elementType) throws DataAccessException;
<T extends @Nullable Object> T queryForObject(String sql, RowMapper<T> rowMapper, @Nullable Object @Nullable ... args) throws DataAccessException;
<T> @Nullable T queryForObject(String sql, Class<T> requiredType, @Nullable Object @Nullable ... args) throws DataAccessException;
Map<String, @Nullable Object> queryForMap(String sql, @Nullable Object @Nullable ... args) throws DataAccessException;
// RowMapper
public interface RowMapper<T extends @Nullable Object> {
// ObjectProvider
default @Nullable T getIfAvailable() throws BeansException {
default T getIfAvailable(Supplier<T> defaultSupplier) throws BeansException {
Environment.getProperty(String)、HttpEntity.getBody()、HttpHeaders.getFirst()、RestClient 的 body()、queryForObject(sql, Class, args) 和 ObjectProvider.getIfAvailable() 的返回值都直接标了 @Nullable。queryForList(sql, Class) 返回 List<@Nullable T>,queryForMap 返回 Map<String, @Nullable Object>,空值信息放在泛型参数上。带 RowMapper 的 queryForObject 返回的是 T,T 的上界是 @Nullable Object,能不能为 null 要看推断出来的类型。
项目和 17 处写法
项目只有 webmvc、jdbc、restclient 三个 starter 加 H2,book 表里第 1 本书的 subtitle 是 null。package-info.java 给整个包加上 @NullMarked:
@NullMarked
package com.example.nullsafe;
import org.jspecify.annotations.NullMarked;
然后照平时的习惯写,一处判空都不做。BookRepository 是 JdbcTemplate 这一组:
package com.example.nullsafe;
import java.util.List;
import java.util.Map;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
@Repository
public class BookRepository {
private final JdbcTemplate jdbc;
public BookRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public int count() {
return jdbc.queryForObject("select count(*) from book", Integer.class);
}
public int subtitleLength(long id) {
String subtitle = jdbc.queryForObject("select subtitle from book where id = ?", String.class, id);
return subtitle.length();
}
public int subtitleLengthViaRowMapper(long id) {
String subtitle = jdbc.queryForObject("select subtitle from book where id = ?",
(rs, rowNum) -> rs.getString("subtitle"), id);
return subtitle.length();
}
public List<String> subtitles() {
return jdbc.queryForList("select subtitle from book order by id", String.class);
}
public int firstSubtitleLength() {
return jdbc.queryForList("select subtitle from book order by id", String.class).get(0).length();
}
public String subtitleFromMap(long id) {
Map<String, Object> row = jdbc.queryForMap("select * from book where id = ?", id);
return row.get("SUBTITLE").toString();
}
}
GreetingService 读配置、读系统属性、取一个可能不存在的 Clock Bean:
package com.example.nullsafe;
import java.time.Clock;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
@Service
public class GreetingService {
private final Environment env;
private final ObjectProvider<Clock> clock;
public GreetingService(Environment env, ObjectProvider<Clock> clock) {
this.env = env;
this.clock = clock;
}
public String greeting() {
String greeting = env.getProperty("app.greeting");
return greeting.toUpperCase();
}
public String mode() {
return System.getProperty("app.mode").trim();
}
public long now() {
return clock.getIfAvailable().millis();
}
}
RemoteClient 用 RestClient 调接口,最后一个方法读 Jackson 的 JsonNode:
package com.example.nullsafe;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import tools.jackson.databind.JsonNode;
@Component
public class RemoteClient {
private final RestClient restClient;
public RemoteClient(RestClient.Builder builder) {
this.restClient = builder.baseUrl("http://localhost:8080").build();
}
public int bodyLength() {
String body = restClient.get().uri("/books/count").retrieve().body(String.class);
return body.length();
}
public int entityBodyLength() {
ResponseEntity<String> entity = restClient.get().uri("/books/count").retrieve().toEntity(String.class);
return entity.getBody().length();
}
public String contentType() {
ResponseEntity<String> entity = restClient.get().uri("/books/count").retrieve().toEntity(String.class);
return entity.getHeaders().getFirst("Content-Type").trim();
}
public String bookTitle(JsonNode book) {
return book.get("title").asString();
}
}
LocalCache 是一个 HashMap<String, String>,外加调用自己标了 @Nullable 的方法:
package com.example.nullsafe;
import java.util.HashMap;
import java.util.Map;
import org.jspecify.annotations.Nullable;
import org.springframework.stereotype.Component;
@Component
public class LocalCache {
private final Map<String, String> entries = new HashMap<>();
public int lengthOf(String key) {
return entries.get(key).length();
}
public void remember(String key, @Nullable String value) {
entries.put(key, value);
}
public @Nullable String find(String key) {
return entries.get(key);
}
public String findOrFail(String key) {
return find(key).strip();
}
}
不加检查器的时候,这些代码 mvn compile 直接通过:
[INFO] BUILD SUCCESS
接上 Error Prone 和 NullAway
NullAway 是 Error Prone 的一个检查器,Error Prone 又以 javac 插件的方式工作,而且要求跑在 JDK 21 或更高版本上。maven-compiler-plugin 的配置照 Error Prone 安装文档写,再加上 NullAway 的参数。版本号放在 properties 里:
<java.version>21</java.version>
<error-prone.version>2.50.0</error-prone.version>
<nullaway.version>0.14.2</nullaway.version>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>-XDcompilePolicy=simple</arg>
<arg>--should-stop=ifError=FLOW</arg>
<arg>-XDaddTypeAnnotationsToSymbol=true</arg>
<arg>-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:OnlyNullMarked=true -XepOpt:NullAway:CustomContractAnnotations=org.springframework.lang.Contract -XepOpt:NullAway:JSpecifyMode=true</arg>
</compilerArgs>
<annotationProcessorPaths>
<path>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_core</artifactId>
<version>${error-prone.version}</version>
</path>
<path>
<groupId>com.uber.nullaway</groupId>
<artifactId>nullaway</artifactId>
<version>${nullaway.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
OnlyNullMarked=true 让 NullAway 只检查标了 @NullMarked 的代码,CustomContractAnnotations 让它认识 Spring 的 @Contract,这两项是 Spring Framework 空安全文档推荐的配置。JSpecifyMode=true 按完整的 JSpecify 语义检查,泛型也算在内。Error Prone 自带的其他检查我没有关。
上面这段配置一开始没有 -XDaddTypeAnnotationsToSymbol=true 这一行,项目里也没有 .mvn/jvm.config。第一次构建失败在这里:
java.lang.IllegalAccessError: class com.google.errorprone.BaseErrorProneJavaCompiler (in unnamed module @0x6b1321b7) cannot access class com.sun.tools.javac.api.BasicJavacTask (in module jdk.compiler) because module jdk.compiler does not export com.sun.tools.javac.api to unnamed module @0x6b1321b7
Maven 默认在自己的进程里调用 javac,Error Prone 要用的 com.sun.tools.javac 内部包没有导出。按 Error Prone 文档,在项目根目录的 .mvn/jvm.config 里写上这些参数。如果 compiler 插件配置了 fork,就要改成 -J--add-exports=... 的形式写进 compilerArgs。
--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED
再构建,换了一个错误:
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.15.0:compile (default-compile) on project nullsafe-demo: Fatal error compiling: -XDaddTypeAnnotationsToSymbol=true is required by Error Prone on JDK 21 -> [Help 1]
Spring Framework 的空安全文档写的是 JSpecify 模式需要 JDK 22 或更高版本。NullAway 的 JSpecify 文档给了 JDK 21 的办法:从 21.0.8 开始,给 javac 加上 -XDaddTypeAnnotationsToSymbol=true,它就能从字节码里读到类型注解,不过 Oracle JDK 21 不支持这个参数,要用 Temurin、Zulu 这类 OpenJDK 构建。这次报错的是 Error Prone 本身,我用的是 Debian 打包的 OpenJDK 21.0.12.1,在 compilerArgs 里加上这一行就过了:
<arg>-XDaddTypeAnnotationsToSymbol=true</arg>
JSpecify 模式报出的 12 个错误
[ERROR] COMPILATION ERROR :
[INFO] -------------------------------------------------------------
[ERROR] /tmp/nullsafe-demo/src/main/java/com/example/nullsafe/BookRepository.java:[19,35] [NullAway] unboxing of a @Nullable expression 'jdbc.queryForObject("select count(*) from book", Integer.class)'
(see http://t.uber.com/nullaway )
[ERROR] /tmp/nullsafe-demo/src/main/java/com/example/nullsafe/BookRepository.java:[24,24] [NullAway] dereferenced expression 'subtitle' is @Nullable
(see http://t.uber.com/nullaway )
[ERROR] /tmp/nullsafe-demo/src/main/java/com/example/nullsafe/BookRepository.java:[34,33] [NullAway] incompatible types: List<@Nullable String> cannot be converted to List<String>
(see http://t.uber.com/nullaway )
[ERROR] /tmp/nullsafe-demo/src/main/java/com/example/nullsafe/BookRepository.java:[42,29] [NullAway] incompatible types: Map<String, @Nullable Object> cannot be converted to Map<String, Object>
(see http://t.uber.com/nullaway )
[ERROR] /tmp/nullsafe-demo/src/main/java/com/example/nullsafe/BookRepository.java:[43,35] [NullAway] dereferenced expression 'row.get("SUBTITLE")' is @Nullable
(see http://t.uber.com/nullaway )
[ERROR] /tmp/nullsafe-demo/src/main/java/com/example/nullsafe/GreetingService.java:[23,24] [NullAway] dereferenced expression 'greeting' is @Nullable
(see http://t.uber.com/nullaway )
[ERROR] /tmp/nullsafe-demo/src/main/java/com/example/nullsafe/GreetingService.java:[31,38] [NullAway] dereferenced expression 'clock.getIfAvailable()' is @Nullable
(see http://t.uber.com/nullaway )
[ERROR] /tmp/nullsafe-demo/src/main/java/com/example/nullsafe/LocalCache.java:[15,32] [NullAway] dereferenced expression 'entries.get(key)' is @Nullable
(see http://t.uber.com/nullaway )
[ERROR] /tmp/nullsafe-demo/src/main/java/com/example/nullsafe/LocalCache.java:[27,25] [NullAway] dereferenced expression 'find(key)' is @Nullable
(see http://t.uber.com/nullaway )
[ERROR] /tmp/nullsafe-demo/src/main/java/com/example/nullsafe/RemoteClient.java:[20,20] [NullAway] dereferenced expression 'body' is @Nullable
(see http://t.uber.com/nullaway )
[ERROR] /tmp/nullsafe-demo/src/main/java/com/example/nullsafe/RemoteClient.java:[25,32] [NullAway] dereferenced expression 'entity.getBody()' is @Nullable
(see http://t.uber.com/nullaway )
[ERROR] /tmp/nullsafe-demo/src/main/java/com/example/nullsafe/RemoteClient.java:[30,60] [NullAway] dereferenced expression 'entity.getHeaders().getFirst("Content-Type")' is @Nullable
(see http://t.uber.com/nullaway )
[INFO] 12 errors
Spring API 上直接标了 @Nullable 的返回值,代码里一共用了 7 处,全部被拦下。getProperty、getIfAvailable()、body()、getBody()、getFirst() 和 queryForObject(sql, String.class, id) 报的是 dereferenced,queryForObject(sql, Integer.class) 直接返回给 int,报的是 unboxing。先赋给局部变量、隔一行再调方法的写法也一样报,NullAway 是顺着数据流在查。
34 行和 42 行是泛型上的错误。queryForList 给的 List<@Nullable String> 不能当 List<String> 返回,queryForMap 的结果也不能赋给 Map<String, Object>。把 JSpecifyMode=true 去掉再编译一次,这两个错误就没有了,总数变成 10 个:
[INFO] 10 errors
43 行的 row.get("SUBTITLE") 和 LocalCache 第 15 行的 entries.get(key) 在两种模式下都报了。JDK 的 Map 本身没有空值注解,是 NullAway 对 Map.get() 做了专门处理,前面没有判空或者 containsKey 检查的话,就把返回值当成 @Nullable。LocalCache 第 27 行用的是自己代码里 @Nullable 方法的返回值,同样被拦下了。
放过去的 5 处
BookRepository 第 30 行,RowMapper 的 lambda 返回 rs.getString("subtitle"),第 1 本书这里拿到的就是 null,但 T 被推断成了普通的 String,后面的 length() 没有报错。第 38 行 queryForList(...).get(0).length(),queryForList 的返回类型明明是 List<@Nullable String>,取元素用的 List.get 却是 JDK 的方法,同样没报。GreetingService 第 27 行的 System.getProperty,LocalCache 第 19 行把 @Nullable 的 value 放进 Map<String, String>,也都过了。
这 4 处的共同点是落在 JDK 类上。按 NullAway 的配置文档,对没有标注的代码,它默认做乐观假设:参数可以传 null,返回值不是 null。剩下一处是 RemoteClient 第 34 行的 book.get("title").asString(),Jackson 3.1.5 的 jar 里没有 JSpecify 注解,JsonNode.get 也按非空处理。
JSpecifyExperimental 补上了 JDK 的 4 处
NullAway 文档里写着,从 0.14.0 开始,在 JSpecifyMode=true 之外再加 JSpecifyExperimental=true,会启用 jspecify/jdk 提供的 JDK 空值模型,另外还有通配符的检查和推断。改一行配置:
diff --git a/pom.xml b/pom.xml
index cc39560..ad98b2e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -51,7 +51,7 @@
<arg>-XDcompilePolicy=simple</arg>
<arg>--should-stop=ifError=FLOW</arg>
<arg>-XDaddTypeAnnotationsToSymbol=true</arg>
- <arg>-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:OnlyNullMarked=true -XepOpt:NullAway:CustomContractAnnotations=org.springframework.lang.Contract -XepOpt:NullAway:JSpecifyMode=true</arg>
+ <arg>-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:OnlyNullMarked=true -XepOpt:NullAway:CustomContractAnnotations=org.springframework.lang.Contract -XepOpt:NullAway:JSpecifyMode=true -XepOpt:NullAway:JSpecifyExperimental=true</arg>
</compilerArgs>
<annotationProcessorPaths>
<path>
错误从 12 个变成 16 个,多出来的正是前面漏掉的 JDK 那 4 处,下面从日志里摘出这 4 条和末尾的计数:
[ERROR] /tmp/nullsafe-demo/src/main/java/com/example/nullsafe/BookRepository.java:[30,24] [NullAway] dereferenced expression 'subtitle' is @Nullable
(see http://t.uber.com/nullaway )
[ERROR] /tmp/nullsafe-demo/src/main/java/com/example/nullsafe/BookRepository.java:[38,95] [NullAway] dereferenced expression 'jdbc.queryForList("select subtitle from book order by id", String.class).get(0)' is @Nullable
(see http://t.uber.com/nullaway )
[ERROR] /tmp/nullsafe-demo/src/main/java/com/example/nullsafe/GreetingService.java:[27,46] [NullAway] dereferenced expression 'System.getProperty("app.mode")' is @Nullable
(see http://t.uber.com/nullaway )
[ERROR] /tmp/nullsafe-demo/src/main/java/com/example/nullsafe/LocalCache.java:[19,26] [NullAway] passing @Nullable parameter 'value' where @NonNull is required
(see http://t.uber.com/nullaway )
[INFO] 16 errors
RowMapper 那一处这次也报了,ResultSet.getString 在 JDK 模型里是 @Nullable,queryForObject 的 T 跟着推断成了可空的 String。jspecify/jdk 里 System.getProperty 的两个重载返回值都标了 @Nullable,带默认值的那个也一样,所以改的时候还得判空。
Jackson 那一处始终没报
标准模式、JSpecify 模式、加上 JSpecifyExperimental,三种配置下 bookTitle 都能编译通过。测试里传一个没有 title 字段的 JSON 进去:
@Test
void missingJsonFieldStillThrows() {
JsonNode book = JsonMapper.builder().build().readTree("{\"name\":\"Pro Spring\"}");
assertThatNullPointerException().isThrownBy(() -> remote.bookTitle(book))
.satisfies(ex -> System.out.println("[test] " + ex));
}
输出的异常:
[test] java.lang.NullPointerException: Cannot invoke "tools.jackson.databind.JsonNode.asString()" because the return value of "tools.jackson.databind.JsonNode.get(String)" is null
字段不存在时 JsonNode.get(String) 返回 null,这一点编译期没有任何提示。
把报出来的错误改掉
改法都很普通。getProperty 换成 getRequiredProperty,取不到值直接抛 IllegalStateException。getIfAvailable() 换成带 Supplier 的重载,返回值就是非空的 T。Error Prone 默认开着的 StringCaseLocaleUsage 检查还对 toUpperCase() 报了一个警告,顺手补上 Locale.ROOT:
diff --git a/src/main/java/com/example/nullsafe/GreetingService.java b/src/main/java/com/example/nullsafe/GreetingService.java
index 1847054..bb80f52 100644
--- a/src/main/java/com/example/nullsafe/GreetingService.java
+++ b/src/main/java/com/example/nullsafe/GreetingService.java
@@ -1,6 +1,7 @@
package com.example.nullsafe;
import java.time.Clock;
+import java.util.Locale;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.core.env.Environment;
@@ -19,16 +20,16 @@ public class GreetingService {
}
public String greeting() {
- String greeting = env.getProperty("app.greeting");
- return greeting.toUpperCase();
+ return env.getRequiredProperty("app.greeting").toUpperCase(Locale.ROOT);
}
public String mode() {
- return System.getProperty("app.mode").trim();
+ String mode = System.getProperty("app.mode");
+ return (mode != null) ? mode.trim() : "default";
}
public long now() {
- return clock.getIfAvailable().millis();
+ return clock.getIfAvailable(Clock::systemUTC).millis();
}
}
JdbcTemplate 那组里,subtitles() 的返回类型直接写成 List<@Nullable String>,queryForMap 用 Map<String, @Nullable Object> 接,其他地方都是先放进局部变量再判空。改完以后 mvn verify 通过,测试打印出来的 subtitles 里第一个元素就是 null:
[test] subtitles = [null, Seventh Edition]
[test] java.lang.NullPointerException: Cannot invoke "tools.jackson.databind.JsonNode.asString()" because the return value of "tools.jackson.databind.JsonNode.get(String)" is null
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.313 s -- in com.example.nullsafe.NullsafeApplicationTests
[INFO] BUILD SUCCESS
编译多花的时间
同一份改好的代码,分别在不接 Error Prone、只开 JSpecifyMode、再加 JSpecifyExperimental 三种配置下跑 mvn -B -o -q clean test-compile,每种先预热一次,再计 9 次:
$ git log -1 --format='%h %s'
75ba94d Timing: same code, no Error Prone/NullAway
warm-up done, exit=0
run 1: 2223 ms exit=0
run 2: 2248 ms exit=0
run 3: 2453 ms exit=0
run 4: 2397 ms exit=0
run 5: 2224 ms exit=0
run 6: 2242 ms exit=0
run 7: 2250 ms exit=0
run 8: 2244 ms exit=0
run 9: 2393 ms exit=0
$ git log -1 --format='%h %s'
765d883 Timing: JSpecifyMode without JSpecifyExperimental
warm-up done, exit=0
run 1: 4198 ms exit=0
run 2: 3896 ms exit=0
run 3: 3938 ms exit=0
run 4: 3990 ms exit=0
run 5: 3927 ms exit=0
run 6: 3809 ms exit=0
run 7: 4280 ms exit=0
run 8: 4062 ms exit=0
run 9: 4148 ms exit=0
$ git log -1 --format='%h %s'
8a7212f timing script
warm-up done, exit=0
run 1: 4312 ms exit=0
run 2: 4032 ms exit=0
run 3: 4343 ms exit=0
run 4: 4087 ms exit=0
run 5: 3942 ms exit=0
run 6: 4337 ms exit=0
run 7: 4139 ms exit=0
run 8: 3820 ms exit=0
run 9: 4590 ms exit=0
三组的中位数是 2248 ms、3990 ms 和 4139 ms。这个项目的主代码只有 7 个源文件(含 package-info.java),测试只有 1 个类,接上检查器多出来的 1.7 到 1.9 秒是在这个规模上测出来的。JSpecifyExperimental 比只开 JSpecifyMode 多 149 ms,比同一组 9 次之间的波动还小。
一起交流
分享你的思考,让讨论更进一步。