Vor einem Jahr habe ich in einem Artikel zu Java 25 beschrieben, was sich zwischen den beiden letzten LTS-Versionen 21 und 25 getan hat – naheliegend, weil die meisten Unternehmen von LTS zu LTS wechseln und der Zwischenraum damit der relevante Ausschnitt ist.
Diesmal spanne ich den Bogen weiter: von Java 18 bis Java 25, acht Versionen, deutlich über 30 finalisierte Features. Wer nämlich noch auf Java 17 sitzt, für den zählt nicht nur, was seit 21 dazukam, sondern auch, was 21 selbst mitgebracht hat.
„Unser Projekt läuft mit Java 17, alles ist fein, warum sollten wir auf Java 21 wechseln, oder gar auf 25?
Das bringt nur Probleme und keinen Vorteil.“
Oder doch?
Über die großen tollen Neuerungen wurde bereits von anderen geschrieben. Oft waren es die immergleichen Hello-World-Beispiele, die zwar die Syntax gezeigt hat, aber nicht, wie der praktische Einsatz denn eigentlich aussehen soll, und vor allem: Warum sollte man das einsetzen, welches Problem löst das bei mir?
Hier zeige ich fünf Features, die einen Umstieg vielleicht doch interessant machen. Für den Spannungsbogen habe ich sie aufsteigend nach Muss-ich-haben-Score sortiert.
- UTF-8 by Default (JEP 400, Java 18)
- Sequenced Collections (JEP 431, Java 21)
- Compact Object Headers (JEP 519, Java 25)
- Pattern Matching for switch + Record Patterns (JEP 441 + 440, Java 21)
- Virtual Threads (JEP 444, Java 21)
Los geht’s.
UTF-8 by Default (JEP 400)
Beginnen wir mit einem unscheinbar wirkenden Feature, UTF-8 by Default. Weniger ein „Oh, das brauche ich!“, eher ein „Ah, gut zu wissen“. Hier wird nichts Neues ermöglicht, sondern eine stille, aber plattformübergreifende Fehlerquelle beseitigt.
Bis Java 17 richtete sich Charset.defaultCharset() nach der jeweiligen Plattform und Locale. Klassen wie `FileReader`oder Methoden wie `new String(bytes)` greifen implizit darauf zu. Eine Datei, die unter Windows ohne explizite Angabe eines Charsets geschrieben wurde, konnte auf dem Linux-CI-Server mit anderem Default nur kaputt gelesen werden.
Seit Java 18 ist der Default unabhängig von Betriebssystem und Locale fest auf UTF-8 gesetzt.
Strenggenommen kann man das als Breaking Change sehen. Allerdings betrifft es nur Code, der sich auf das alte Verhalten verlassen hat, das halt zufällig wie gewünscht funktionierte. Wer bereits Charset explizit angegeben hat, merkt nichts von der Änderung. Und für alle anderen wurde das Migrationsproperty `-Dfile.encoding=COMPAT` eingeführt, um den Umstieg zu erleichtern. Damit kann das alte plattformabhängige Verhalten explizit reaktiviert werden, bis der Code gefixt ist.
Im Code lässt sich der Effekt schön nachvollziehen:
System.out.println("Default-Charset dieser JVM: " + Charset.defaultCharset());
System.out.println();
String original = "Über die Größe des Bildschirms";
byte[] utf8Bytes = original.getBytes(StandardCharsets.UTF_8);
// Genau das passierte früher unbeabsichtigt: eine mit UTF-8 geschriebene
// Datei wird auf einem System mit anderem Default-Charset gelesen
String misread = new String(utf8Bytes, Charset.forName("Cp1252"));
String correct = new String(utf8Bytes, StandardCharsets.UTF_8);
System.out.println("Falsch interpretiert: " + misread);
System.out.println("Korrekt interpretiert: " + correct);
Die Ausgabe sieht im Windows Terminal so aus:
Falsch interpretiert: Über die Größe des Bildschirms
Korrekt interpretiert: Über die Größe des Bildschirms
Genau dieser Bruch trat vor Java 18 auf, sobald Code keinen Charset angab und sich auf den Default der jeweiligen Plattform verließ. Auf Java 18+ liefert Charset.defaultCharset() garantiert UTF-8.
Sequenced Collections (JEP 431)
Kleinster Aufwand, größte Trefferquote im Alltag: [Sequenced Collections](https://openjdk.org/jeps/431).
Fast jeder Entwickler stolpert regelmäßig über solche Konstrukte:
List<String> log = new ArrayList<>(List.of("start", "step1", "step2", "ende"));
// erstes Element holen
String erster = log.get(0);
// letztes Element holen
String letzter = log.get(log.size() - 1);
// rückwärts durchiterieren
List<String> umgekehrt = new ArrayList<>(log);
Collections.reverse(umgekehrt);
Ergänzend kommt dazu, dass diese Operationen für jeden Collection-Typ fröhlich mit unterschiedlichen Namen versehen wurden:
| Collection-Typ | erstes Element | letztes Element |
|---|---|---|
| List | list.get(0) | list.get(list.size() – 1) |
| Deque | deque.getFirst() | deque.getLast() |
| SortedSet | sortedSet.first() | sortedSet.last() |
| LinkedHashSet | linkedHashSet.iterator().next() | (nicht vorhanden) |
Auch für die Rückwärtsiteration gibt es je nach Collection unterschiedliche Methoden:
| Collection-Typ | nächstes Element |
|---|---|
| List | list.listIterator(), it.hasPrevious(), it.previous() |
| Deque | deque.descendingIterator(), it.hasNext(), it.next() |
| SortedSet | navSet.descendingSet() |
| LinkedHashSet | (nicht vorhanden) |
Mit Sequenced Collections wird der Zugriff vereinfacht und vereinheitlicht. Die Aufrufe aus dem Beispiel sehen jetzt so aus:
String erster2 = log.getFirst();
String letzter2 = log.getLast();
List<String> umgekehrt2 = log.reversed(); // Live-View, keine Kopie nötig
log.addFirst("init"); // funktioniert jetzt auch bei LinkedHashSet, Deque, ...
Der eigentliche Mehrwert liegt nicht nur in der kürzeren Syntax, sondern darin, dass getFirst()/getLast() jetzt *einheitlich über verschiedene Collection-Typen hinweg* funktionieren, die vorher jeweils eigene, uneinheitliche Wege dafür brauchten.
Compact Object Headers (JEP 519)
Als Teil des Project Lilliput wurden mit Java 24 Compact Object Headers als experimentelles Feature eingeführt und mit Java 25 finalisiert.
Jedes Java-Objekt hat einen Header mit einer Größe von 24 Byte.
Mit Compact Object Headers werden diese 24 Byte auf 16 Byte komprimiert, was eine Einsparung von 8 Byte pro Objekt bedeutet.
Das senkt Heap-Verbrauch, GC-Aufwand und CPU-Zeit spürbar. Und: Es ist dafür keine Codeänderungen erforderlich, es muss lediglich beim Programmstart ein zusätzlicher Parameter angegeben werden.
Schauen wir uns ein Beispiel an. Ein Point hat zwei Koordinaten x und y vom Typ int.
class Point {
int x;
int y;
}
Das Speicherlayout des Objekts sieht ohne Compact Object Headers (Standard bis Java 24, bzw. Java 25 ohne Flag) so aus:
de.gedoplan.showcase.compactobjectheaders.Point object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x01040210
12 4 int Point.x 0
16 4 int Point.y 0
20 4 (object alignment gap)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
Mit `-XX:+UseCompactObjectHeaders` erhalten wir dieses Layout:
de.gedoplan.showcase.compactobjectheaders.Point object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0104040000000001 (Lilliput)
8 4 int Point.x 0
12 4 int Point.y 0
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
Für die Ausgaben wurde die kleine Java-Bibliothek JOL („Java Object Layout“) verwendet. Der vollständige Code findet sich auf unserem GitHub-Repository.
Die Größe eines Point-Objekts ist also von 24 Byte auf 16 Byte geschrumpft. Das bedeutet eine Einsparung von 8 Byte für ein Objekt!
Ein Beispiel, um das etwas besser zu verdeutlichen:
Im Cache eines Microservices befindet sich eine Liste mit 10 Millionen Point-Objekten:
List<Point> punkte = IntStream.range(0, 10_000_000)
.mapToObj(i -> new Point(i, i * 2))
.toList();
Java 21: 24 Byte/Objekt -> 240 MB
Java 25: 16 Byte/Objekt -> 160 MB
Ersparnis: 80 MB
Cloud-Anbieter berechnen nach Sekunden und Byte. Mit vielen Instanzen, die in kurzer Folge gestartet und gestoppt werden, bringen Compact Object Headers echte Kostenersparnis.
In Java 25 müssen Compact Object Headers noch explizit aktiviert werden. Für Java 27 ist vorgesehen, sie standardmäßig zu aktivieren und somit zum Default-Headerlayout der JVM zu machen.
Pattern Matching for switch + Record Patterns (441 + 440)
Zwei Features, die dem einen oder anderen vielleicht schon bekannt sind, wurden mit Java 21 eingeführt: Pattern Matching for switch (JEP 441) und Record Patterns (JEP 440).
Jedes für sich macht sich ganz hübsch für Demos, die eigentliche Stärke zeigt sich aber, wenn beide zusammen eingesetzt werden und wir außerdem noch Sealed Classes dazunehmen. (Die gibt es schon seit Java 17, deshalb habe ich sie in der Überschrift nicht mit angegeben.)
sealed interface Payment permits CardPayment, PayPalPayment, BankTransfer {}
record CardPayment(String number, BigDecimal amount) implements Payment {}
record PayPalPayment(String email, BigDecimal amount) implements Payment {}
record BankTransfer(String iban, BigDecimal amount, String reference) implements Payment {}
String describe(Payment p) {
return switch (p) {
case CardPayment(var number, var amount) when amount.compareTo(BigDecimal.valueOf(1000)) > 0
-> "Große Kartenzahlung: " + number;
case CardPayment(var number, var amount) -> "Kartenzahlung: " + number;
case PayPalPayment(var email, var amount) -> "PayPal von " + email;
case BankTransfer(var iban, var amount, var ref) -> "Überweisung " + ref;
// kein default nötig – Compiler weiß, dass alle Payment-Typen abgedeckt sind
};
}
Jetzt fügen wir eine weitere Implementierung von Payment hinzu und ergänzen auch die permits-Aufzählung:
sealed interface Payment permits CardPayment, PayPalPayment, BankTransfer, CryptoTransfer {}
record CryptoTransfer(String walletAddress, BigDecimal amount, String currency) implements Payment {}
Der Compiler kennt die erlaubten Payment-Implementierungen. Die Änderung führt dazu, dass das switch-Statement nicht mehr alle abdeckt, was der Compiler mit einer Fehlermeldung quittiert: Wir möchten doch bitte einen default-Eintrag hinzufügen:

de\gedoplan\showcase\patternmatching\PatternMatchingDemo.java:13: Fehler: Der Switch-Ausdruck deckt nicht alle möglichen Eingabewerte ab
return switch (payment) {
^
1 Fehler
In der IDE entsprechend:

Hier erhalten wir noch den ergänzenden Vorschlag, den fehlenden Branch hinzuzufügen, und auch gleich noch eine Vorschau auf den generierten Stub-Code: `-> null;`:

Wir sehen also: Die Kombination von switch statement, record patterns und sealed classes ermöglicht es dem Compiler sicherzustellen, dass Änderungen an den verwendeten Typen (hier Payment) überprüft und sofort entsprechend gemeldet werden können.
Virtual Threads (JEP 444)
Wahrscheinlich das bekannteste Feature in dieser Liste sind die mit Java 21 eingeführten Virtual Threads.
Ein Thread wird in Java mit java.lang.Thread auf einen Betriebssystemthread abgebildet. Für jeden Thread werden dabei ca. 1 MB an Speicher reserviert. Bei 1000 Threads ist das mal eben 1 GB. Und bis jetzt *machen* die noch gar nichts, sie *existieren* ledglich.
Klassische Threads benötigen Systemressourcen (Hauptspeicher), ihre Erstellung ist aufwendig (Systemthread anfordern, Thread initialisieren, Speicher reservieren) und dauert entsprechend auch einen Moment.
Ein paar Dutzend paralleler REST-Requests lassen sich damit noch bedienen, aber wenn die die Anzahl der Anfragen steigt, wird dieses Threadsystem schnell zum Flaschenhals.
Auftritt Virtual Threads. Die JVM verwaltet die hereinkommenden Requests und multiplext sie auf wenige Systemthreads (Carrier-Threads).
Das kostet kaum Systemressourcen, und das Wechseln zu einem anderen Virtual Thread besteht im Wesentlichen im Umhängen von ein paar Zeigern im Speicher.
Auf diese Weise können Tausende Requests parallel bearbeitet werden.
In diesem Beispiel werden eine größere Anzahl HTTP-Request gleichzeitig an einen Server geschickt.
// klassische Threads
measure("Platform-Pool (200 Threads)",
Executors.newFixedThreadPool(200), urls, httpClient);
// Virtual Threads
measure("Virtual-Thread-Pool",
Executors.newVirtualThreadPerTaskExecutor(), urls, httpClient);
void measure(String label, ExecutorService pool, List<URI> urls, HttpClient httpClient) throws ExecutionException, InterruptedException {
try (pool) {
List<Future<HttpResponse<Void>>> responseFutures = new ArrayList<>();
for (URI uri : urls) {
var responseFuture = pool.submit(() ->
httpClient.send( // <== hier werden die Requests abgeschickt
HttpRequest.newBuilder(uri).build(),
HttpResponse.BodyHandlers.discarding()));
responseFutures.add(responseFuture);
}
for (var responseFuture : responseFutures) {
responseFuture.get();
}
}
}
Der Unterschied liegt in einer einzigen Zeile – `newFixedThreadPool(200)` wird zu `newVirtualThreadPerTaskExecutor()`. Der blockierende Code selbst (`httpClient.send(…)`) bleibt exakt gleich. In einem kleinen Lasttest mit 2.000 simulierten Requests führte das zu einem messbaren Unterschied: 11,41 Sekunden beim klassischen Pool gegenüber 2,46 Sekunden mit Virtual Threads. Warum das nicht die ganze Geschichte ist und was passiert, wenn man zum Beispiel den Pool einfach großzügiger dimensioniert, behandle ich in einem eigenen Artikel.
Lobende Erwähnung: String Templates (JEP 430,459)
[String Templates](https://openjdk.org/jeps/459) verdienen eine besondere Erwähnung, weil sie uns daran erinnert haben, was *Preview*-Feature bedeutet.String Templates wurden mit Java 21 als erster Preview eingeführt und mit Java 22 als zweiter Preview fortgesetzt.
Die Syntax wirkte vergleichsweise simpel:
String name = "Welt";
int age = 30;
// So sollte es aussehen, statt String.format() oder Concatenation
String message = STR."Hallo, \{name}! Nächstes Jahr bist du \{age + 1}.";
Direkter Einbau von Variablenwerten in Strings, ohne String.format() oder Konkatenation mit +. Eine der langerwarteten Neuerungen, die in unzähligen Konferenzvorträgen und Blogposts schon als „kommt sicher bald final“ vorgestellt wurde.
Für Java 23 stand die Finalisierung an. Doch kurz vor dem Feature-Freeze, im März 2024, schrieb Brian Goetz (Amber-Projektleiter) an die Entwickler-Mailingliste, dass sich das Design in der Praxis als grundlegend unzureichend erwiesen hatte. Man kam zu keinem Konsens über ein besseres Design, und die Zeit war eh schon zu knapp. Ergebnis: Das Feature wurde aus Java 23 komplett entfernt. Nicht verschoben, nicht nochmal als Preview weitergeführt, sondern ersatzlos gestrichen.
// Java 21/22: kompiliert (mit --enable-preview)
String message = STR."Hallo, \{name}!";
// Java 23+: Compilerfehler – STR und die \{}-Syntax existieren nicht mehr
Wer in Java 21 oder 22 schon mit diesem Feature experimentiert oder es gar schon in Produktivcode verwendet hat („kommt bald final“), der musste es nun wieder ausbauen, wenn der Code überhaupt *compilieren* sollte.
Ich habe hier in meiner Feature-Auswahl bewusst auf Previes verzichtet. Auf ein „Okay, sieht gar nicht schlecht aus, das neue Java. Make it so!“ soll nicht ein halbes Jahr später ein „Klasse, jetzt müssen wir das alles wieder zurückrollen.“ folgen.
String Templates zeigen, dass „zweite Preview, Finalisierung als nächster Schritt geplant“ keine Garantie ist, dass das Feature *wirklich* in den Standard aufgenommen wird. Das ist der *Sinn* des Preview-Machanismus.
Aber hey, das war eine Ausnahme. Record Patterns haben drei Previews gebraucht, genau wie Sealed Classes. Und nur weil Structured Concurrency mit Java 27 im 7. Preview sein wird und das API mehrmals angepasst wurde, heißt das nicht, dass sie nicht doch irgendwann finalisiert wird.
Fazit: Espresso oder kalter Kaffee?
Acht Java-Versionen, Dutzende JEPs – aber nicht jedes Feature rechtfertigt allein den Wechsel. UTF-8 als Standard-Encoding, Sequenced Collections und Compact Object Headers sind eher die verlässlichen Grundlagenverbesserungen: unspektakulär, aber spürbar, sobald man sie einmal hat und nicht mehr missen will.
Den eigentlichen Ausschlag geben zwei Dinge. Pattern Matching for switch und Record Patterns verändern, wie man über Daten denkt: Aus stillschweigend unvollständigen instanceof-Ketten werden vom Compiler erzwungene Vollständigkeitsprüfungen. Und Virtual Threads zeigen im Beispiel oben einen Faktor von 4,6 – derselbe blockierende Code, den man schon immer geschrieben hat, skaliert deutlich besser, ganz ohne Reactive-Framework, ganz ohne Architektur-Umbau.
Wer auf Java 17 bleibt, verpasst praktisch nutzbare Vorteile. Virtual Threads rechtfertigen den Wechsel bei I/O-lastigen Workloads mit vielen gleichzeitigen Verbindungen – wie im Beispiel oben. Pattern Matching plus Sealed Classes eliminieren ganze Fehlerklassen im Produktivcode. UTF-8, Sequenced Collections und Compact Object Headers holt man sich sowieso mit, ganz ohne dass man sich dafür entscheiden müsste.
Bleibt man bei 17, weil Migrationsschmerzen schwerer wiegen als konkret messbare Verbesserungen – dann bleibt der Kaffee kalt.






