Neuigkeiten von trion.
Immer gut informiert.

Null-Checks in Spring mit JSpecify und Error Prone im Maven-Build

Die NullPointerException ist nach wie vor einer der häufigsten Laufzeitfehler in Java-Anwendungen. Das eigentliche Problem ist selten der einzelne vergessene Check, sondern dass an keiner Stelle festgehalten ist, welche Werte überhaupt null sein dürfen und welche nicht. Diese Information steckt bestenfalls im Kopf der Entwickler oder in der Dokumentation, aber nicht im Typsystem.

JSpecify ist ein herstellerübergreifender Satz von Annotationen, der genau diese Information ausdrückt. Mit Spring Framework 7 und Spring Boot 4 ist das gesamte Spring-API mit diesen Annotationen versehen. Damit die Annotationen nicht nur Dokumentation bleiben, braucht es ein Werkzeug, das sie prüft. Im Maven-Build übernimmt das Error Prone zusammen mit dem NullAway-Plugin von Uber.

Die JSpecify-Annotationen

JSpecify definiert vier Annotationen im Paket org.jspecify.annotations: @Nullable, @NonNull, @NullMarked und @NullUnmarked. Den Unterschied im Alltag macht @NullMarked.

An einem Paket, einer Klasse oder einem Modul angebracht, dreht @NullMarked die Vorannahme um: Innerhalb des markierten Bereichs gilt jeder Typ als nicht null, sofern er nicht ausdrücklich mit @Nullable versehen ist. Man annotiert also nicht mehr die vielen Nicht-null-Fälle, sondern nur noch die wenigen Ausnahmen.

package-info.java: Paket auf non-null umstellen
@NullMarked
package de.trion.orders;

import org.jspecify.annotations.NullMarked;
In einem @NullMarked-Paket ist alles non-null, außer explizit anders deklariert
package de.trion.orders;

import org.jspecify.annotations.Nullable;

public class OrderService {

    // Rückgabe ist non-null, Parameter ist non-null
    public Order load(String id) { ... }

    // Rückgabe darf null sein
    public @Nullable Order find(String id) { ... }
}

Der Gewinn gegenüber früher liegt in der Vereinheitlichung. Bisher brachte praktisch jedes Ökosystem eigene Annotationen mit: Spring, JetBrains, die alte JSR-305 aus FindBugs, das Checker Framework. Werkzeuge mussten alle Varianten kennen, und deren Semantik war jeweils leicht unterschiedlich, insbesondere bei Generics und Arrays. JSpecify legt eine einzige, präzise definierte Semantik fest, die von NullAway, Error Prone und IntelliJ IDEA gleichermaßen verstanden wird. Die Annotationen selbst kommen über eine schlanke Abhängigkeit ins Projekt:

<dependency>
    <groupId>org.jspecify</groupId>
    <artifactId>jspecify</artifactId>
    <version>1.0.1</version>
</dependency>

JSpecify in Spring 7 und Spring Boot 4

Mit Spring Framework 7 und Spring Boot 4 ist der komplette Spring-Code mit JSpecify annotiert. Die Pakete sind @NullMarked, und überall dort, wo eine Methode null zurückgeben oder akzeptieren kann, steht ein @Nullable. Die bisherigen spring-eigenen Annotationen aus org.springframework.lang sind damit abgelöst.

Der praktische Nutzen zeigt sich beim Aufruf von Spring-APIs aus dem eigenen Code. Ruft eine @NullMarked-Klasse eine Spring-Methode auf, die laut Annotation @Nullable liefert, dann weiß der Prüfer das und verlangt eine Behandlung des null-Falls, bevor der Wert dereferenziert wird. Die Information steht dabei konsistent an zwei Stellen zur Verfügung: in der IDE als Inspektion während der Entwicklung und im Build als harte Prüfung. Spring selbst prüft die eigene Konsistenz übrigens auf demselben Weg, nämlich mit NullAway im Spring-Build.

Annotationen prüfen nichts von allein

Die JSpecify-Annotationen haben für sich genommen keine Wirkung. Sie stehen im Bytecode, aber weder der Compiler noch die Laufzeitumgebung leiten daraus eine Prüfung ab. Ohne ein zusätzliches Werkzeug bleiben sie reine Dokumentation.

Für die Prüfung gibt es mehrere Optionen. IntelliJ IDEA wertet die Annotationen als Inspektion aus, was beim Schreiben hilft, aber nicht im CI greift. Das Checker Framework prüft sehr gründlich und formal fundiert, ist dafür aber langsam und aufwendig einzurichten. NullAway von Uber liegt dazwischen: schnell genug, um bei jedem Build mitzulaufen, und darauf ausgelegt, mit möglichst wenig Fehlalarmen den Großteil der realen null-Fehler zu finden. NullAway läuft dabei nicht eigenständig, sondern als Plugin von Error Prone.

Prüfung im Maven-Build mit Error Prone und NullAway

Error Prone klinkt sich als Plugin in den javac-Aufruf ein. Die Einrichtung erfolgt daher komplett über das maven-compiler-plugin: Error Prone und NullAway kommen als Annotation-Processor-Pfade dazu, die eigentliche Aktivierung geschieht über Compiler-Argumente.

Ausschnitt aus der pom.xml
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <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>2.50.0</version>
            </path>
            <path>
                <groupId>com.uber.nullaway</groupId>
                <artifactId>nullaway</artifactId>
                <version>0.13.8</version>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>

-Xep:NullAway:ERROR schaltet NullAway ein und stuft Verstöße als Fehler ein, sodass der Build abbricht. -XepOpt:NullAway:OnlyNullMarked=true sagt NullAway, dass es sich strikt an die JSpecify-Regel halten soll: Nur @NullMarked-Code wird geprüft, alles andere gilt als nicht annotiert. So lässt sich die Prüfung Paket für Paket einführen, ohne die gesamte Codebasis auf einen Schlag umstellen zu müssen.

Seit JDK 16 kapselt der Compiler seine internen APIs, auf die Error Prone zugreift. Die nötigen Freigaben legt man am saubersten in .mvn/jvm.config ab, dann gelten sie für den gesamten Build:

.mvn/jvm.config
--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

Ab jetzt bricht ein mvn verify ab, sobald ein @Nullable-Wert ohne Prüfung dereferenziert wird oder aus einer non-null-Methode null zurückgegeben wird:

[ERROR] OrderService.java:[24,16] [NullAway] dereferenced expression order is @Nullable

Wer die Compiler-Argumente und Prozessor-Pfade nicht selbst pflegen möchte, kann auf das Nullability Maven Plugin zurückgreifen, das diese Konfiguration bündelt. Über -XepOpt:NullAway:JSpecifyMode=true lässt sich zusätzlich die volle JSpecify-Semantik einschließlich der Nullness von Generics aktivieren. Dieser Modus ist allerdings noch in Entwicklung, kann Fehlalarme erzeugen und setzt JDK 22 oder neuer voraus (oder aktuelle Patch-Stände von JDK 17 und 21 mit einem zusätzlichen javac-Flag). Für den Einstieg genügt OnlyNullMarked.

Stärken von Error Prone

Der Unterschied von Error Prone zu klassischen Analysewerkzeugen ist der Ort, an dem es arbeitet. Error Prone läuft im Compiler und bekommt den Syntaxbaum, den javac ohnehin aufbaut, samt vollständig aufgelöster Typen, Symbole und Datenflussinformation. Es rät also nicht anhand von Textmustern, welchen Typ ein Ausdruck hat, sondern kennt ihn. Das ist die Grundlage der Nullness-Prüfung: NullAway baut auf der Datenflussanalyse von Error Prone auf und verfolgt, ob ein Wert an einer Stelle bereits gegen null geprüft wurde.

Das bringt mehrere Vorteile mit sich:

Läuft im selben Schritt wie die Kompilierung.
Es gibt keinen zweiten Parse-Durchlauf und kein separates Werkzeug in der Pipeline. Die Prüfung nutzt dieselbe inkrementelle Kompilierung und meldet Funde als ganz normale Compiler-Fehler.

Sehr wenige Fehlalarme.
Die Standard-Checks von Error Prone sind bewusst so kuratiert, dass sie nahezu keine Fehlalarme erzeugen. Nur deshalb lässt sich ein Fund überhaupt als Fehler behandeln, der den Build abbricht, statt als Warnung, die in der Masse untergeht.

Automatische Korrekturen.
Viele Checks liefern einen Vorschlag zur Behebung mit, der sich über -XepPatchChecks sogar direkt in den Quelltext einspielen lässt.

Erweiterbar.
Eigene Checks lassen sich mit Zugriff auf die volle Compiler-API schreiben, etwa um projekt- oder hausinterne Regeln durchzusetzen.

Zu beachten ist, dass NullAway für die Geschwindigkeit an einigen Stellen bewusst optimistische Annahmen trifft, etwa über nicht annotierte Bibliotheken. Es ist damit nicht so formal vollständig wie das Checker Framework, findet aber im Alltag den Großteil der relevanten Fehler, ohne den Build spürbar zu verlangsamen.

Der Unterschied zu PMD

PMD ist ein verbreitetes Analysewerkzeug, arbeitet aber grundlegend anders. Es bringt einen eigenen Parser und Syntaxbaum mit und wendet darauf ein Regelwerk an. Typen löst PMD nur eingeschränkt selbst auf, es sieht den Code im Kern als Muster, nicht als typisiertes Programm. Das ist die Stärke von PMD für sprachübergreifende Stil- und Konventionsregeln, aber die Schwäche für Korrektheitsanalysen, die exakte Typinformation brauchen.

Die Unterschiede in der Übersicht:

Error Prone (mit NullAway) PMD

Arbeitsgrundlage

typaufgelöster Compiler-Baum

eigener Syntaxbaum, begrenzte Typauflösung

Ausführung

im javac-Lauf

separater Schritt

Fehlalarme

bei den Standard-Checks nahezu null

je nach Regel spürbar höher

Schwerpunkt

Korrektheitsfehler, inklusive Nullness

Stil, Konventionen, einfache Code Smells

Build-Abbruch

zuverlässig als Compiler-Fehler

über Schwellwerte konfigurierbar

In der Praxis kann Error Prone PMD in vielen Fällen ersetzen. Die Funde erscheinen unmittelbar beim Kompilieren und nicht erst in einem nachgelagerten Analyseschritt, und das ohne einen zweiten Parse-Durchlauf über den Quelltext.

Ein weiterer Punkt betrifft neue Java-Versionen. Weil PMD auf seinem eigenen Parser aufsetzt, muss dessen Grammatik jeder Sprachversion erst nachgezogen werden, bevor sich Projekte mit aktuellen Sprachfeatures überhaupt fehlerfrei analysieren lassen. Error Prone nutzt den Parser und die Typauflösung des Compilers selbst und kennt jede Sprachversion damit sofort.

Für die Frage, ob ein Wert null sein kann und ob das im Code korrekt behandelt wird, braucht es ohnehin ein typbewusstes, in den Compiler integriertes Werkzeug. Error Prone und NullAway leisten genau das.

Fazit

JSpecify liefert das gemeinsame Vokabular für Nullness, Spring Framework 7 und Spring Boot 4 sprechen es durchgängig, und Error Prone mit NullAway setzt es im Build durch. Dank @NullMarked lässt sich die Prüfung Paket für Paket einführen, statt eine bestehende Codebasis auf einmal umstellen zu müssen. Der Aufwand beschränkt sich auf etwas Konfiguration in der pom.xml und der jvm.config. Im Gegenzug fällt eine ganze Fehlerklasse schon beim Kompilieren auf und nicht erst im Betrieb.

Die Null-Safety-Dokumentation von Spring und das NullAway-Wiki beschreiben die weiteren Optionen im Detail.



Zu den Themen Java, Spring Boot und Spring Framework bieten wir sowohl Beratung, Entwicklungsunterstützung als auch passende Schulungen an:

Auch für Ihren individuellen Bedarf können wir Workshops und Schulungen anbieten. Sprechen Sie uns gerne an.

Feedback oder Fragen zu einem Artikel - per E-Mail an [email protected] oder über das Kontaktformular. Wir freuen uns auf eine Kontaktaufnahme!

Suche

Los geht's!

Bitte teilen Sie uns mit, wie wir Sie am besten erreichen können.