Share
Embed
JSpecify—Getting Rid of the Billion-Dollar Mistake in Java for Good This Time?
Talk version of my essay https://michael-simons.eu/p/jspecify-and-nullaway-a-fresh-take-on-nullsafety-in-the-java-world.html first run at JCon 2026 in Cologne.
Michael Simons
April 23, 2026
Transcript
60 slides
-
JSpecify Getting Rid of the Billion-Dollar Mistake in Java for
Good This Time? -
🚨 Safe harbour statement 🚨 • This talk or the
speaker might contain or hold personal opinions • I am a maintainer of Spring Data Neo4j, in which JSpecify has been used as part of actual work, not only theoretically • Depending on your position and work, the value of JSpecify and Nullaway might vary Neo4j Inc. All rights reserved 2026 2 Michael Simons Java & Testcontainers Champion, Senior Staff Engineer at Neo4j -
What is a (null) pointer? 5 • A pointer is
an object that stores a memory address • One uses it to obtain the value stored at that location “A pointer is dereferenced” • In C it’s basically #define NULL ((void*)0) But that’s not necessarily the value at memory address 0 • In practice, dereferencing a null pointer may result in an attempted read or write from memory that is not mapped, triggering a segmentation fault or memory access violation. -
Thank god! 6 “C allows for manual memory management, which
gives developers direct control over memory allocation and deallocation. On the other hand, Java uses automatic memory management through garbage collection.” -
Sir Charles Antony Richard Hoare 11.01.1934 - 05.03.2026 8 •
Developed quicksort 1959-1960 • Hoare logic for formal verification of program correctness • Introduced the null-reference to ALGOL W in 1965 “I call it my billion-dollar mistake. It was the invention of the null reference in 1965. At that time, I was designing the first comprehensive type system for references in an object-oriented language (ALGOL W). My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn't resist the temptation to put in a null reference, simply because it was so easy to implement.” -
A brief history about @NonNull-standards 9 • JSR 305: Annotations
for Software Defect Detection from 2006, never published as JSR, available as part of SpotBugs (originally FindBugs) • org.jetbrains.annotations.NotNull: JetBrains specific annotations for static code analysis • org.eclipse.jdt.annotation.NonNull: Eclipse compiler-specific annotations for static code analysis • @NonNull from Project Lombok, not intended as a verification tool • javax.valiation.constraints, more of a tool for validating input values xkcd.com/927 by Randall Munroe. -
JSpecify appeared in 2020 • Not a JSR • But
very similar • Designed by a committee of industry and research experts • Google • JetBrains • Meta • Microsoft • Oracle • Sonar • Uber and • Broadcom (Spring Framework) 10 -
Nullness and what makes JSpecify special 11 Nullness annotations enable
nullness analysis by letting you communicate, for each type usage, whether it is nullable or non-null. A core element of the JSpecify design is that nullness is not binary, but rather ternary: • nullable types • non-null types • and types of unspecified nullness -
What’s in org.jspecify:jspecify:1.0.0? 12 2 pairs of annotations: • @NullMarked
and @NullUnmarked for scoping • @NonNull and @Nullable for expressing nullness in those scopes -
What’s in org.jspecify:jspecify:1.0.0? 13 2 pairs of annotations: • @NullMarked
and @NullUnmarked for scoping • @NonNull and @Nullable for expressing nullness in those scopes Those annotations are marked with @Retention(RUNTIME) meaning they become part of your deployment and are effectively transitive dependencies! Don’t put them into optional or provided scope as dependencies, that won’t fly in the future. -
What’s in org.jspecify:jspecify:1.0.0? 14 2 pairs of annotations: • @NullMarked
and @NullUnmarked for scoping • @NonNull and @Nullable for expressing nullness in those scopes Those annotations are marked with @Retention(RUNTIME) meaning they become part of your deployment and are effectively transitive dependencies! Don’t put them into optional or provided scope as dependencies, that won’t fly in the future. Personal experience with @API Guardian: You can exclude it from compile path, but it gives you various kinds of warnings and issues, especially on the module path. Remember, both API Guardian and JSpecify will become part of your API contract. -
What’s in org.jspecify:jspecify:1.0.0? 15 2 pairs of annotations: • @NullMarked
and @NullUnmarked for scoping • type-use annotations @NonNull and @Nullable for expressing nullness in those scopes Neither are inherited, so you need to annotate both interfaces and implementations. -
Scoping 16 @NullMarked and @NullUnmarked aim for • MODULE •
PACKAGE • TYPE • METHOD • CONSTRUCTOR -
Scoping 17 @NullMarked and @NullUnmarked aim for • MODULE •
PACKAGE • TYPE • METHOD • CONSTRUCTOR That view on packages is a nice lie. Packages are not hierarchical, and you would need to add a package info on each of them to mark all packages as Nullmarked or NullUnmarked. -
Scoping 18 • Use @NullMarked for modules or packages in
which you made sure that all types that are not annotated as nullable, are in fact: Not nullable. • Use @NullUnmarked for single packages in modules or classes in packages for which you want to stick with unspecified nullness. -
Different types 19 • @Nullable String is a different type
than @NonNull String • A typed collection of List<@NonNull String> cannot hold @Nullable Strings. • In type theory, @Nullable String is a union type of null and String, which is not the same thing as String -
Different types 20 • @Nullable String is a different type
than @NonNull String • A typed collection of List<@NonNull String> cannot hold @Nullable Strings. • In type theory, @Nullable String is a union type of null and String, which is not the same thing as String -
Different types 21 • @Nullable String is a different type
than @NonNull String • A typed collection of List<@NonNull String> cannot hold @Nullable Strings. • In type theory, @Nullable String is a union type of null and String, which is not the same thing as String • The as-yet-unnumbered JEP https://openjdk.org/jeps/8303099 “Null-Restricted and Nullable Types” uses the same reasoning (different language though, String? and String!) -
Many different type variables in generic types 22 @NullMarked public
interface List<E extends @Nullable Object> { boolean add(E element); E get(int index); @Nullable E getFirst(); Optional<@NonNull E> maybeFirst(); } -
Turn your project into a nice yellow ocher in one
easy step 23 -
Turn your project into a nice yellow ocher in one
easy step 24 -
Slow down a bit 25 Example code available here: https://codeberg.org/michael-simons/publications/src/branch/main/demos/jspecify
import java.util.Objects; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @NullMarked public final class Calculator { public Integer sum(Integer summand, @Nullable Integer @Nullable ... others) { int sum = Objects.requireNonNull(summand, "One summand is required"); if (others != null) { for (var other : others) { sum += Objects.requireNonNullElse(other, 0); } } return sum; } } -
WAT 27 import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThatNoException; import static org.assertj.core.api.Assertions.assertThatNullPointerException;
class CalculatorTests { @Test void shouldRequireOneSummand() { var calc = new Calculator(); assertThatNullPointerException().isThrownBy(() -> calc.sum(null)) .withMessage("One summand is required"); } @Test void shouldNotFailOnBadUsage() { var calc = new Calculator(); assertThatNoException().isThrownBy(() -> calc.sum(1, (Integer[]) null)); } } -
28 Null, away! The things you have to do by
emulating aspects that should really be part of the language -
What is a NullAway? 29 • It’s an Error Prone
plugin • Reads JSpecify (and other) nullness annotations • Error Prone itself is a javac plugin and augments the type analysis of the Java compiler so that it can detect more errors • NullAway enforces both explicit nullness AND checks on possible null references -
Back to this example… 30 import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatNullPointerException; class CalculatorTests { @Test void shouldRequireOneSummand() { var calc = new Calculator(); assertThatNullPointerException().isThrownBy(() -> calc.sum(null)) .withMessage("One summand is required"); } @Test void shouldNotFailOnBadUsage() { var calc = new Calculator(); assertThatNoException().isThrownBy(() -> calc.sum(1, (Integer[]) null)); } } -
Back to this example… 31 [INFO] --- compiler:3.14.0:testCompile (java-test-compile) @
jspecify --- [INFO] Recompiling the module because of changed dependency. [INFO] Compiling 1 source file with javac [debug release 25] to target/test-classes [INFO] ------------------------------------------------------------- [ERROR] COMPILATION ERROR : [INFO] ------------------------------------------------------------- [ERROR] /Users/msimons/Projects/michael-simons/publications/demos/jspecify/src/test/java/ac/simons/javaspe ktrum/jspecify/CalculatorTests.java:[13,76] [NullAway] passing @Nullable parameter 'null' where @NonNull is required (see http://t.uber.com/nullaway ) [INFO] 1 error [INFO] ------------------------------------------------------------- [INFO] ------------------------------------------------------------------------ [INFO] BUILD FAILURE [INFO] ------------------------------------------------------------------------ -
We can get more errors… 32 package ac.simons.javaspektrum.jspecify; import java.util.Objects;
import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @NullMarked public final class Calculator { public Integer sum(Integer summand, @Nullable Integer @Nullable ... others) { int sum = Objects.requireNonNull(summand, "One summand is required"); if (others != null) { for (var other : others) { sum += Objects.requireNonNullElse(other, 0); } } return sum; } } -
We can get more errors… 34 [INFO] --- compiler:3.14.0:compile (java-compile)
@ jspecify --- [INFO] Recompiling the module because of changed source code. [INFO] Compiling 2 source files with javac [debug release 25] to target/classes [INFO] ------------------------------------------------------------- [ERROR] COMPILATION ERROR : [INFO] ------------------------------------------------------------- [ERROR] /Users/msimons/Projects/michael-simons/publications/demos/jspecify/src/main/java/ac/simons/javaspe ktrum/jspecify/Calculator.java:[12,34] [NullAway] enhanced-for expression others is @Nullable (see http://t.uber.com/nullaway ) [INFO] 1 error [INFO] ------------------------------------------------------------- [INFO] ------------------------------------------------------------------------ [INFO] BUILD FAILURE [INFO] ------------------------------------------------------------------------ -
Why the null check on a non-null argument? 35 import
java.util.Objects; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @NullMarked public final class Calculator { public Integer sum(Integer summand, @Nullable Integer @Nullable ... others) { int sum = Objects.requireNonNull(summand, "One summand is required"); if (others != null) { for (var other : others) { sum += Objects.requireNonNullElse(other, 0); } } return sum; } } -
Why the null check on a non-null argument? 36 •
As a library developer I cannot expect that people configure NullAway or similar • There’s a fairly good chance that the occasional null ends up in the code path -
Why the null check on a non-null argument? 37 •
As a library developer I cannot expect that people configure NullAway or similar • There’s a fairly good chance that the occasional null ends up in the code path • Let’s go import lombok.NonNull; and have it generate the bytecode for java.util.Objects.requireNonNull([field name here], "[field name here] is marked non-null but is null"); already 😈 -
Why the null check on a non-null argument? 38 •
As a library developer I cannot expect that people configure NullAway or similar • There’s a fairly good chance that the occasional null ends up in the code path • Let’s go import lombok.NonNull; and have it generate the bytecode for java.util.Objects.requireNonNull([field name here], "[field name here] is marked non-null but is null"); already 😈 • “Have you tried Kotlin?” -
How an annotation becomes part of your API contract 39
import java.util.Objects; public final class Calculator { public Integer sum(Integer summand, Integer ... others) { int sum = Objects.requireNonNull(summand); if (others != null) { for (var other : others) { sum += Objects.requireNonNullElse(other, 0); } } return sum; } } import kotlin.test.Test import kotlin.test.assertEquals class KalkulatorTests { @Test fun sumShouldWork() { val a: Int? = 1 val b: Int? = null assertEquals(2, Calculator() .sum(a, b, 1)) } } -
How an annotation becomes part of your API contract 40
import java.util.Objects; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @NullMarked public final class Calculator { public Integer sum(Integer summand, @Nullable Integer @Nullable ... others ) { int sum = Objects.requireNonNull(summand); if (others != null) { for (var other : others) { sum += Objects.requireNonNullElse(other, 0); } } return sum; } } import kotlin.test.Test import kotlin.test.assertEquals class KalkulatorTests { @Test fun sumShouldWork() { val a: Int? = 1 val b: Int? = null assertEquals(2, Calculator() .sum(a, b, 1)) } } Kotlin: Argument type mismatch: actual type is 'Int?', but 'Int' was expected. -
How an annotation becomes part of your API contract 41
import java.util.Objects; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @NullMarked public final class Calculator { public Integer sum(Integer summand, @Nullable Integer @Nullable ... others ) { int sum = Objects.requireNonNull(summand); if (others != null) { for (var other : others) { sum += Objects.requireNonNullElse(other, 0); } } return sum; } } import kotlin.test.Test import kotlin.test.assertEquals class KalkulatorTests { @Test fun sumShouldWork() { val a: Int? = 1 val b: Int? = null assertEquals(2, Calculator() .sum(a!!, b, 1)) } } -
Making Error Prone / NullAway work 43 • Documentation is
very Gradle centric • Maven documentation is sparse • Error Prone deeply hooks into Java internals… -
Making Error Prone / NullAway work 44 --add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-exports
jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.main=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 • Documentation is very Gradle centric • Maven is sparse • Error Prone deeply hooks into Java internals… • I did keep a jvm.config like this around -
Making Error Prone / NullAway work 45 • The rest
are basically just compiler options, and “easy” -
Configure a compile policy other than “by-todo” (Error Prone suggests
simple) 46 <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration combine.self="append"> <compilerArgs> <arg>-XDcompilePolicy=simple</arg> </compilerArgs> <annotationProcessorPaths> <path> <groupId>com.google.errorprone</groupId> <artifactId>error_prone_core</artifactId> <version>${errorprone.version}</version> </path> <path> <groupId>com.uber.nullaway</groupId> <artifactId>nullaway</artifactId> <version>${nullaway.version}</version> </path> </annotationProcessorPaths> </configuration> </plugin> -
Break the flow on error 47 <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration
combine.self="append"> <compilerArgs> <arg>-XDcompilePolicy=simple</arg> <arg>--should-stop=ifError=FLOW</arg> </compilerArgs> <annotationProcessorPaths> <path> <groupId>com.google.errorprone</groupId> <artifactId>error_prone_core</artifactId> <version>${errorprone.version}</version> </path> <path> <groupId>com.uber.nullaway</groupId> <artifactId>nullaway</artifactId> <version>${nullaway.version}</version> </path> </annotationProcessorPaths> </configuration> </plugin> -
Activate Error Prone and NullAway, scan only null marked scopes
48 <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration combine.self="append"> <compilerArgs> <arg>-XDcompilePolicy=simple</arg> <arg>--should-stop=ifError=FLOW</arg> <arg>-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:OnlyNullMarked=true</arg> </compilerArgs> <annotationProcessorPaths> <path> <groupId>com.google.errorprone</groupId> <artifactId>error_prone_core</artifactId> <version>${errorprone.version}</version> </path> <path> <groupId>com.uber.nullaway</groupId> <artifactId>nullaway</artifactId> <version>${nullaway.version}</version> </path> </annotationProcessorPaths> </configuration> </plugin> -
Or just use a plugin… 50 • am.ik.maven:nullability-maven-plugin is your
friend: https://github.com/making/nullability-maven-plugin <plugin> <groupId>am.ik.maven</groupId> <artifactId>nullability-maven-plugin</artifactId> <version>0.3.0</version> <extensions>true</extensions> <configuration> <checking>tests</checking> </configuration> <executions> <execution> <goals> <goal>configure</goal> </goals> </execution> </executions> </plugin> -
You are an application developer using a null safe framework
52 • Absolutely go for JSpecify / NullAway setup, especially on a greenfield project, it will get pretty close to what Hoare wanted to have in the beginning • Existing projects are harder. Spring Data Neo4j was no fun to enhance in one go. You are a library developer • Depends. • Right now, JSpecify is picked up by a lot of tools and frameworks • I don’t expect the annotations to change much • => you can be part of that ecosystem, however, you have to cater for the fact that you can still end up with a null reference if the users didn’t shush null away -
Steps 53 1. Look for nullable type usages 2. Add
@NullMarked 3. Run nullness analysis on the annotated code 4. Run nullness analysis on calling code -
My works 54 • Spring Data Neo4j: ✅ • Neo4j-Migrations:
✅ (About 5 hours active working time, 61 changed files with about 500 additions and 300 deletions) • Neo4j-JDBC: Definitely not. JDBC is a standing specification. • Cypher-DSL: I’d love too when time permits. • Neo4j internals: Working on a new module atm, could be interesting for our own sanity, same as with “normal” applications. -
How about your favorite AI tool? 56 • When done
100%, this is all static analysis • You don’t need a glorified pattern recognition framework for it • It may or may not help you sprinkling annotations all over your codebase in a meaningful way -
How about your favorite AI tool? 57 • Example: Made
the field @Nullable (correct), ignored the JavaDoc on the wither, I got an Error Prone error that my check is superflous => Correct would be making the parameter @Nullable too… Details, I guess… 🤷 (Fun fact: The comment is OG Claude) /** * Configures placeholders that will be resolved in Cypher scripts using the * syntax {@code ${nm:key}}. Programmatic placeholders take precedence over * placeholders defined via environment variables with the prefix * {@value Defaults#ENVIRONMENT_VARIABLE_PREFIX_PLACEHOLDERS}. * @param newPlaceholders a map of placeholder names to their values, may be * {@literal null} to use only environment variable-based placeholders * @return the builder for further customization * @since 3.3.0 */ public Builder withPlaceholders(Map<String, String> newPlaceholders) { this.placeholders = (newPlaceholders != null) ? Map.copyOf(newPlaceholders) : null; return this; } -
What about the JVM and Java? 58 • Reactive programming
-> Project Loom / Virtual threads • GraalVM Native Image compilation (AoT) -> Project Leyden (Class Data Sharing (CDS), Profile Guided Optimisations (PGO), Ahead-of-time Class Loading & Linking JEP-483) • JSpecify -> “Null-Restricted and Nullable Types” ??? -
JSpecify Annotations are cheap, intent is hard, verification is everything
(Impro subtitle courtesy of Markus’ Keynote) -
Thank you! Neo4j Inc. All rights reserved 2026 60 Contakt
[email protected] Social media @[email protected] Profil https://michael-simons.eu I write books