
Records sind eine von zwei großen Neuerungen in Java 16 (die zweite ist „Pattern Matching for instanceof“). In diesem Artikel erfährst du:
- Was sind Java Records, und wofür brauchen wir sie?
- Wie implementiert und benutzt man Records in Java?
- Wie kann man einen Java Record um zusätzliche Funktionen erweitern?
- Was ist im Zusammenhang mit der Vererbung wichtig?
- Wie arbeiten Records mit Pattern Matching zusammen?
- Was ist bei Reflection, Serialisierung und Deserialisierung von Records zu beachten?
- Wozu braucht man Records, wenn man Konstruktor, Getter,
equals(),hashCode()undtoString()auch von der IDE oder von Lombok erzeugen lassen kann?
Fangen wir mit einem Beispiel aus den Zeiten vor Records an...
Wofür brauchen wir Records?
Nehmen wir an, wir wollen eine unveränderliche Klasse Point erstellen, mit x- und y-Koordinaten und allem, was man braucht, um diese Klasse sinnvoll einzusetzen. Wir wollen Point-Objekte erstellen, deren Felder auslesen und sie in Sets speichern oder als Keys in Maps verwenden können.
Heraus käme dabei in etwa der folgende Code:
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
@Override
public boolean equals(Object obj) {
if (obj == this) return true;
if (obj == null || obj.getClass() != this.getClass()) return false;
Point that = (Point) obj;
return this.x == that.x && this.y == that.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
@Override
public String toString() {
return "Point[x=%d, y=%d]".formatted(x, y);
}
}
Das ist schon eine ganze Menge Boilerplate-Code für die Anforderung „eine Klasse mit x- und y-Werten“.
Wer Lombok in den eigenen Projekten einsetzen wollte und durfte, war klar im Vorteil. Lombok kann Konstruktoren, Getter, equals()-, hashCode()- und toString()-Methoden automatisch erstellen. Der Code reduziert sich dadurch auf wenige Zeilen:
@AllArgsConstructor
@Getter
@EqualsAndHashCode
@ToString
public class Point {
private final int x;
private final int y;
}
Das ist schon deutlich komfortabler. Lombok ist ausgereift und integriert sich nahtlos in fast jede IDE. Ich setze es seit über zehn Jahren immer wieder gerne ein.
Seit Java 16 geht es allerdings noch kürzer:
public record Point(int x, int y) {}
Mit Records wird aus den ursprünglich 22 Zeilen – bzw. den 7 Zeilen mit Lombok – nur noch eine Zeile! Das ist nicht nur kürzer, sondern auch sicherer (s. Abschnitte Java Records vs. Klassen und Java Records vs. Lombok).
Schauen wir uns im Folgenden an, wie genau man Records schreibt und wie man sie nutzt.
Wie implementiert und benutzt man Records in Java?
Im vorherigen Abschnitt haben wir gesehen, wie man einen Record mit nur einer Zeile Code schreibt:
public record Point(int x, int y) {}
Der Compiler generiert daraus die Klasse Point mit:
- den finalen Feldern
int xundint y(den sogenannten „Komponenten“ des Records), - einem Konstruktor, der beide Felder setzt (dem sogenannten „kanonischen Konstruktor“),
- den Accessor-Methoden
x()undy()zum Lesen der Komponenten, - einer
equals()-Methode, die zweiPoint-Instanzen als gleich einstuft, wenn derenx- undy-Koordinaten gleich sind, - einer
hashCode()-Methode, die für zwei gleichePoint-Instanzen den gleichen Hashwert liefert (imPoint-Beispiel wird der Hashcode alsx * 31 + yberechnet), - einer
toString()-Methode, die einen lesbaren Text liefert (im BeispielPoint[x=..., y=...]).
Aus einer Zeile Quellcode wird also eine vollständige Klasse:
Du kannst Point wie eine reguläre Klasse einsetzen (in der ersten Zeile des folgenden Code-Beispiels wird der oben erwähnte, automatisch generierte „kanonische Konstruktor“ aufgerufen):
Point p = new Point(5, 10);
int x = p.x();
int y = p.y();
Vergleichen kannst du zwei Punkte dann bspw. wie folgt:
Point p1 = new Point(8, 4);
Point p2 = new Point(4, 3);
if (p1.equals(p2)) {
// ...
}
Java Record Konstruktoren
Im vorangegangenen Abschnitt hast du gelernt, dass der Compiler automatisch einen Konstruktor erzeugt, den sogenannten kanonischen Konstruktor. In diesem Kapitel erfährst du, wie du diesen kanonischen Konstruktor überschreiben kannst, wie du sogenannte „kompakte“ Konstruktoren schreiben kannst – und beliebige weitere nicht-kanonische Konstruktoren.
Die folgende Grafik zeigt die drei Formen im Überblick – die Details folgen in den nächsten Abschnitten.
Kanonischen Konstruktor eines Records überschreiben
Wir können den kanonischen Konstruktor eines Records auch selbst implementieren:
public record Point(int x, int y) {
/** Canonical constructor as the compiler would generate it */
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
Sinn ergibt das aber erst, wenn wir vor oder nach der Zuweisung der Record-Felder zusätzlichen Code ausführen – z. B. könnten wir sicherstellen wollen, dass die Koordinaten nicht negativ sind:
public record Point(int x, int y) {
/** Canonical constructor */
public Point(int x, int y) {
if (x < 0 || y < 0) throw new IllegalArgumentException();
this.x = x;
this.y = y;
}
}
Neben der Validierung könnten wir auch Parameter transformieren oder z. B. eine defensive Kopie eines Arrays erzeugen.
Wichtig ist bei dieser Form, dass die Konstruktor-Signatur exakt die gleiche ist wie die des Records. Folgendes ist hingegen nicht erlaubt:
public record Point(int x, int y) {
public Point(int a, int b) { // Other names than x and y are not allowed!
this.x = a;
this.y = b;
}
}
Der Compiler würde das mit folgender Fehlermeldung quittieren:
$ javac Point.java
Point.java:4: error: invalid canonical constructor in record Point
public Point(int a, int b) {
^
(invalid parameter names in canonical constructor)
1 error
Ebenso wichtig ist, dass alle Felder gesetzt werden (logisch, sie sind ja final). Würden wir nur x setzen, nicht aber y, dann würde der Compiler mit folgender Meldung abbrechen:
$ javac Point.java
Point.java:4: error: variable y might not have been initialized
}
^
1 error
Interessanterweise muss keine 1:1-Zuordnung der Konstruktor-Parameter zu den Feldern erfolgen. Es müssen nicht einmal alle Parameter verwendet werden. So ist auch folgender Code gültig:
public record Point(int x, int y) {
public Point(int x, int y) {
this.x = x;
this.y = x; // Assigning this.y to x here - and ignoring y
}
}
Glücklicherweise erkennen moderne IDEs das. IntelliJ z. B. warnt hier mit „‘x’ should probably not be assigned to ‘y’“.
Schließlich darf die Sichtbarkeit des kanonischen Konstruktors nicht restriktiver sein als die Sichtbarkeit des Records selbst. Das bedeutet, dass ein als private gekennzeichneter Record einen als public markierten Konstruktor haben kann – ein als public deklarierter Record darf andersherum jedoch keinen privaten Konstruktor haben – folgendes ist also nicht erlaubt:
public record Point(int x, int y) {
private Point(int x, int y) { // private constructor not allowed for public record
this.x = x;
this.y = y;
}
}
Der Compiler würde das mit der folgenden Fehlermeldung quittieren:
$ javac Point.java
Point.java:2: error: invalid canonical constructor in record Point
private Point(int x, int y) {
^
(attempting to assign stronger access privileges; was public)
1 error
Kompakter Konstruktor („Compact Constructor“)
Es gibt noch eine prägnantere Variante, um den kanonischen Konstruktor zu überschreiben. Man kann die Parameter in der Signatur und die Zuweisungen komplett weglassen. Diese Art wird kompakter Konstruktor („compact constructor“) genannt:
public record Point(int x, int y) {
/** Compact constructor */
public Point { // ← No parameters here
if (x < 0 || y < 0) throw new IllegalArgumentException();
// ← No assignments here
}
}
Der Compiler fügt die Zuweisungen this.x = x und this.y = y automatisch am Ende des Konstruktors ein und erzeugt damit letztendlich aus dem kompakten Konstruktor exakt denselben Bytecode wie aus dem im vorherigen Abschnitt als zweites gezeigten kanonischen Konstruktor.
Die Parameter dürfen innerhalb des Konstruktors auch verändert werden, z. B. könnten wir stillschweigend alle negativen Werte durch 0 ersetzen:
public record Point(int x, int y) {
/** Compact constructor */
public Point {
x = Math.max(x, 0);
y = Math.max(y, 0);
}
}
Das würde dem folgenden kanonischen Konstruktor entsprechen:
public record Point(int x, int y) {
/** Canonical constructor */
public Point(int x, int y) {
x = Math.max(x, 0);
y = Math.max(y, 0);
this.x = x;
this.y = y;
}
}
Beide Formen des Konstruktors sind letztendlich gleich, und es darf nur entweder ein kanonischer oder ein kompakter Konstruktor implementiert werden.
Meine Empfehlung ist es, immer einen kompakten Konstruktor zu verwenden. Schließlich wollen wir Programmierer:innen unsere Ideen zum Ausdruck bringen – und nicht unnötigen Boilerplate-Code schreiben.
Achtung: Da die Komponenten des Records erst am Ende des Konstruktors gesetzt werden, sollte man nicht innerhalb des Konstruktors auf die Accessor-Methoden – im Beispiel x() und y() – zugreifen. Die Komponenten sind zu diesem Zeitpunkt noch mit Default-Werten belegt (im Fall von int also mit 0).
Man sollte stattdessen auf die (nicht explizit angegebenen) Konstruktor-Parameter zugreifen:
public record Point(int x, int y) {
/** Compact constructor */
public Point {
System.out.println(x()); // Prints 0 (fields are not yet assigned)
System.out.println(x); // Prints the x parameter passed to the constructor
}
}
Deutlich wird das, wenn man innerhalb des Konstruktors versucht über this auf die Felder zuzugreifen:
public record Point(int x, int y) {
/** Compact constructor */
public Point {
System.out.println(this.x); // Not allowed - x is not yet initialized
}
}
Dieser Code führt zu einem Compilerfehler:
$ javac Point.java
Point.java:3: error: variable x might not have been initialized
System.out.println(this.x);
^
1 error
Konsistent wäre es gewesen, auch den Zugriff über x() und y() im Konstruktor zu verbieten.
Zusätzliche Konstruktoren in Records
Records können um zusätzliche Konstruktoren erweitert werden, wie z. B. einen Default-Konstruktor (einen ohne Parameter) oder einen, der x und y auf denselben Wert setzt:
public record Point(int x, int y) {
/** Default constructor */
public Point() {
this(0, 0);
}
/** Custom constructor */
public Point(int value) {
this(value, value);
}
}
Dabei müssen immer a) alle Felder gesetzt werden und b) muss das, wie im vorangegangenen Beispiel gezeigt, durch Delegation an den kanonischen Konstruktor per this(...) geschehen.
Die Werte direkt zuzuweisen, wie im folgenden Code, ist hingegen nicht erlaubt:
public record Point(int x, int y) {
/** Default constructor */
public Point() {
this.x = 0; // Not allowed!
this.y = 0; // Not allowed!
}
/** Custom constructor */
public Point(int value) {
this.x = value; // Not allowed!
this.y = value; // Not allowed!
}
}
Der Compiler bricht mit folgender Meldung ab:
$ javac Point.java
Point.java:3: error: constructor is not canonical, so it must invoke another constructor of class Point
public Point() {
^
1 error
Der Grund dafür ist, dass immer – egal welchen Konstruktor man verwendet – die möglicherweise im kanonischen oder kompakten Konstruktor implementierten Parameter-Validierungen aufgerufen werden sollen.
Felder in Records
Was ist mit Feldern? Der Zustand eines Records besteht ausschließlich aus seinen Komponenten. Zusätzliche Felder sind nur in einer Form erlaubt: als statische Felder.
Keine zusätzlichen Instanzfelder
Ein Record darf keine weiteren Instanzfelder deklarieren – das ist einer der häufigsten Stolpersteine beim Umstieg von Klassen auf Records. Folgender Code kompiliert nicht:
public record Point(int x, int y) {
private String label; // Not allowed!
}
Der Compiler sagt auch gleich, was du stattdessen tun sollst:
$ javac Point.java
Point.java:2: error: field declaration must be static
private String label;
^
(consider replacing field with record component)
1 error
Das ist kein Versehen, sondern Absicht: Ein Record ist ein transparenter Träger für seine Komponenten. Alles, was zum Zustand gehört, soll auch in equals(), hashCode(), toString() und – wie du weiter unten sehen wirst – im Pattern Matching sichtbar sein. Ein verstecktes Instanzfeld würde genau das unterlaufen.
Statische Felder in Records
Records können hingegen um statische Felder (finale sowie nicht-finale) erweitert werden. Beispielsweise könnten wir die 0 aus dem oben gezeigten Default-Konstruktor in eine Konstante extrahieren:
public record Point(int x, int y) {
private static final int ZERO = 0;
public Point() {
this(ZERO, ZERO);
}
}
Wir könnten z. B. auch einen statischen Instanz-Counter hinzufügen, der im Konstruktor hochgezählt wird:
public record Point(int x, int y) {
private static long instanceCounter = 0;
public Point {
synchronized (Point.class) {
instanceCounter++;
}
}
}
Tatsächlich würde ich so einen Counter eher als AtomicLong oder LongAdder implementieren – die wären dann allerdings wieder final und damit nicht als Beispiel für ein nicht-finales statisches Feld geeignet. ;-)
Methoden in Records
Genau wie den kanonischen Konstruktor können wir auch die automatisch generierten Accessor-Methoden eines Records überschreiben. Der folgende Record enthält eine Array-Komponente und erzeugt im Konstruktor und im Accessor jeweils eine defensive Kopie des Arrays, um Änderungen am im Record gespeicherten Array zu verhindern:
public record ImmutableArrayHolder(int[] array) {
/* Compact constructor */
public ImmutableArrayHolder {
array = array.clone();
}
/* Accessor method */
public int[] array() {
return array.clone();
}
}
Damit ist der Record unveränderlich. Was noch fehlt, ist Wertgleichheit – denn die generierten equals()-, hashCode()- und toString()-Methoden behandeln ein Array wie jedes andere Objekt: Sie vergleichen Referenzen, nicht Inhalte. Zwei ImmutableArrayHolder mit inhaltsgleichen Arrays sind also nicht gleich (die beiden Arrays sind Klone an verschiedenen Speicheradressen). Außerdem liefert toString() ein wenig hilfreiches ImmutableArrayHolder[array=[I@135fbaa4].
Wer Wertsemantik braucht, muss diese drei Methoden zusätzlich überschreiben:
public record ImmutableArrayHolder(int[] array) {
// ... compact constructor and accessor as above ...
@Override
public boolean equals(Object obj) {
return obj instanceof ImmutableArrayHolder other
&& Arrays.equals(array, other.array);
}
@Override
public int hashCode() {
return Arrays.hashCode(array);
}
@Override
public String toString() {
return "ImmutableArrayHolder[array=" + Arrays.toString(array) + "]";
}
}
Meist ist es einfacher, statt eines Arrays gleich eine unveränderliche List als Komponente zu verwenden (z. B. per List.copyOf() im kompakten Konstruktor) – dann stimmen defensive Kopie und Wertsemantik ohne zusätzlichen Code.
Neben zusätzlichen Konstruktoren und statischen Feldern lassen sich in Java-Records auch zusätzliche statische und nicht-statische Methoden definieren.
Die folgende statische Methode gibt den Wert des Instanz-Zählers zurück:
public record Point(int x, int y) {
private static long instanceCounter = 0;
// ... Constructor increasing instanceCounter ...
public static synchronized long getInstanceCounter() {
return instanceCounter;
}
}
Eine nicht-statische, also eine Instanz-Methode, könnten wir z. B. implementieren, um den euklidischen Abstand zu einem anderen Punkt zu berechnen:
public record Point(int x, int y) {
public double distanceTo(Point target) {
int dx = target.x() - this.x();
int dy = target.y() - this.y();
return Math.sqrt(dx * dx + dy * dy);
}
}
Aufrufen können wir die Methode z. B. wie in folgendem Beispiel:
Point p1 = new Point(17, 3);
Point p2 = new Point(18, 12);
double distance = p1.distanceTo(p2);
Bei der Implementierung und dem Aufruf von Record-Methoden gibt es keinen Unterschied zu normalen Klassen.
Records und Vererbung
Records können Interfaces implementieren:
public interface WithXCoordinate {
int x();
}
public record Point(int x, int y) implements WithXCoordinate {}
Das ist auch in Kombination mit den in Java 17 veröffentlichten Sealed Types möglich:
public sealed interface WithXCoordinate permits Point, Point3D {
int x();
}
public record Point(int x, int y) implements WithXCoordinate {}
public record Point3D(int x, int y, int z) implements WithXCoordinate {}
Records können hingegen nicht von Klassen erben. Das folgende ist also nicht erlaubt:
public class TaggedElement {
private String tag;
}
public record Point(int x, int y) extends TaggedElement {} // Not allowed!
Das liegt daran, dass Records bereits von der Klasse java.lang.Record erben – und dass sie unveränderlich sein sollen. Das wären sie nicht, wenn sie von einer veränderlichen Klasse erben würden.
Records sind außerdem implizit final, man kann also auch nicht von ihnen erben. Folgender Code ist also ebenfalls ungültig:
public record Point(int x, int y) {}
public class TaggedPoint extends Point { // Not allowed!
private String tag;
TaggedPoint(int x, int y, String tag) {
super(x, y);
this.tag = tag;
}
}
Records und Pattern Matching
Records und Pattern Matching wurden zusammen entworfen – ein Record ist ein transparenter Träger seiner Komponenten, und ein Record Pattern nimmt ihn genau entlang dieser Komponenten wieder auseinander. Seit Java 21 sind Record Patterns ein finales Feature.
Anstatt einen Point per instanceof zu prüfen, zu casten und dann x() und y() aufzurufen, kannst du die Komponenten direkt im Pattern binden:
Object obj = new Point(3, 4);
if (obj instanceof Point(int x, int y)) {
System.out.println("Point at " + x + "/" + y);
}
Dasselbe funktioniert in einem switch – auf Wunsch kombiniert mit einer Guard-Bedingung (when):
String describe(Object obj) {
return switch (obj) {
case Point(int x, int y) when x == y -> "on the diagonal: %d/%d".formatted(x, y);
case Point(int x, int y) -> "Point at %d/%d".formatted(x, y);
default -> "something else";
};
}
Ihre wahre Stärke zeigen Record Patterns in Kombination mit Sealed Interfaces – wie dem WithXCoordinate aus dem vorigen Abschnitt: Der Compiler weiß dann, dass nur Point und Point3D in Frage kommen, und prüft, ob dein switch alle Fälle abdeckt. Verschachtelte Patterns, Patterns über generische Records und weitere Beispiele findest du im Abschnitt Record Patterns des Java-21-Artikels.
Wo Records deklariert werden dürfen
Records sind nicht auf eigene Dateien beschränkt. Im Vergleich zu regulären Klassen sollte man einige Besonderheiten kennen – die erkläre ich in den nächsten Abschnitten.
Lokale Records
Records dürfen auch lokal (d. h. innerhalb von Methoden) definiert werden. Das kann insbesondere dann hilfreich sein, wenn man Zwischenergebnisse mit mehreren zusammengehörigen Variablen speichern möchte.
Im folgenden Beispiel definieren wir innerhalb der findFurthestPoint()-Methode den lokalen Record PointWithDistance: eine Kombination aus einem Point und einem double-Wert, der die Entfernung des Punktes zu einem Ursprungspunkt repräsentiert.
Mit Hilfe des lokalen Records füllen wir eine Liste von Punkten und deren Entfernungen zum aktuellen Punkt. Aus dieser Liste ermitteln wir dann denjenigen PointWithDistance mit der größten Distanz – um daraus wiederum den zugehörigen Point zu extrahieren.
public Point findFurthestPoint(Point origin, Point... points) {
record PointWithDistance(Point point, double distance) {}
List<PointWithDistance> pointsWithDistance = new ArrayList<>();
for (Point point : points) {
double distance = origin.distanceTo(point);
pointsWithDistance.add(new PointWithDistance(point, distance));
}
PointWithDistance furthestPointWithDistance = Collections.max(
pointsWithDistance,
Comparator.comparing(PointWithDistance::distance));
return furthestPointWithDistance.point();
}
Records innerhalb von inneren Klassen
Records dürfen auch innerhalb von inneren Klassen definiert werden:
class OuterClass {
// ...
class InnerClass {
record InnerClassRecord(String foo, int bar) {}
// ...
}
}
Diese Möglichkeit ist insofern erwähnenswert, als dass sie erst mit dem finalen Release von Records durch JDK Enhancement Proposal 395 ermöglicht wurde.
Generische Records
Records können genau wie Klassen Typparameter haben. Damit eignen sie sich hervorragend als Ersatz für die in Java fehlenden Tupel:
public record Pair<A, B>(A first, B second) {}
Die Verwendung unterscheidet sich nicht von einer generischen Klasse:
Pair<String, Integer> pair = new Pair<>("answer", 42);
String first = pair.first();
int second = pair.second();
Ein generischer Record Pair<A, B> ist für kleine Zwischenergebnisse praktisch. Für alles, was eine fachliche Bedeutung hat, solltest du trotzdem einen benannten Record bevorzugen – PointWithDistance sagt mehr als Pair<Point, Double>.
Varargs-Komponenten
Die letzte Komponente eines Records darf ein Varargs-Parameter sein:
public record Polyline(String name, Point... points) {}
Der kanonische Konstruktor nimmt dann beliebig viele Punkte entgegen; die Komponente selbst ist ein Array, und der Accessor points() liefert ein Point[]:
Polyline triangle =
new Polyline("triangle", new Point(0, 0), new Point(4, 0), new Point(0, 3));
int numberOfPoints = triangle.points().length; // 3
Da es sich um ein Array handelt, gelten dieselben Einschränkungen wie im Abschnitt Methoden in Records beschrieben: equals() und hashCode() vergleichen das Array nur per Referenz – zwei Polyline-Instanzen mit demselben Namen und denselben Punkten sind also nicht gleich.
Annotationen auf Record-Komponenten
Eine Annotation, die du an eine Record-Komponente schreibst, landet nicht nur an einer Stelle. Der Compiler propagiert sie – abhängig von ihrem @Target – auf alle Elemente, die er aus der Komponente ableitet:
public record Point(@Positive int x, @Positive int y) {}
Ist @Positive für FIELD, METHOD und PARAMETER deklariert, dann trägt das Feld x die Annotation, ebenso die Accessor-Methode x() und der Parameter x des kanonischen Konstruktors. Erlaubt die Annotation zusätzlich das Ziel RECORD_COMPONENT, ist sie auch per Reflection an der Komponente selbst sichtbar (dazu gleich mehr).
Genau darauf verlassen sich Frameworks wie Bean Validation oder Jackson: Sie finden ihre Annotationen an der Stelle, an der sie sie auch bei einer regulären Klasse suchen würden.
Ein Detail solltest du kennen: Die Propagierung gilt nur für die implizit erzeugten Elemente. Implementierst du die Accessor-Methode x() selbst, dann trägt sie die Annotation der Komponente nicht – du musst sie dort explizit wiederholen.
Records zur Laufzeit
Reflection und Deserialisierung greifen zur Laufzeit direkt auf die Felder eines Objekts zu – an Konstruktoren und Methoden vorbei. Bei Records verhalten sich beide anders als bei Klassen. Das schauen wir uns jetzt an.
Records und Reflection
Zunächst die gute Nachricht: Records lassen sich per Reflection besonders komfortabel auslesen. Class weiß, ob es sich um einen Record handelt, und liefert dessen Komponenten samt Name, Typ und Accessor-Methode:
Point point = new Point(5, 10);
System.out.println(Point.class.isRecord()); // true
for (RecordComponent component : Point.class.getRecordComponents()) {
System.out.println(component.getName() + " : " + component.getType()
+ " = " + component.getAccessor().invoke(point));
}
Der Code gibt folgendes aus:
true
x : int = 5
y : int = 10
Auf dieser API bauen z. B. Serialisierungs-Bibliotheken auf, um Records generisch verarbeiten zu können – ohne sich auf Feldnamen oder Getter-Konventionen verlassen zu müssen.
Und jetzt der Unterschied zu Klassen: Finale Felder von regulären Klassen können per Reflection verändert werden. Im folgenden Code ist Point die Klasse vom Beginn dieses Artikels.
Point point = new Point(10, 5);
System.out.println("point = " + point);
Field xField = Point.class.getDeclaredField("x");
xField.setAccessible(true);
System.out.println("point.x = " + xField.get(point));
xField.set(point, 55);
System.out.println("point = " + point);
Bis einschließlich Java 25 gibt der Code folgendes aus:
point = Point[x=10, y=5]
point.x = 10
point = Point[x=55, y=5]
Das bedeutet, wir haben per Reflection das eigentlich private und finale x-Feld der Point-Klasse ausgelesen und geändert! (Wie weit man das treiben kann, zeigt der Artikel Java Deep Reflection: Wie man Integer und String hackt – dort ergibt am Ende 2 + 3 = 6.)
Seit Java 26 funktioniert das zwar immer noch – aber nicht mehr stillschweigend. Mit JEP 500 („Prepare to Make Final Mean Final“) gibt die JVM beim ersten reflektiven Schreibzugriff auf ein finales Feld eine Warnung aus (Ausgabe gekürzt):
point = Point[x=10, y=5]
point.x = 10
WARNING: Final field x in class eu.happycoders.records.Point has been mutated reflectively by class eu.happycoders.records.ReflectionDemo in unnamed module ...
WARNING: Use --enable-final-field-mutation=ALL-UNNAMED to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled
point = Point[x=55, y=5]
In einer künftigen Java-Version wird die JVM den Zugriff standardmäßig mit einer Exception abbrechen; wer ihn dann noch braucht, muss ihn per --enable-final-field-mutation explizit freischalten.
Wenn wir den gleichen Code mit dem Point-Record aufrufen, dann erhalten wir – in jeder Java-Version seit 16 – folgende Ausgabe:
point = Point[x=10, y=5]
point.x = 10
Exception in thread "main" java.lang.IllegalAccessException:
Can not set final int field eu.happycoders.records.Point.x to java.lang.Integer
Wir können also auch bei Records per Reflection private Felder auslesen (und damit z. B. einen Accessor umgehen, der eine defensive Kopie einer veränderlichen Komponente, wie z. B. einem Array, erzeugt).
Records sind aber – im Gegensatz zu Klassen – von Anfang an vor Veränderungen durch Reflection geschützt. JEP 500 nennt es ausdrücklich als Ziel, finale Felder regulärer Klassen an dieses Verhalten anzugleichen. Anders gesagt: Records haben seit Java 16 vorweggenommen, was für alle finalen Felder die Zukunft ist.
Im Überblick:
Records deserialisieren
Records haben eine Besonderheit beim Deserialisieren. Diese zeige ich an folgendem Beispiel.
Erweitern wir den Point-Konstruktor zunächst um eine Parameter-Validierung. Sagen wir, wir wollen negative Werte ausschließen. Wir verwenden die kompakte Notation für die Überschreibung des kanonischen Konstruktors. Außerdem machen wir die Klasse serialisierbar:
public record Point(int x, int y) implements Serializable {
@Serial private static final long serialVersionUID = -1482007299343243215L;
public Point {
if (x < 0) throw new IllegalArgumentException("x must be >= 0");
if (y < 0) throw new IllegalArgumentException("y must be >= 0");
}
}
Um den Unterschied beim Deserialisieren zu sehen, erstellen wir eine analoge reguläre Klasse PointClass:
public final class PointClass implements Serializable {
@Serial private static final long serialVersionUID = 8411630734446201523L;
private final int x;
private final int y;
public PointClass(int x, int y) {
if (x < 0) throw new IllegalArgumentException("x must be >= 0");
if (y < 0) throw new IllegalArgumentException("y must be >= 0");
this.x = x;
this.y = y;
}
// ... getters, equals(), hashCode(), toString() ...
}
Wir kommentieren die Parameter-Validierung vorübergehend aus und serialisieren mit folgendem Code einen ungültigen Point-Record und eine ungültige PointClass-Klasse in jeweils eine Datei:
PointClass pc = new PointClass(-5, 5); // Parameter validation temporarily commented
try (FileOutputStream fileOut = new FileOutputStream("point-class.bin");
ObjectOutputStream objectOut = new ObjectOutputStream(fileOut)) {
objectOut.writeObject(pc);
}
Point p = new Point(-5, 5); // Parameter validation temporarily commented
try (FileOutputStream fileOut = new FileOutputStream("point-record.bin");
ObjectOutputStream objectOut = new ObjectOutputStream(fileOut)) {
objectOut.writeObject(p);
}
Danach kommentieren wir die Parameter-Prüfung wieder ein und versuchen die serialisierten Objekte zu deserialisieren:
try (FileInputStream fileIn = new FileInputStream("point-class.bin");
ObjectInputStream objectIn = new ObjectInputStream(fileIn)) {
PointClass pointClass = (PointClass) objectIn.readObject();
System.out.println("pointClass = " + pointClass);
}
try (FileInputStream fileIn = new FileInputStream("point-record.bin");
ObjectInputStream objectIn = new ObjectInputStream(fileIn)) {
Point point = (Point) objectIn.readObject();
System.out.println("point = " + point);
}
Das Ergebnis macht einen weiteren Unterschied zwischen Records und Klassen sichtbar:
pointClass = PointClass{x=-5, y=5}
Exception in thread "main" java.io.InvalidObjectException: x must be >= 0
at ...
Caused by: java.lang.IllegalArgumentException: x must be >= 0
at eu.happycoders.records.Point.<init>(Point.java:10)
Die fehlerhafte Klasse lässt sich problemlos deserialisieren – der fehlerhafte Record hingegen nicht.
Der Grund: Bei der Deserialisierung einer Klasse werden die aus dem ObjectInputStream gelesenen Werte direkt in die Felder der Klasse geschrieben. Bei einem Record hingegen wird der kanonische Konstruktor aufgerufen – und damit die ggf. darin enthaltenen Parameter-Überprüfungen ausgeführt.
Die beiden Wege im Vergleich:
Java Records vs. Klassen
Ich bekomme oft die Frage gestellt, wozu man Records braucht, wenn man doch einfach Konstruktor, Getter, equals(), hashCode() und toString() von einer IDE generieren lassen kann.
Zum einen ist es meiner Meinung nach nicht die Aufgabe einer IDE, die Unzulänglichkeiten einer Programmiersprache auszugleichen. Eine Programmiersprache sollte so ausgereift sein, dass wir mit möglichst wenig Code unsere Ideen ausdrücken können – den Rest sollte der Compiler erledigen. Es verwenden auch nicht alle die gleiche IDE, und unterschiedliche IDEs generieren unterschiedlichen Quellcode.
Records haben weitere, konkrete Vorteile:
- Die Felder von Records sind wirklich unveränderlich – und zwar seit Java 16. Bei regulären Klassen ließen sich finale Felder per Reflection verändern; erst seit Java 26 warnt die JVM davor, und erst eine künftige Version wird es standardmäßig unterbinden (s. Abschnitt Records und Reflection). Records haben diese Garantie von Anfang an.
- Es ist nicht möglich per Deserialisierung ungültige Records zu erzeugen, da bei der Deserialisierung (im Gegensatz zu Klassen) der kanonische Konstruktor des Records aufgerufen wird.
- Für die Methoden
equals(),hashCode()undtoString()erzeugt der Compiler einen speziellen Bytecode, der spezielle Implementierungen dieser Methoden in der JVM aufruft. So können diese Methoden in zukünftigen Java-Versionen immer weiter optimiert werden, ohne dass existierender Code neu kompiliert werden muss. - Records arbeiten eng mit anderen Sprachmerkmalen zusammen, wie z. B. den in Java 21 finalisierten Record Patterns (s. Abschnitt Records und Pattern Matching).
Java Records vs. Lombok
Ähnlich oft bekomme ich die Frage gestellt, was der Vorteil von Records gegenüber Lombok ist.
Meiner Meinung nach sollten wir nicht die Verantwortung für etwas, das eine Sprache können sollte, an eine Library abgeben. Denn das birgt Risiken: Was, wenn die Library nicht weiter gewartet wird? Was, wenn sie nicht an neue Java-Versionen angepasst oder gar komplett eingestellt wird?
Zudem gelten die gleichen konkreten Nachteile wie bei regulären Klassen: Finale Felder Lombok-annotierter Klassen können per Reflection verändert werden (bis Java 25 ohne Warnung, s. o.); durch Deserialisierung können ungültige Instanzen erzeugt werden; equals(), hashCode() und toString() können nicht durch die JVM optimiert werden; und auch Record Patterns funktionieren nicht mit Lombok-annotierten Klassen.
Ausblick: Withers und Value Objects
Zwei Entwicklungen rund um Records solltest du im Blick behalten.
Withers: Da Records unveränderlich sind, erzeugt man geänderte Varianten, indem man einen neuen Record aus dem alten ableitet. Dafür schreibt man heute sogenannte Wither-Methoden von Hand:
public record Point(int x, int y) {
public Point withX(int newX) {
return new Point(newX, y);
}
}
Bei Records mit vielen Komponenten wird das schnell zum Boilerplate, den Records eigentlich abschaffen sollten. JDK Enhancement Proposal 468 („Derived Record Creation“) schlägt dafür eine eigene Syntax vor: point with { x = 10; }. Das JEP befindet sich allerdings noch im Candidate-Status; eine Zielversion gibt es nicht. Wie Withers heute funktionieren und wie die geplante Syntax aussieht, erfährst du im Artikel Withers in Java: Derived Record Creation Expressions.
Value Objects: Mit JEP 401 („Value Objects“, für Java 28 als Preview-Feature geplant) wird ein Record per value record Point(int x, int y) {} zu einem Objekt ohne Identität – die JVM darf es dann z. B. inline in Arrays und andere Objekte einbetten. Records sind dafür der natürliche Kandidat, denn sie bringen die wichtigste Voraussetzung schon mit: Unveränderlichkeit.
Fazit
Records bieten eine kompakte Notationsmöglichkeit, um Java-Klassen mit ausschließlich finalen Feldern zu definieren. Records enthalten automatisch einen Konstruktor, der alle finalen Felder setzt (den kanonischen Konstruktor), lesende Zugriffsmethoden für alle Felder (die Accessor-Methoden), sowie durch die JVM optimierte equals()-, hashCode()- und toString()-Methoden.
Records können um weitere Konstruktoren, statische Felder und statische sowie nicht-statische Methoden erweitert werden – nicht aber um zusätzliche Instanzfelder. Der kanonische Konstruktor kann überschrieben werden.
Records können Interfaces implementieren (auch versiegelte), aber keine Klassen erweitern, und von ihnen kann auch nicht geerbt werden. Sie dürfen generisch sein, lokal in Methoden und in inneren Klassen deklariert werden, und mit Record Patterns lassen sie sich in instanceof und switch direkt in ihre Komponenten zerlegen.
Beim Deserialisieren von Records wird immer deren kanonischer Konstruktor – und die darin ggf. enthaltenen Parameter-Validierungen – aufgerufen. Per Reflection lassen sich Record-Komponenten auslesen, aber nicht verändern – eine Garantie, die Java 26 mit JEP 500 nach und nach auf alle finalen Felder ausweitet.
Records wurden in Java 14 und Java 15 als Preview-Features veröffentlicht und durch JDK Enhancement Proposal 395 in Java 16 als produktionsreif eingestuft. Records wurden im Rahmen von Projekt Amber entwickelt, innerhalb dessen auch Switch Expressions, Text Blocks, Pattern Matching und Sealed Classes entwickelt wurden.
Konntest du etwas aus diesem Artikel mitnehmen? Mit einer Bewertung auf meinem ProvenExpert-Profil hilfst du anderen Entwickler:innen einzuschätzen, ob sich das Lesen lohnt – und mir zu verstehen, welche Inhalte dir weiterhelfen.
Der HappyCoders-Newsletter hält dich über neue Java-Features und Artikel auf dem Laufenden – klicke hier, um dich anzumelden.




