Zum Inhalt springen

Pattern Matching in Java: instanceof, switch und Record Patterns

Eine Murmelbahn aus hellem Ahorn auf cremefarbenem Grund. Eine Rinne mit einer grünen Glaspyramide und einer grünen Glasscheibe führt von links in einen Block mit vier Ausgängen – rund, quadratisch, dreieckig und ohne Form. Aus dem runden rollt eine leuchtende Bernsteinkugel, aus dem quadratischen ein Glaswürfel

Pattern Matching prüft, ob ein Wert eine bestimmte Form hat, und zerlegt ihn im selben Schritt in die Teile, die du brauchst. In Java heißt das: Statt mit instanceof den Typ zu prüfen und danach zu casten, schreibst du den Typ und einen Variablennamen direkt in die Prüfung. Und statt die Accessor-Methoden eines Records aufzurufen, zerlegst du ihn direkt in seine Komponenten.

Die folgende Methode zeigt fast alles, worum es in diesem Artikel geht:

record Point(int x, int y) {}

String describe(Object obj) {
  return switch (obj) {
    case String s when s.length() > 5 -> "long string: " + s;
    case String s                     -> "string: " + s;
    case Integer i                    -> "integer, squared: " + i * i;
    case Point(int x, int y)          -> "point at " + x + "/" + y;
    case null                         -> "null";
    default                           -> "something else";
  };
}

Für die Argumente "Hello, world", "Hello", 12, new Point(3, 4), 3.14 und null liefert sie:

long string: Hello, world
string: Hello
integer, squared: 144
point at 3/4
something else
null

Jeder case prüft obj gegen ein Pattern. Das Pattern String s passt auf jeden String und bindet ihn an die Variable s. Die Bedingung when s.length() > 5 schränkt den Fall auf lange Strings ein. Das Pattern Point(int x, int y) passt auf jeden Point und bindet seine beiden Komponenten an x und y. Und case null fängt den Fall ab, der ohne ihn zu einer NullPointerException führt.

Pattern Matching ist in Java schrittweise gewachsen: Patterns in instanceof gibt es seit Java 16, Patterns in switch und Record Patterns seit Java 21, den Unterstrich für unbenannte Patterns seit Java 22. Primitive Typen in Patterns sind in Java 27 im fünften Preview.

In diesem Artikel erfährst du,

  • was ein Pattern ist und welche Arten von Patterns es gibt,
  • was der instanceof-Operator prüft und warum er mit Generics nur eingeschränkt funktioniert,
  • wie du mit Pattern Matching for instanceof den Cast nach einem instanceof sparst,
  • wo eine Pattern-Variable gültig ist,
  • wie du mit Pattern Matching for switch nach Typen verzweigst, mit when einschränkst und null behandelst,
  • warum der Compiler auf die Reihenfolge der Fälle achtet und wann ein switch vollständig ist,
  • wie du Records mit Record Patterns zerlegst, auch verschachtelt,
  • wie du Sealed Interfaces, Records und switch kombinierst,
  • welche Fehler beim Pattern Matching typisch sind.

Die Beispiele in diesem Artikel

Die Beispiele sind fast alle Compact Source Files: Dateien mit einer void main()-Methode ohne umschließende Klasse, die du mit java Datei.java direkt starten kannst. Compact Source Files und die Klasse IO, mit der die Beispiele ihre Ergebnisse auf der Konsole ausgeben, gibt es seit Java 25. Die Beispiele laufen deshalb erst ab Java 25. Die Features selbst sind älter; seit wann es sie gibt, steht im jeweiligen Abschnitt.

Den vollständigen Code findest du im GitHub-Repository java-pattern-matching, jedes Listing als eigene Datei. Die lauffähigen Beispiele liegen in src/main/java/ und erzeugen die gezeigten Ausgaben. Die Listings mit Compilerfehler liegen in does-not-compile/, das Preview-Beispiel in preview/.

Was ist Pattern Matching?

Ein Pattern (deutsch: Muster) besteht aus einem Test und aus Variablen. Das Pattern String s z. B. testet, ob ein Wert ein String ist. Wenn der Wert passt, bindet das Pattern ihn an die Pattern-Variable s – mit dem Typ String, ohne Cast.

Patterns stehen an zwei Stellen: rechts von instanceof und hinter case in einem switch. Java kennt vier Arten von Patterns:

PatternBeispielPrüftSeit
Type PatternString sden TypJava 16 (instanceof),
Java 21 (switch)
Record PatternPoint(int x, int y)den Typ und jede KomponenteJava 21
unbenanntes Pattern_nichts – passt auf jede Komponente eines RecordsJava 22
Primitive Type Patternbyte bob der Wert in den Typ passtPreview seit Java 23

Die ersten drei sind final, das vierte ist ein Preview-Feature. Alle vier erkläre ich im Laufe dieses Artikels. Zuerst kamen Patterns zum instanceof-Operator – den sehen wir uns deshalb als Erstes an, noch ohne Patterns.

Der instanceof-Operator

x instanceof T liefert true, wenn x nicht null ist und sich ohne ClassCastException in den Typ T casten lässt – also wenn das Objekt eine Instanz der Klasse T oder einer ihrer Unterklassen ist oder das Interface T implementiert.

Das folgende Listing prüft zunächst den String "Hello" gegen vier verschiedene Typen und dann null gegen Object:

Object obj = "Hello";
IO.println(obj instanceof String);
IO.println(obj instanceof CharSequence);
IO.println(obj instanceof Comparable);
IO.println(obj instanceof Integer);
IO.println();

Object nothing = null;
IO.println(nothing instanceof Object);
true
true
true
false

false

String implementiert die Interfaces CharSequence und Comparable, deshalb liefern die ersten drei Prüfungen true. Ein String ist kein Integer, das ergibt das erste false.

Das zweite false steht für null: null ist keine Instanz irgendeines Typs, nicht einmal von Object. Eine null-Prüfung vor dem Test, etwa obj != null && obj instanceof String, brauchst du deshalb nicht: Wenn obj null ist, liefert obj instanceof String ohnehin false.

Wenn ein Test nie true ergeben kann, weist der Compiler ihn ab. Ein Number-Objekt z. B. kann nie ein String sein:

void check(Number number) {
  if (number instanceof String) {
    IO.println("never");
  }
}
error: incompatible types: Number cannot be converted to String
  if (number instanceof String) {
      ^

Dieselbe Prüfung bezieht versiegelte Klassenhierarchien ein; Beispiele dafür stehen im Abschnitt instanceof-Tests mit versiegelten Klassen des Artikels über Sealed Classes.

instanceof und Generics

Zur Laufzeit trägt ein Objekt keine Typargumente mehr (die sogenannte Type Erasure): Eine ArrayList<String> ist dann nur eine ArrayList. Deshalb lässt der Compiler einen Test auf List<String> bei einem Object nicht zu:

void check(Object obj) {
  if (obj instanceof List<String> list) {
    IO.println(list.size());
  }
}
error: Object cannot be safely cast to List<String>
  if (obj instanceof List<String> list) {
      ^

Erlaubt ist der Test dann, wenn der Compiler das Typargument aus dem Typ der Variablen ableiten kann. Eine List<String> ist, wenn sie eine ArrayList ist, immer eine ArrayList<String>:

List<String> names = new ArrayList<>(List.of("Anna", "Ben"));
if (names instanceof ArrayList<String> arrayList) {
  arrayList.ensureCapacity(100);
  IO.println("ArrayList with " + arrayList.size() + " elements");
}
ArrayList with 2 elements

Das geht seit Java 16; bis Java 15 meldete der Compiler hier „illegal generic type for instanceof“. Wenn du das Typargument nicht kennst, schreibst du den Wildcard-Typ: obj instanceof List<?> list.

Vor Java 16: instanceof und Cast

Bis Java 15 folgte auf fast jeden instanceof-Test ein Cast:

void print(Object obj) {
  if (obj instanceof String) {
    String s = (String) obj;
    IO.println("string of length " + s.length());
  } else if (obj instanceof Integer) {
    Integer i = (Integer) obj;
    IO.println("integer, squared: " + i * i);
  }
}

Für "Hello" und 12 gibt die Methode aus:

string of length 5
integer, squared: 144

Jeder Zweig nennt seinen Typ dreimal: im Test, in der Variablendeklaration nach dem Test und im Cast. Wenn der Test auf einen Typ prüft und der Cast einen anderen nennt, kompiliert der Code trotzdem – den Fehler meldet erst zur Laufzeit eine ClassCastException.

Die folgende Methode prüft z. B., ob obj eine Number ist, castet aber auf Integer:

void print(Object obj) {
  if (obj instanceof Number) {
    Integer i = (Integer) obj;
    IO.println("integer, squared: " + i * i);
  }
}

print(12) und print(12L) geben aus:

integer, squared: 144
Exception in thread "main" java.lang.ClassCastException: class java.lang.Long cannot be cast to class java.lang.Integer (java.lang.Long and java.lang.Integer are in module java.base of loader 'bootstrap')
    at InstanceofWrongCast.print(InstanceofWrongCast.java:3)
    at InstanceofWrongCast.main(InstanceofWrongCast.java:10)

Der Compiler lässt den Cast zu, weil Integer eine Unterklasse von Number ist. Ob obj zur Laufzeit ein Integer ist oder ein anderer Untertyp von Number wie Long, kann er nicht prüfen.

Pattern Matching for instanceof

Seit Java 16 kann rechts von instanceof auch ein Type Pattern stehen. Die Methode print() mit den Zweigen für String und Integer sieht damit so aus:

void print(Object obj) {
  if (obj instanceof String s) {
    IO.println("string of length " + s.length());
  } else if (obj instanceof Integer i) {
    IO.println("integer, squared: " + i * i);
  }
}
string of length 5
integer, squared: 144

Wenn obj ein String ist, liefert der erste Test true und bindet obj an die Pattern-Variable s vom Typ String. Wenn obj kein String ist – oder null –, liefert der erste Test false, und s bekommt keinen Wert.

Dann prüft der else if-Zweig auf dieselbe Weise, ob obj ein Integer ist, und bindet obj in diesem Fall an die Pattern-Variable i vom Typ Integer. In beiden Zweigen steht der Typ nur noch einmal da statt dreimal.

Der Gültigkeitsbereich einer Pattern-Variablen

Eine Pattern-Variable gilt überall dort, wo der Compiler sicher weiß, dass das Pattern gepasst hat. JEP 394 nennt das Flow Scoping: Der Gültigkeitsbereich folgt dem Kontrollfluss, nicht den geschweiften Klammern.

Bei if (obj instanceof String s) gilt s also im if-Block. Steht rechts vom Test noch ein &&, gilt s auch auf dessen rechter Seite – und weiterhin im if-Block, der dann nur ausgeführt wird, wenn auch die rechte Seite true ist.

Die folgende Methode prüft deshalb Typ und Länge in derselben Bedingung:

void print(Object obj) {
  if (obj instanceof String s && s.length() > 5) {
    IO.println("long string: " + s);
  } else if (obj instanceof Integer i) {
    IO.println("integer, squared: " + i * i);
  } else {
    IO.println("something else");
  }
}

Für "Hello, world", "Hello" und 12 gibt sie aus:

long string: Hello, world
something else
integer, squared: 144

s.length() wird nur ausgewertet, wenn obj ein String ist, denn der Operator && wertet seine rechte Seite nur aus, wenn die linke true ist.

Bei || ist es umgekehrt: Die rechte Seite wird nur ausgewertet, wenn die linke false ist – also gerade dann, wenn das Pattern nicht passt und s keinen Wert hat. Der Compiler kennt s dort deshalb nicht:

void check(Object obj) {
  if (obj instanceof String s || s.isEmpty()) {
    IO.println("string or empty");
  }
}
error: cannot find symbol
  if (obj instanceof String s || s.isEmpty()) {
                                 ^
  symbol:   variable s

Flow Scoping funktioniert auch mit der Negation. Wenn der if-Block die Methode verlässt, sobald das Pattern nicht passt, gilt die Pattern-Variable nach dem if:

int length(Object obj) {
  if (!(obj instanceof String s)) {
    return -1;
  }
  return s.length();
}

Für "Hello" und 12 liefert die Methode:

5
-1

So prüfst du Vorbedingungen am Anfang einer Methode und arbeitest danach ohne weitere Einrückung mit der Pattern-Variablen.

Pattern Matching in equals()

Eine equals()-Methode prüft zuerst, ob das andere Objekt den richtigen Typ hat, und vergleicht dann die Felder. Mit Pattern Matching und && wird daraus ein einziger Ausdruck:

final class Money {
  private final long cents;
  private final String currency;

  Money(long cents, String currency) {
    this.cents = cents;
    this.currency = currency;
  }

  @Override
  public boolean equals(Object o) {
    return o instanceof Money other
        && cents == other.cents
        && currency.equals(other.currency);
  }

  @Override
  public int hashCode() {
    return Long.hashCode(cents) * 31 + currency.hashCode();
  }
}

Die drei Vergleiche new Money(999, "EUR").equals(…) mit new Money(999, "EUR"), new Money(999, "USD") und dem String "9.99 EUR" liefern:

true
false
false

Den null-Fall deckt instanceof mit ab: equals(null) liefert false, weil null instanceof Money false ist.

Für eine Klasse, die nur Werte trägt, brauchst du equals() übrigens gar nicht selbst zu schreiben: Ein Record erzeugt equals() und hashCode() automatisch aus seinen Komponenten.

Pattern-Variablen und gleichnamige Variablen

Eine Pattern-Variable verdeckt ein gleichnamiges Feld (englisch: Shadowing) – wie jede lokale Variable. Das Feld erreichst du dann über this:

public class PatternShadowing {
  private String s = "field";

  void print(Object obj) {
    IO.println(s);
    if (obj instanceof String s) {
      IO.println(s);
      IO.println(this.s);
    }
  }

  void main() {
    print("pattern variable");
  }
}
field
pattern variable
field

Eine lokale Variable mit demselben Namen lässt der Compiler dagegen nicht zu:

void print(Object obj) {
  String s = "local variable";
  if (obj instanceof String s) {
    IO.println(s);
  }
}
error: variable s is already defined in method print(Object)
  if (obj instanceof String s) {
                            ^

Pattern-Variablen sind nicht final

Du darfst einer Pattern-Variablen einen neuen Wert zuweisen:

void print(Object obj) {
  if (obj instanceof String s) {
    s = s.trim();
    IO.println("[" + s + "]");
  }
}

Für den String " Hello " mit je zwei Leerzeichen am Anfang und am Ende gibt die Methode [Hello] aus.

In den beiden Previews in Java 14 und Java 15 waren Pattern-Variablen noch implizit final; seit Java 16 verhalten sie sich wie lokale Variablen. Wenn der Compiler eine Zuweisung verhindern soll, schreibst du final davor: obj instanceof final String s.

Pattern Matching for switch

Wenn eine if-Kette denselben Wert gegen mehrere Typen prüft, wird sie schnell lang:

String describe(Object obj) {
  if (obj instanceof String s && s.length() > 5) {
    return "long string: " + s;
  } else if (obj instanceof String s) {
    return "string: " + s;
  } else if (obj instanceof Integer i) {
    return "integer, squared: " + i * i;
  } else {
    return "something else";
  }
}

Seit Java 21 kannst du dieselbe Logik als switch mit Patterns schreiben:

String describe(Object obj) {
  return switch (obj) {
    case String s when s.length() > 5 -> "long string: " + s;
    case String s                     -> "string: " + s;
    case Integer i                    -> "integer, squared: " + i * i;
    default                           -> "something else";
  };
}

Beide Methoden liefern für "Hello, world", "Hello", 12 und 3.14:

long string: Hello, world
string: Hello
integer, squared: 144
something else

Beim switch steht obj nur noch einmal da, und jeder Fall belegt eine Zeile. Der switch prüft die Fälle von oben nach unten und führt den ersten Fall aus, dessen Pattern passt.

Die Pfeilnotation und switch als Ausdruck mit Rückgabewert gibt es seit Java 14; beides erklärt der Artikel über Switch Expressions. Pattern Matching for switch funktioniert in der Pfeilnotation (case … ->) wie in der klassischen Doppelpunktnotation (case …:) und als Statement wie als Ausdruck.

Neu ist auch der Typ des Selektors, also des Werts in den Klammern hinter switch. Bis Java 20 durfte er nur char, byte, short, int, deren Wrapper-Klassen, String oder ein Enum sein. Seit Java 21 ist jeder Referenztyp erlaubt.

Guards mit when

Ein Type Pattern prüft nur den Typ. Weitere Bedingungen hängst du mit when an, wie in case String s when s.length() > 5 oben. Der Ausdruck hinter when heißt Guard (Wächter). Er darf die Pattern-Variablen des Falls verwenden und muss true oder false liefern.

Warum when und nicht && wie bei instanceof? Bei instanceof ist der ganze Test obj instanceof String s ein boolescher Ausdruck, und der Operator && verknüpft ihn mit einem zweiten booleschen Ausdruck. Hinter case steht dagegen kein boolescher Ausdruck, sondern ein Pattern: String s allein liefert weder true noch false. Für die zusätzliche Bedingung braucht ein case deshalb ein eigenes Schlüsselwort.

Ein Guard darf auch lokale Variablen von außerhalb des switch verwenden, aber nur, wenn sie final oder effectively final sind, also nach der Initialisierung nicht mehr geändert werden. Die folgende Methode ändert limit vor dem switch:

String describe(Object obj, int limit) {
  limit++;
  return switch (obj) {
    case String s when s.length() > limit -> "long string: " + s;
    default                               -> "something else";
  };
}
error: local variables referenced from a guard must be final or effectively final
    case String s when s.length() > limit -> "long string: " + s;
                                    ^

Dieselbe Regel gilt für Lambda-Ausdrücke.

case null

Ein switch wirft eine NullPointerException, wenn der Selektor null ist – das galt schon immer und gilt weiterhin. Die Methode describe() aus dem Abschnitt Pattern Matching for switch führt also für null keinen ihrer Fälle aus, auch nicht default:

Exception in thread "main" java.lang.NullPointerException
    at java.base/java.util.Objects.requireNonNull(Objects.java:220)

Seit Java 21 kannst du null mit einem eigenen Fall behandeln. case null steht allein oder zusammen mit default als case null, default:

String describe(Object obj) {
  return switch (obj) {
    case String s  -> "string: " + s;
    case Integer i -> "integer, squared: " + i * i;
    case null      -> "null";
    default        -> "something else";
  };
}

String describeOrDefault(Object obj) {
  return switch (obj) {
    case String s      -> "string: " + s;
    case Integer i     -> "integer, squared: " + i * i;
    case null, default -> "null or something else";
  };
}

describe(null), describeOrDefault(null) und describeOrDefault(3.14) liefern:

null
null or something else
null or something else

case null funktioniert in jedem switch mit einem Referenztyp als Selektor, auch ohne Patterns. Ein klassischer switch über Strings fängt null damit ebenso ab:

String httpMethod(String method) {
  return switch (method) {
    case "GET", "HEAD" -> "read-only";
    case "POST", "PUT" -> "writes data";
    case null          -> "no method given";
    default            -> "unknown: " + method;
  };
}

httpMethod("GET") und httpMethod(null) liefern:

read-only
no method given

Die Reihenfolge der Fälle: Dominanz

Da der switch den ersten passenden Fall ausführt, kann ein früher Fall einen späteren vollständig verdecken. Der Compiler prüft das und meldet einen Fall, der nie erreicht wird, als dominiert (englisch: dominated).

Das passiert, wenn ein Obertyp vor seinem Untertyp steht. Jeder Integer ist eine Number, der zweite Fall wird also nie ausgeführt:

String describe(Object obj) {
  return switch (obj) {
    case Number n  -> "number: " + n;
    case Integer i -> "integer: " + i;
    default        -> "something else";
  };
}
error: this case label is dominated by a preceding case label
    case Integer i -> "integer: " + i;
         ^

In der richtigen Reihenfolge – erst der speziellere Typ, dann der allgemeinere – kompiliert die Methode:

String describe(Object obj) {
  return switch (obj) {
    case Integer i -> "integer: " + i;
    case Number n  -> "some other number: " + n;
    default        -> "something else";
  };
}

Für 12, 3.14 und "Hello" liefert sie:

integer: 12
some other number: 3.14
something else

Nach demselben Prinzip gelten drei weitere Regeln.

Fall mit Guard vor Fall ohne Guard

Der Fall mit Guard muss vor dem Fall ohne Guard stehen. Wenn case String s vorne steht, passt er auf jeden String, und der Fall mit Guard dahinter wird nie erreicht:

String describe(Object obj) {
  return switch (obj) {
    case String s                     -> "string: " + s;
    case String s when s.length() > 5 -> "long string: " + s;
    default                           -> "something else";
  };
}
error: this case label is dominated by a preceding case label
    case String s when s.length() > 5 -> "long string: " + s;
         ^

In der richtigen Reihenfolge prüft der switch zuerst die Bedingung und fängt alle übrigen Strings mit dem Fall ohne Guard ab:

String describe(Object obj) {
  return switch (obj) {
    case String s when s.length() > 5 -> "long string: " + s;
    case String s                     -> "string: " + s;
    default                           -> "something else";
  };
}

Für "Hello, world", "Hello" und 12 liefert die Methode:

long string: Hello, world
string: Hello
something else

Konstante vor Pattern

Eine Konstante muss vor dem Pattern ihres Typs stehen. case "Hello" hinter case String s wird nie erreicht, denn jeder String passt schon auf das Pattern:

String greet(String text) {
  return switch (text) {
    case String s -> "text: " + s;
    case "Hello"  -> "Hello back";
  };
}
error: this case label is dominated by a preceding case label
    case "Hello"  -> "Hello back";
         ^

Mit der Konstanten vorne kompiliert die Methode:

String greet(String text) {
  return switch (text) {
    case "Hello"  -> "Hello back";
    case String s -> "text: " + s;
  };
}

greet("Hello") und greet("Hi") liefern:

Hello back
text: Hi

default hinter allen Patterns

default muss hinter allen Patterns stehen. Wenn default vor case String s steht, meldet der Compiler case String s als dominiert:

String describe(Object obj) {
  return switch (obj) {
    default       -> "something else";
    case String s -> "string: " + s;
  };
}
error: this case label is dominated by a preceding case label
    case String s -> "string: " + s;
         ^

Mit default am Ende kompiliert die Methode:

String describe(Object obj) {
  return switch (obj) {
    case String s -> "string: " + s;
    default       -> "something else";
  };
}

Für "Hello" und 12 liefert sie:

string: Hello
something else

Vollständigkeit: wann ein switch kein default braucht

Ein switch mit Patterns muss vollständig sein (englisch: exhaustive): Für jeden möglichen Wert des Selektors muss ein Fall passen. Das gilt für Switch-Ausdrücke wie für Switch-Statements. Ein Statement über Object, das nur Strings und Integers behandelt, weist der Compiler ab:

void print(Object obj) {
  switch (obj) {
    case String s  -> IO.println("string: " + s);
    case Integer i -> IO.println("integer, squared: " + i * i);
  }
}
error: the switch statement does not cover all possible input values
  switch (obj) {
  ^
  missing patterns:
      Object _

Hier fehlt ein default. Ein Switch-Statement, das weder Patterns noch case null verwendet, muss dagegen weiterhin nicht vollständig sein – damit jedes bestehende Switch-Statement unverändert kompiliert.

Ohne default kommt ein switch aus, wenn seine Fälle alle möglichen Typen abdecken. Das ist bei einer versiegelten Klassenhierarchie möglich, denn die Sealed Classes legen fest, welche Klassen ein Interface implementieren dürfen:

sealed interface Shape permits Circle, Square, Rectangle {}
record Circle(double radius) implements Shape {}
record Square(double side) implements Shape {}
record Rectangle(double width, double height) implements Shape {}

double area(Shape shape) {
  return switch (shape) {
    case Circle c    -> Math.PI * c.radius() * c.radius();
    case Square s    -> s.side() * s.side();
    case Rectangle r -> r.width() * r.height();
  };
}

Für einen Kreis mit Radius 1, ein Quadrat mit Seitenlänge 2 und ein Rechteck mit 2 × 3 liefert die Methode:

3.141592653589793
4.0
6.0

Wenn einer der drei Fälle fehlt, z. B. Rectangle, meldet der Compiler:

error: the switch expression does not cover all possible input values
  return switch (shape) {
         ^
  missing patterns:
      Rectangle _

Der Nutzen liegt in Folgendem: Wenn eine vierte Form hinzukommt, meldet der Compiler jeden switch über Shape, der die vierte Form noch nicht behandelt, als unvollständig – damit zeigt er uns die Stellen im Code, die wir anpassen müssen. Ein default-Fall hingegen würde die neue Form stillschweigend abfangen – deshalb empfehle ich, bei versiegelten Typen auf default zu verzichten.

Guards zählen bei der Vollständigkeit nicht mit, denn der Compiler wertet ihre Bedingungen nicht aus. Das kann er auch gar nicht: Sie hängen von Werten ab, die erst zur Laufzeit feststehen.

Die folgenden drei Fälle decken jede Zahl ab, trotzdem weist der Compiler den switch ab:

String sign(Integer number) {
  return switch (number) {
    case Integer i when i < 0  -> "negative";
    case Integer i when i == 0 -> "zero";
    case Integer i when i > 0  -> "positive";
  };
}
error: the switch expression does not cover all possible input values
  return switch (number) {
         ^
  missing patterns:
      Integer _

Lass beim letzten Fall den Guard weg – case Integer i -> "positive" –, dann ist der switch vollständig.

Qualifizierte Enum-Konstanten

Bis Java 20 durfte eine Enum-Konstante nur in einem switch über ihr eigenes Enum stehen, und dann nur mit ihrem einfachen Namen. Ein switch über das Enum CompassDirection sah beispielsweise so aus:

enum CompassDirection { NORTH, SOUTH, EAST, WEST }

String fly(CompassDirection direction) {
  return switch (direction) {
    case NORTH -> "Flying north";
    case SOUTH -> "Flying south";
    case EAST  -> "Flying east";
    case WEST  -> "Flying west";
  };
}

Mit dem Namen des Enums davor, z. B. case CompassDirection.NORTH, meldete der Compiler, etwa in Java 17, „an enum switch case label must be the unqualified name of an enumeration constant“.

Seit Java 21 darf eine Enum-Konstante auch qualifiziert sein, also mit dem Namen ihres Enums im case stehen, z. B. case CompassDirection.NORTH. Damit kannst du über ein versiegeltes Interface verzweigen, das von mehreren Enums implementiert wird:

sealed interface Direction permits CompassDirection, VerticalDirection {}
enum CompassDirection implements Direction { NORTH, SOUTH, EAST, WEST }
enum VerticalDirection implements Direction { UP, DOWN }

String fly(Direction direction) {
  return switch (direction) {
    case CompassDirection.NORTH -> "Flying north";
    case CompassDirection.SOUTH -> "Flying south";
    case CompassDirection.EAST  -> "Flying east";
    case CompassDirection.WEST  -> "Flying west";
    case VerticalDirection.UP   -> "Gaining altitude";
    case VerticalDirection.DOWN -> "Losing altitude";
  };
}

fly(CompassDirection.EAST) und fly(VerticalDirection.UP) liefern:

Flying east
Gaining altitude

Der Selektor hat den Typ Direction, kein Enum-Typ, sondern ein versiegeltes Interface, das beide Enum-Typen implementieren. Bis Java 20 wäre dieser switch deshalb nicht möglich gewesen. Da der switch alle sechs Konstanten beider Enums abdeckt, ist er vollständig und braucht kein default.

Pattern-Variablen im switch: Gültigkeit und Fall-through

Eine Pattern-Variable gilt in ihrem Fall: im Guard und auf der rechten Seite des Pfeils. Zwei Fälle dürfen deshalb Variablen mit demselben Namen deklarieren.

In der Doppelpunktnotation fällt die Ausführung ohne break in den nächsten Fall durch. In einen Fall mit Pattern darf sie das nicht, denn dessen Pattern-Variable hätte dann keinen Wert:

void print(Object obj) {
  switch (obj) {
    case Integer i:
      IO.println("integer: " + i);
    case String s:
      IO.println("string: " + s);
      break;
    default:
      IO.println("something else");
  }
}
error: illegal fall-through to a pattern
    case String s:
         ^
  (the previous case label is missing a break)

Aus demselben Grund darf ein Fall nicht mehrere Patterns mit Variablen tragen. case Integer i, Long l weist der Compiler ab, denn bei einem Integer hätte l keinen Wert und bei einem Long i nicht:

error: illegal fall-through from a pattern
    case Integer i, Long l -> "integral number";
                    ^

Seit Java 22 geht das mit unbenannten Patterns, die keine Variable binden:

String classify(Number number) {
  return switch (number) {
    case Byte _, Short _, Integer _, Long _ -> "integral number";
    case Float _, Double _                  -> "floating-point number";
    default                                 -> "other number";
  };
}

classify(12), classify(3.14) und classify(new BigDecimal("1.5")) liefern:

integral number
floating-point number
other number

Mehr zum Unterstrich steht im Artikel über unbenannte Variablen und Patterns.

Record Patterns

Ein Record ist ein transparenter Träger seiner Komponenten: Sein Konstruktor nimmt die Komponenten entgegen, seine Accessor-Methoden geben sie heraus. Ein Record Pattern nutzt das und zerlegt einen Record direkt in seine Komponenten. Record Patterns gibt es seit Java 21.

Mit einem Type Pattern prüfst du beispielsweise einen Point und rufst danach seine Accessor-Methoden auf:

record Point(int x, int y) {}

void print(Object obj) {
  if (obj instanceof Point p) {
    IO.println("point at " + p.x() + "/" + p.y());
  }
}

Mit einem Record Pattern hingegen bindest du die Komponenten direkt an Variablen (im folgenden Beispiel int x und int y):

record Point(int x, int y) {}

void print(Object obj) {
  if (obj instanceof Point(int x, int y)) {
    IO.println("point at " + x + "/" + y);
  }
}

Beide Methoden geben für new Point(3, 4) aus:

point at 3/4

Das Record Pattern Point(int x, int y) passt, wenn obj ein Point ist. Dann ruft es die Accessor-Methoden x() und y() auf und bindet ihre Werte an die Variablen x und y. Die Komponenten stehen im Pattern in derselben Reihenfolge wie in der Deklaration des Records; ihre Namen im Pattern sind frei wählbar.

Im switch kannst du Record Patterns mit Guards kombinieren:

String describe(Point point) {
  return switch (point) {
    case Point(int x, int y) when x == 0 && y == 0 -> "origin";
    case Point(int x, int y) when x == y           -> "on the diagonal: " + x;
    case Point(int x, int y)                       -> "point at " + x + "/" + y;
  };
}

Für new Point(0, 0), new Point(5, 5) und new Point(3, 4) liefert die Methode:

origin
on the diagonal: 5
point at 3/4

Eine Konstante an der Stelle einer Komponente, etwa case Point(0, int y), ist nicht erlaubt; der Compiler meldet einen Syntaxfehler. Bedingungen an die Werte der Komponenten stehen deshalb im Guard.

Verschachtelte Record Patterns

Wenn eine Komponente selbst ein Record ist, kannst du auch sie im selben Pattern zerlegen. Ohne Record Pattern sieht das für eine Linie aus zwei Punkten so aus:

record Point(int x, int y) {}
record Line(Point from, Point to) {}

void print(Object obj) {
  if (obj instanceof Line line) {
    Point from = line.from();
    Point to = line.to();
    if (from != null && to != null) {
      IO.println("line from " + from.x() + "/" + from.y()
          + " to " + to.x() + "/" + to.y());
    }
  }
}

Mit einem verschachtelten Record Pattern wird das deutlich kürzer:

record Point(int x, int y) {}
record Line(Point from, Point to) {}

void print(Object obj) {
  if (obj instanceof Line(Point(int x1, int y1), Point(int x2, int y2))) {
    IO.println("line from " + x1 + "/" + y1 + " to " + x2 + "/" + y2);
  }
}

Beide Methoden geben für eine Linie von Point(1, 2) nach Point(5, 7) aus:

line from 1/2 to 5/7

Die folgende Grafik zeigt, wie das Pattern die Linie zerlegt. Jedes Record Pattern prüft den Typ seines Objekts und ruft dessen Accessor-Methoden auf; die Pfeile tragen die Namen dieser Methoden. Ihre Ergebnisse prüfen die inneren Patterns, und die vier Type Patterns in der unteren Reihe binden die Werte an die Variablen:

Verschachteltes Record Pattern: Das Pattern Line(Point(int x1, int y1), Point(int x2, int y2)) zerlegt eine Line in zwei Points und bindet x1 = 1, y1 = 2, x2 = 5, y2 = 7
Ein verschachteltes Record Pattern zerlegt die Linie in einem Schritt bis zu den vier Koordinaten

Die Variante ohne Record Pattern prüft from und to auf null, weil die Variante mit Record Pattern das ebenfalls tut: Ein Record Pattern passt nie auf null. Wenn from oder to null ist, passt das verschachtelte Pattern nicht, und die Methode gibt nichts aus.

var und Generics

Statt der Typen der Komponenten darfst du var schreiben. Der Compiler leitet den Typ dann aus der Deklaration des Records ab – bei einem generischen Record auch aus dessen Typargument:

record Pair<T>(T first, T second) {}

void print(Pair<String> pair) {
  if (pair instanceof Pair(var first, var second)) {
    IO.println(first.toUpperCase() + " " + second.length());
  }
}

Für new Pair<>("Hello", "world") gibt die Methode aus:

HELLO 5

Das Pattern Pair(var first, var second) trägt kein Typargument. Der Compiler leitet es aus dem Typ von pair ab – Pair<String> – und gibt first und second den Typ String. Deshalb kompilieren first.toUpperCase() und second.length() ohne Cast.

Der Unterstrich: unbenannte Patterns

Wenn du eine Komponente nicht brauchst, schreibst du seit Java 22 einen Unterstrich _ an ihre Stelle. Das unbenannte Pattern passt auf jeden Wert der Komponente und bindet keine Variable:

String start(Object obj) {
  return switch (obj) {
    case Line(Point(var x, var y), _) -> "line starting at " + x + "/" + y;
    default                           -> "not a line";
  };
}

Für die Linie aus dem vorigen Abschnitt liefert die Methode:

line starting at 1/2

Den Unterstrich gibt es auch als Variablennamen in einem Type Pattern, z. B. case Integer _. Beide Formen erklärt der Artikel über unbenannte Variablen und Patterns.

Record Patterns und null

Ein Record Pattern passt nie auf null. Ein Type Pattern in einer Komponente passt dagegen auch auf null, wenn es jeden möglichen Wert der Komponente abdeckt – so wie Point to bei einer Komponente vom Typ Point. Das folgende Listing prüft null und eine Linie, deren Endpunkt null ist, gegen fünf Patterns:

record Point(int x, int y) {}
record Line(Point from, Point to) {}

void main() {
  Object nothing = null;
  IO.println(nothing instanceof Point(int x, int y));

  Line half = new Line(new Point(1, 2), null);
  IO.println(half instanceof Line(Point from, Point to));
  IO.println(half instanceof Line(var from, var to));
  IO.println(half instanceof Line(Point(int x1, int y1), Point to));
  IO.println(half instanceof Line(Point(int x1, int y1), Point(int x2, int y2)));
}
false
true
true
true
false

Die fünf Zeilen der Reihe nach:

  1. null passt auf kein Record Pattern.
  2. Das Type Pattern Point to deckt jeden Wert der Komponente to ab und passt deshalb auch auf null; to ist dann null.
  3. Das Pattern var to verhält sich wie Point to.
  4. Der Startpunkt ist ein Point und passt auf Point(int x1, int y1); der Endpunkt passt auf Point to.
  5. Der Endpunkt ist null und passt deshalb nicht auf das Record Pattern Point(int x2, int y2).

Wenn du die Komponenten eines Records brauchst, der null sein kann, liefert ein verschachteltes Record Pattern also die null-Prüfung mit.

Wenn eine Accessor-Methode eine Exception wirft

Ein Record Pattern ruft die Accessor-Methoden des Records auf. Wenn eine davon eine Exception wirft, verpackt der switch – oder instanceof – sie in eine MatchException. Das folgende Listing überschreibt die Accessor-Methode celsius() so, dass sie bei einem fehlenden Messwert (repräsentiert als Double.NaN) eine Exception wirft:

record Temperature(double celsius) {
  @Override
  public double celsius() {
    if (Double.isNaN(celsius)) {
      throw new IllegalStateException("not measured");
    }
    return celsius;
  }
}

String describe(Object obj) {
  return switch (obj) {
    case Temperature(double celsius) -> celsius + " °C";
    default                          -> "something else";
  };
}

describe(new Temperature(21.5)) liefert 21.5 °C. Für den fehlenden Messwert, also describe(new Temperature(Double.NaN)), fängt das folgende Listing die Exception ab und gibt sie zusammen mit ihrer Ursache aus:

try {
  IO.println(describe(new Temperature(Double.NaN)));
} catch (MatchException e) {
  IO.println(e);
  IO.println("cause: " + e.getCause());
}
java.lang.MatchException: java.lang.IllegalStateException: not measured
cause: java.lang.IllegalStateException: not measured

Die MatchException trägt die ursprüngliche Exception als Ursache. Am sichersten ist es, wenn Accessor-Methoden keine Exceptions werfen: Prüf ungültige Werte schon im Konstruktor des Records.

Alles zusammen: Sealed Interfaces, Records und switch

Sealed Interfaces, Records und switch mit Patterns ergänzen sich: Das versiegelte Interface legt fest, welche Records es gibt, die Records tragen die Daten, und der switch verzweigt über sie, vom Compiler auf Vollständigkeit geprüft. Das folgende Beispiel beschreibt arithmetische Ausdrücke wie (2 + 3) * 4 als Baum aus Records:

sealed interface Expr permits Const, Neg, Add, Mul {}
record Const(int value) implements Expr {}
record Neg(Expr operand) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}

Const ist eine Zahl, Neg die Negation eines Ausdrucks, Add und Mul die Summe und das Produkt zweier Ausdrücke. Der Ausdruck (2 + 3) * 4 ist damit ein Mul, dessen linker Operand ein Add ist:

Ausdrucksbaum für (2 + 3) * 4: Mul mit den Operanden Add(Const(2), Const(3)) und Const(4)
Der Ausdruck (2 + 3) * 4 als Baum aus Records

Um einen solchen Baum auszuwerten, verzweigt die Methode eval() mit einem switch über die vier Records und ruft sich für die Operanden selbst auf. Die Methode format() erzeugt auf dieselbe Weise den Ausdruck als Text:

int eval(Expr expr) {
  return switch (expr) {
    case Const(int value)           -> value;
    case Neg(Expr operand)          -> -eval(operand);
    case Add(Expr left, Expr right) -> eval(left) + eval(right);
    case Mul(Expr left, Expr right) -> eval(left) * eval(right);
  };
}

String format(Expr expr) {
  return switch (expr) {
    case Const(int value)           -> String.valueOf(value);
    case Neg(Expr operand)          -> "(-" + format(operand) + ")";
    case Add(Expr left, Expr right) ->
        "(" + format(left) + " + " + format(right) + ")";
    case Mul(Expr left, Expr right) -> format(left) + " * " + format(right);
  };
}

Für den Baum aus der Grafik – new Mul(new Add(new Const(2), new Const(3)), new Const(4)) – gibt format(expr) + " = " + eval(expr) aus:

(2 + 3) * 4 = 20

Keiner der beiden Switch-Ausdrücke braucht ein default, denn Expr ist versiegelt und jeder der vier Records hat seinen Fall.

Ausdrücke vereinfachen mit verschachtelten Patterns

Verschachtelte Patterns zeigen ihren Nutzen, wenn eine Methode nicht nur den obersten Knoten, sondern auch dessen Kinder prüft. Die folgende Methode simplify() vereinfacht einen Ausdruck nach fünf Regeln:

  • Ein Produkt mit dem linken Faktor 0 wird zu 0.
  • Ein Produkt mit dem linken Faktor 1 wird zum rechten Faktor.
  • Ein Produkt mit dem rechten Faktor 1 wird zum linken Faktor.
  • Eine Summe mit dem linken Summanden 0 wird zum rechten Summanden.
  • Eine doppelte Negation wird zum Ausdruck ohne Negation.

Jede Regel ist ein Fall im switch:

Expr simplify(Expr expr) {
  return switch (expr) {
    case Mul(Const(int c), Expr _) when c == 0 -> new Const(0);
    case Mul(Const(int c), Expr e) when c == 1 -> simplify(e);
    case Mul(Expr e, Const(int c)) when c == 1 -> simplify(e);
    case Add(Const(int c), Expr e) when c == 0 -> simplify(e);
    case Neg(Neg(Expr e))                      -> simplify(e);
    case Neg(Expr e)                           -> new Neg(simplify(e));
    case Add(Expr l, Expr r)                   -> new Add(simplify(l), simplify(r));
    case Mul(Expr l, Expr r)                   -> new Mul(simplify(l), simplify(r));
    case Const c                               -> c;
  };
}

Der Fall Mul(Const(int c), Expr e) when c == 1 z. B. passt auf ein Produkt, dessen linker Operand die Konstante 1 ist, und liefert den vereinfachten rechten Operanden. Die letzten vier Fälle haben keinen Guard und decken zusammen alle vier Records ab – damit ist der switch vollständig. Drei von ihnen vereinfachen die Operanden, der vierte gibt eine Konstante unverändert zurück.

Den Ausdruck 1 * -(-(0 + 7)) – als Baum new Mul(new Const(1), new Neg(new Neg(new Add(new Const(0), new Const(7))))) – vereinfacht simplify() in einem Durchgang zu 7:

1 * (-(-(0 + 7))) = 7
7 = 7

Pattern Matching statt Visitor Pattern

Ohne Pattern Matching bleiben für eine Verzweigung über die Records des Ausdrucksbaums zwei Wege: eine instanceof-Kette, die der Compiler nicht auf Vollständigkeit prüft, oder das Visitor Pattern mit einem Visitor-Interface, einer visit()-Methode pro Record, einer accept()-Methode in jedem Record und einer Visitor-Klasse pro Operation.

Mit Pattern Matching besteht jede Operation – eval(), format() und simplify() – aus einer einzigen Methode mit einem switch, statt aus einer Visitor-Klasse mit vier visit()-Methoden. Die Records brauchen keine accept()-Methode und bleiben reine Datenträger.

Wenn ein fünfter Record hinzukommt, z. B. Div für die Division, meldet der Compiler die Switch-Ausdrücke in eval(), format() und simplify() als unvollständig. Du musst dann alle drei Methoden um einen Fall für Div ergänzen, bevor der Code wieder kompiliert. Bei einer Hierarchie, die du selbst kontrollierst, ist das gewollt.

Wenn dagegen fremder Code neue Untertypen hinzufügen können soll, passt das Modell nicht: Ein versiegeltes Interface lässt nur die Klassen zu, die in seiner permits-Liste stehen.

Primitive Typen in Patterns (Preview)

Seit Java 23 gibt es Patterns auch für primitive Typen, als Preview-Feature. In Java 27 ist es mit JEP 532 im fünften Preview. Ein Primitive Type Pattern wie byte b prüft, ob ein Wert ohne Verlust in den Typ passt:

String fits(int value) {
  return switch (value) {
    case byte b  -> "fits into a byte: " + b;
    case short s -> "fits into a short: " + s;
    case int i   -> "needs an int: " + i;
  };
}

Mit --enable-preview in Java 27 liefert die Methode für 100, 1_000 und 100_000:

fits into a byte: 100
fits into a short: 1000
needs an int: 100000

Welche Regeln für primitive Typen im Detail gelten und wie sie sich mit Wrapper-Klassen verhalten, zeigt der Artikel Primitive Typen in Patterns, instanceof und switch.

Häufige Fehler

Die folgenden Fehler zeigen die Abschnitte oben jeweils ausführlich:

  • Der switch hat kein case null. Wenn der Selektor null ist, wirft der switch eine NullPointerException – auch mit default. Ein case null fängt den Fall ab.
  • Der allgemeinere Typ steht vor dem spezielleren. Der Compiler meldet den späteren Fall als dominiert; setz den Untertyp vor den Obertyp.
  • Der letzte Fall hat einen Guard. Guards zählen bei der Vollständigkeit nicht mit; lass den Guard beim letzten Fall eines Typs weg.
  • Ein default in einem switch über einen versiegelten Typ. Er fängt neue Untertypen stillschweigend ab, statt sie vom Compiler melden zu lassen.
  • Eine Pattern-Variable rechts von ||. Dort hat sie keinen Wert, und der Compiler kennt sie nicht – der Gültigkeitsbereich folgt &&, nicht ||.
  • Eine Konstante im Record Pattern. Point(0, int y) ist (noch) nicht erlaubt; die Bedingung gehört in einen Guard mit when.
  • Eine Accessor-Methode, die eine Exception wirft. Ein Record Pattern macht daraus eine MatchException.

Geschichte

Pattern Matching ist ein Teil von Project Amber und kam über mehrere Preview-Runden in Java an:

FeaturePreviewsfinal
Pattern Matching for instanceofJava 14 (JEP 305),
Java 15 (JEP 375)
Java 16 (JEP 394)
Pattern Matching for switchJava 17 (JEP 406),
Java 18 (JEP 420),
Java 19 (JEP 427),
Java 20 (JEP 433)
Java 21 (JEP 441)
Record PatternsJava 19 (JEP 405),
Java 20 (JEP 432)
Java 21 (JEP 440)
Unnamed Variables & PatternsJava 21 (JEP 443)Java 22 (JEP 456)
Primitive Types in PatternsJava 23 (JEP 455),
Java 24 (JEP 488),
Java 25 (JEP 507),
Java 26 (JEP 530),
Java 27 (JEP 532)
–

Drei Formen aus den Previews gibt es in der finalen Fassung nicht mehr:

  • Guards mit &&: In Java 17 und 18 stand die Bedingung mit && am Pattern, etwa case String s && s.length() > 5. Seit Java 19 heißt es when.
  • Benannte Record Patterns: In Java 19 durfte ein Record Pattern zusätzlich eine Variable für den ganzen Record binden, etwa Point(int x, int y) p. Java 20 hat das wieder entfernt.
  • Record Patterns in der for-each-Schleife: Java 20 erlaubte for (Point(int x, int y) : points). Java 21 hat das gestrichen, mit der Aussicht, es in einem zukünftigen JEP erneut vorzuschlagen.

Die Details der einzelnen Runden stehen in den Artikeln zu Java 14, Java 15, Java 16, Java 17, Java 18, Java 19, Java 20 und Java 21.

Zusammenfassung

Ein Pattern besteht aus einem Test und aus Variablen, an die es die passenden Teile eines Werts bindet. Seit Java 16 kann ein Type Pattern wie String s rechts von instanceof stehen und den Cast danach ersetzen. Die Pattern-Variable gilt dort, wo das Pattern sicher gepasst hat: im if-Block, rechts von && und nach einem if, das bei Nichtpassen die Methode verlässt.

Seit Java 21 kann ein switch über Patterns verzweigen. Ein Guard mit when schränkt einen Fall ein, ein case null fängt null ab, das sonst zu einer NullPointerException führt. Der Compiler meldet Fälle, die ein früherer Fall verdeckt, und einen switch mit Patterns, der nicht alle möglichen Werte abdeckt.

Record Patterns wie Point(int x, int y) zerlegen einen Record in seine Komponenten, auch verschachtelt und mit var. Ein Record Pattern passt nie auf null. Seit Java 22 kann ein Unterstrich die Komponenten ersetzen, die du nicht brauchst.

Mit Sealed Classes kann der Compiler prüfen, ob ein switch jeden Untertyp behandelt. Wenn ein Untertyp hinzukommt, meldet er jeden switch, der ihn noch nicht behandelt, als unvollständig.

Meine Empfehlungen:

  • Schreib instanceof mit Pattern statt mit anschließendem Cast – in jedem Code ab Java 16.
  • Wenn du einen Wert gegen mehrere Typen prüfst, nimm einen switch mit Patterns statt einer if-Kette. Der Compiler prüft dann Reihenfolge und Vollständigkeit.
  • Verzichte bei versiegelten Typen auf default, damit der Compiler neue Untertypen meldet.
  • Schreib case null, wenn der Selektor null sein kann – sonst wirft der switch eine NullPointerException.
  • Schreib Bedingungen an Komponenten in einen Guard mit when, nicht in ein if rechts vom Pfeil. Dann prüft der switch bei nicht erfüllter Bedingung den nächsten Fall.

Wie es mit Patterns für primitive Typen weitergeht, zeigt der Artikel Primitive Typen in Patterns, instanceof und switch.

Was soll ich als Nächstes vertiefen? Das erfahre ich am besten durch dein Feedback: Eine Bewertung auf meinem ProvenExpert-Profil zeigt mir, welche Themen dir wichtig sind – und motiviert mich, weitere Artikel zu schreiben.

👉 Bewertung abgeben

Dieses Thema im eigenen Code?

Du hast den Artikel gelesen – im Training arbeitet ihr damit. 2 Tage lang gehen wir die Themen an euren eigenen Projekten durch, statt an konstruierten Beispielen.

Praxisnah, verständlich und direkt auf euren Projektalltag übertragbar. Statt Theorie vermittle ich Prinzipien, die euch helfen, Code langfristig besser, wartbarer und performanter zu schreiben.

Java 21 SchulungAlle Trainings ansehen

Werde ein:e bessere:r Java-Entwickler:in

Mit meinem kostenlosen Newsletter bleibst du vorn. Modernes Java: neue Versionen & Features, Performance und JVM-Insights – 1x im Monat.

Suche