NullPointerException — der ungeliebte Dauergast jeder Java-Codebase. Tony Hoare nannte seine Erfindung der Null-Referenz einmal seinen „Milliarden-Dollar-Fehler“. Moderne Sprachen wie Kotlin und Swift lösen das mit nicht-nullbaren Typen — Java selbst aber nicht.

Zwei Werkzeuge ändern das: JSpecify 1.0 als standardisierte Annotationen und NullAway als Compile-Time-Checker von Uber. Dieser Artikel zeigt, wie du beide kombinierst, um NPEs zu elimieren, bevor sie jemals in Produktion laufen.

Das Problem: Fragmentierte @Nullable-Landschaft

Jeder definiert @Nullable selbst: javax.annotationorg.jetbrains.annotationsorg.checkerframework.checker.nullness.qualedu.umd.cs.findbugs.annotations, … Keines dieser Annotations ist standardisiert, und keines verhält sich tool-übergreifend konsistent.

JSpecify 1.0.0 (Juli 2024) löst das: Ein Konsortium aus Google, Uber, JetBrains und Oracle hat sich auf vier Nullness-Annotationen mit präziser Semantik geeinigt.

JSpecify: Die vier Annotationen

import org.jspecify.annotations.*;

@NullMarked   <em>// Alle nicht-annotierten Typen in diesem Scope sind @NonNull</em>
public class BenutzerService {

    <em>// @Nullable auf Typs — der Rückgabetyp kann null sein</em>
    public @Nullable Benutzer findeNachEmail(String email) {
        return benutzerDb.get(email);  <em>// darf null zurückgeben</em>
    }

    <em>// Ohne @Nullable: @NonNull implizit — der Parameter darf nicht null sein</em>
    public void aktualisiereEmail(Benutzer benutzer, String neueEmail) {
        benutzer.setEmail(neueEmail);  <em>// NPE-safe: benutzer ist garantiert nicht null</em>
    }
}
Code-Sprache: JavaScript (javascript)
AnnotationZweck
@NullableType-Use: Dieser Typ kann null sein
@NullMarkedScope: Alles in diesem Bereich ist default @NonNull
@NullUnmarkedScope: Hebt @NullMarked für einen Teilbereich auf (inkrementelle Migration)
@NonNullType-Use: Explizit nicht null (meist redundant, da Default)

Wichtig: @NullMarked gilt für Parameter, Rückgabewerte und Felder — nicht für lokale Variablen (diese sind durch Flow-Typisierung automatisch erfasst).

NullAway: Der Compile-Time-Enforcer

NullAway (aktuell 0.13.7) ist ein Error-Prone-Plugin, das Null-Verletzungen zur Compile-Zeit erkennt — ohne Laufzeit-Overhead. Anders als der Checker Framework (vollständig, aber langsam) verfolgt NullAway einen pragmatischen Ansatz: Es erkennt ~95% aller NPEs mit weniger als 10% Build-Zeit-Overhead.

Setup mit Gradle (empfohlen)

plugins {
    id 'java'
    id 'net.ltgt.errorprone' version '5.1.0'
}

dependencies {
    errorprone 'com.uber.nullaway:nullaway:0.13.7'
    errorprone 'com.google.errorprone:error_prone_core:2.50.0'
    api 'org.jspecify:jspecify:1.0.0'
}

tasks.withType(JavaCompile).configureEach {
    options.errorprone {
        check('NullAway', CheckSeverity.ERROR)
        option('NullAway:AnnotatedPackages', 'com.meinefirma')
        option('NullAway:JSpecifyMode', 'true')
    }
    if (name.toLowerCase().contains('test')) {
        options.errorprone.disable('NullAway')
    }
}
Code-Sprache: JavaScript (javascript)

Setup mit Maven

<dependencies>
    <dependency>
        <groupId>org.jspecify</groupId>
        <artifactId>jspecify</artifactId>
        <version>1.0.0</version>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.13.0</version>
            <configuration>
                <compilerArgs>
                    <arg>-Xplugin:ErrorProne
                        -Xep:NullAway:ERROR
                        -XepOpt:NullAway:AnnotatedPackages=com.meinefirma
                        -XepOpt:NullAway:JSpecifyMode=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.7</version>
                    </path>
                </annotationProcessorPaths>
            </configuration>
        </plugin>
    </plugins>
</build>
Code-Sprache: HTML, XML (xml)

Was NullAway erkennt

NullAway führt eine intra-prozedurale Datenflussanalyse durch und findet diese typischen Fehler:

Bug 1: null übergeben, wo @NonNull erwartet wird

@NullMarked
public class Validierung {
    static String sichereLaenge(String s) {
        return s.length() + " Zeichen";  <em>// NPE-safe: s ist @NonNull</em>
    }

    static void test() {
        sichereLaenge(null);  <em>// ❌ COMPILE-ERROR: @Nullable an @NonNull übergeben</em>
    }
}
Code-Sprache: JavaScript (javascript)

Bug 2: @Nullable dereferenzieren ohne Null-Check

@NullMarked
public class Validierung {
    static void log(@Nullable Object x) {
        System.out.println(x.toString());  <em>// ❌ COMPILE-ERROR: x ist @Nullable</em>
    }

    static void logFixed(@Nullable Object x) {
        if (x != null) {
            System.out.println(x.toString());  <em>// ✅ OK: Datenfluss erkennt x ≠ null</em>
        }
    }
}
Code-Sprache: JavaScript (javascript)

Bug 3: @NonNull-Feld nicht initialisiert

@NullMarked
public class Person {
    String name;  <em>// @NonNull implizit</em>

    Person() {}  <em>// ❌ COMPILE-ERROR: Field 'name' nicht initialisiert</em>
}
Code-Sprache: JavaScript (javascript)

Bug 4: @Nullable aus @NonNull-Methode zurückgeben

@NullMarked
public class Factory {
    @Nullable String vielleichtNull() { return null; }

    String garantiertNichtNull() {
        return vielleichtNull();  <em>// ❌ COMPILE-ERROR</em>
    }
}
Code-Sprache: JavaScript (javascript)

Inkrementelle Migration mit @NullUnmarked

Bestehende Codebasen migriert man schrittweise:

@NullUnmarked  <em>// Altes Package: Keine Prüfungen</em>
package com.meinefirma.legacy;

<em>// ---</em>

@NullMarked    <em>// Neues Package: Volle Prüfungen</em>
package com.meinefirma.buchhaltung;
Code-Sprache: HTML, XML (xml)

NullAway’s Option NullAway:OnlyNullMarked prüft automatisch nur @NullMarked-Scopes.

JSpecify und Kotlin

Ein großer Vorteil von JSpecify: Kotlin liest die Annotationen direkt. Wenn du eine Java-Bibliothek mit @NullMarked annotierst, erkennt der Kotlin-Compiler deren Nullness-Signaturen:

<em>// Java-Klasse mit JSpecify:</em>
<em>// @NullMarked</em>
<em>// public class Calculator {</em>
<em>//     public @Nullable String format(int n) { ... }</em>
<em>// }</em>

<em>// Kotlin-Consumer erkennt automatisch:</em>
val result: String? = Calculator().format(42)  <em>// ? nullable</em>
<em>// Ohne JSpecify wäre es String! (platform type) → keine Prüfung</em>
Code-Sprache: HTML, XML (xml)

Vergleich der Tools

NullAway + JSpecifyChecker FrameworkIntelliJ Inspections
StandardisiertJa (JSpecify 1.0)NeinNein
Build-Zeit-Overhead<10%ErheblichKeiner (nur IDE)
Erkennt~95%100% (sound)~80% (Heuristiken)
Kotlin-InteropJaNeinTeilweise
InkrementellJa (@NullUnmarked)Ja (@SuppressWarnings)Immer aktiv

Fazit

JSpecify und NullAway zusammen geben Java das, was Kotlin und Swift von Haus aus haben: Compile-Time-Null-Safety. Der Setup-Aufwand ist minimal (eine Dependency, eine Annotation), der Gewinn immens: Keine NullPointerException in annotiertem Code.

Beginne mit neuen Packages unter @NullMarked und migriere bestehenden Code schrittweise. In einem Jahr wirst du dich fragen, wie du jemals ohne leben konntest.