Upgrade to Pro — share decks privately, control downloads, hide ads and more …

JSpecify—Getting Rid of the Billion-Dollar Mist...

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on.

JSpecify—Getting Rid of the Billion-Dollar Mistake in Java for Good This Time?

Avatar for Michael Simons

Michael Simons

April 23, 2026

More Decks by Michael Simons

Other Decks in Programming

Transcript

  1. 🚨 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
  2. 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.
  3. 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.”
  4. 7

  5. 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.”
  6. 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.
  7. 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
  8. 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
  9. 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
  10. 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.
  11. 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.
  12. 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.
  13. Scoping 16 @NullMarked and @NullUnmarked aim for • MODULE •

    PACKAGE • TYPE • METHOD • CONSTRUCTOR
  14. 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.
  15. 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.
  16. 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
  17. 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
  18. 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!)
  19. 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(); }
  20. 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; } }
  21. 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)); } }
  22. 28 Null, away! The things you have to do by

    emulating aspects that should really be part of the language
  23. 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
  24. 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)); } }
  25. 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] ------------------------------------------------------------------------
  26. 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; } }
  27. 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] ------------------------------------------------------------------------
  28. 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; } }
  29. 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
  30. 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 😈
  31. 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?”
  32. 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)) } }
  33. 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.
  34. 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)) } }
  35. Making Error Prone / NullAway work 43 • Documentation is

    very Gradle centric • Maven documentation is sparse • Error Prone deeply hooks into Java internals…
  36. 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
  37. Making Error Prone / NullAway work 45 • The rest

    are basically just compiler options, and “easy”
  38. 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>
  39. 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>
  40. 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>
  41. 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>
  42. 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
  43. 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
  44. 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.
  45. 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
  46. 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; }
  47. 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” ???
  48. Thank you! Neo4j Inc. All rights reserved 2026 60 Contakt

    [email protected] Social media @[email protected] Profil https://michael-simons.eu I write books