Mit Java 21 steht eine neue Version der Programmiersprache zur Verfügung, die sich durch eine Reihe von bedeutsamen Verbesserungen und Erweiterungen auszeichnet. Die neue Version bietet Entwicklern nicht nur aktualisierte API-Funktionen, sondern auch erweiterte Möglichkeiten im Bereich des Pattern Matchings und innovative Ansätze für die Thread-Verwaltung. Im Folgenden werden die wichtigsten Änderungen detailliert beschrieben. Dazu zählen die neuen Math- und String-Methoden, die Erweiterungen bei Sequenced Collections und die Abkehr von bestimmten Methoden (Deprecations). Ein weiterer Punkt sind die Neuerungen im Pattern Matching mit Sealed Interfaces und Record Patterns. Abschließend werden die Vorteile und Best Practices von Virtual Threads erörtert.
Java 21 beinhaltet eine Vielzahl an Optimierungen und Erweiterungen, welche die Arbeitsprozesse für Entwickler vereinfachen und sowohl die Programmiersprache als auch die Laufzeitumgebung beschleunigen sollen. Von besonderer Relevanz sind dabei die Anpassungen an den Application Programming Interfaces (APIs), da sie eine Verbesserung bestehender Funktionen, die Ersetzung veralteter Methoden und die Bereitstellung neuer Optionen zum Ziel haben.
Eine detaillierte Darstellung aller Änderungen ist im Rahmen dieses Artikels nicht möglich, weshalb für eine vollständige Auflistung aller Änderungen auf die Release Notes von Java 21 auf der Website von Oracle verwiesen wird.
Die neue Version weist praktische Erweiterungen und Verbesserungen auf, wodurch sich Zeichenketten und mathematische Operationen einfacher und effizienter bearbeiten lassen.
Die Klassen StringBuilder
und StringBuffer
wurden um die repeat()
-Methode erweitert. Diese ermöglicht die wiederholte Wiedergabe einer Zeichenkette, wodurch sich wiederholende Muster mit geringem Aufwand erstellen lassen.
Warum ist diese Methode hilfreich?
repeat()
-Methode anstelle von Schleifen oder manuellen Verkettungen zur Repetition einer Zeichenkette stellt eine grundsätzlich effiziente Lösung dar.Es sei angenommen, dass eine Profil-Seite entwickelt wird, wobei eine Teil-Maskierung der hinterlegten Email-Adresse gewünscht wird. In diesem Zusammenhang soll anhand eines Beispiels erörtert werden, wie die Maskierung mit der repeat()
-Methode umgesetzt werden kann:
public class EmailMasking {
public static void main(String[] args) {
String email = "john.doe@example.com";
// Erstelle eine Maskierung für die EMail
StringBuilder maskedEmail = new StringBuilder();
maskedEmail.append(email.substring(0, 3));
maskedEmail.append("*".repeat(email.length() - 6));
maskedEmail.append(email.substring(email.length() - 3))
// Maskierte EMail: joh**************com
System.out.println("Maskierte Email: " + maskedEmail.toString());
}
}
Im vorliegenden Beispiel wird die Email-Adresse durch eine Folge von Sternchen der maskiert. Die Verwendung der repeat()
-Methode erlaubt die Maskierung mit einem einzigen Aufruf, wodurch der Code klarer und effizienter gestaltet werden kann.
Die neue Methode Character.isEmoji(int codepoint)
erlaubt die Überprüfung, ob ein bestimmtes Zeichen ein Emoji ist. Ein Codepoint ist eine eindeutige Nummer im Unicode-Standard, die jedes Zeichen, einschließlich Emojis, identifiziert. Die Verwendung dieser Methode ist vorteilhaft, da sie die Nutzung von Unicode-Zeichen verbessert und die Arbeit mit modernen Textformaten, die Emojis enthalten, vereinfacht.
Welcher Nutzen lässt sich aus der Anwendung dieser Methode ableiten?
Ein exemplarisches Szenario wäre die Entwicklung einer Chat-Anwendung, für die eine Statistik über die Verwendung von Emojis in Nachrichten erstellt werden soll. Die Umsetzung kann mit der Methode isEmoji()
erfolgen, wie im Folgenden dargestellt wird:
public class EmojiUsageAnalyzer {
public static void main(String[] args) {
String message = "Hallo 😀! Wie geht's dir heute? 😊";
int emojiCount = countEmojis(message);
// Anzahl der Emojis in der Nachricht: 2
System.out.println("Anzahl der Emojis in der Nachricht: " + emojiCount);
}
public static int countEmojis(String text) {
int count = 0;
int length = text.length();
for (int i = 0; i < length;) {
int codePoint = text.codePointAt(i);
if (Character.isEmoji(codePoint)) {
count++;
}
i += Character.charCount(codePoint);
}
return count;
}
}
Im Folgenden wird der Text einer Nachricht analysiert, um die Anzahl der Emojis zu zählen. Zu diesem Zweck wird die Methode isEmoji()
verwendet, welche es ermöglicht, jedes Zeichen im Text zu überprüfen und festzustellen, ob es sich um ein Emoji handelt. Der Zähler wird entsprechend erhöht. Auf diese Weise kann die Häufigkeit der Emojis in den Nachrichten ermittelt und daraus nützliche Statistiken abgeleitet werden.
Die in Java eingeführte Methode Math.clamp(int value, int min, int max)
ermöglicht die Begrenzung eines Wertes auf ein bestimmtes Intervall. Dies ist insbesondere dann von Vorteil, wenn sichergestellt werden soll, dass Werte innerhalb eines vorgegebenen Bereichs liegen, ohne dass eine aufwändige Überprüfungslogik erforderlich ist.
Was bringt diese Methode?
clamp()
-Methode stellt eine einfache und direkte Lösung dar, um sicherzustellen, dass ein Wert innerhalb eines Bereichs liegt, ohne dass manuell eine Überprüfungslogik geschrieben werden muss.Im Folgenden wird die Entwicklung einer Anwendung vorgestellt, welche die Positionierung eines Schiebereglers (Slider) auf einer Benutzeroberfläche steuert. Dabei ist sicherzustellen, dass sich der Schieberegler lediglich innerhalb eines definierten Bereichs bewegt. Zur Veranschaulichung wird ein Beispiel präsentiert, welches die Umsetzung mit der clamp()
-Methode demonstriert:
public class SliderControl {
public static void main(String[] args) {
int sliderPosition = 150; // Beispielwert für die Position des Schiebereglers
int minPosition = 0;
int maxPosition = 100;
// Begrenze die Position des Schiebereglers auf den Bereich [minPosition, maxPosition]
int clampedPosition = Math.clamp(sliderPosition, minPosition, maxPosition);
// Begrenzte Position des Schiebereglers: 100
System.out.println("Begrenzte Position des Schiebereglers: " + clampedPosition);
}
}
Die Funktion begrenzt in diesem Beispiel die Position des Schiebereglers auf den Bereich von 0 bis 100. Sollte der ursprüngliche Wert außerhalb dieses Bereichs liegen, erfolgt eine automatische Anpassung auf den nächsten gültigen Wert. Dadurch kann die Position des Schiebereglers ohne weitere Logik innerhalb des erlaubten Bereichs gehalten werden.
Die jüngste Erweiterung des Funktionsumfangs ermöglicht eine vereinfachte Handhabung geordneter Datenstrukturen. Die neu implementierten Interfaces und Klassen bieten eine konsistente und intuitive Zugriffsmöglichkeit auf Elemente in geordneten Sammlungen sowie die Möglichkeit ihrer Manipulation.
In Java-Sammlungen bezeichnet der Begriff „defined encounter order” die festgelegte Reihenfolge, in der Elemente verarbeitet oder iteriert werden. Bei Sammlungen wie ArrayList
, LinkedList
, LinkedHashSet
und LinkedHashMap
wird die Reihenfolge, in der Elemente hinzugefügt werden, beibehalten und ist bei der Iteration vorhersehbar. Dies steht im Gegensatz zu Sammlungen wie HashSet
und HashMap
, bei denen die Reihenfolge der Elemente nicht vorhersehbar ist. Eine definierte Begegnungsreihenfolge erweist sich insbesondere dann als vorteilhaft, wenn die Reihenfolge der Elemente für den Algorithmus oder die Logik einer Anwendung von entscheidender Bedeutung ist, da sie eine konsistente und erwartbare Verarbeitung der Elemente gewährleistet.
list.get()
deque.getFirst()
sortedSet.first()
linkedHashSet.iterator().next()
list.get(list.size()-1)
deque.getLast()
sortedSet.last()
SequencedCollection<E>
, SequencedSet<E>
sowie SequencedMap<E>
. Diese Interfaces bringen einheitliche Methoden mit sich:
E getFirst()
und E getLast()
: Gibt das erste bzw. das letzte Element der Sammlung zurück.SequencedCollection<E> reversed()
: Gibt eine umgekehrte Ansicht der Sammlung zurück.void addFirst(E e)
und void addLast(E e)
: Fügt ein Element an den Anfang bzw. das Ende der Sammlung hinzu.E removeFirst()
und E removeLast()
: Entfernt und gibt das erste bzw. letzte Element der Sammlung zurück.
Die Anwendung der genannten Methoden führt zu einer erleichterten Verarbeitung sequenzierter Datenstrukturen. Bei einer Nichtunterstützung einer Methode kann es zu einer sogenannten UnsupportedOperationException
kommen.
Im Rahmen der vorgenommenen Änderungen wurden einige veraltete Methoden und Konstruktoren entfernt, da sie in der Vergangenheit potenzielle Probleme verursachen konnten. Neben dem Aspekt der erhöhten Sicherheit und Stabilität von Anwendungen zielt die aktuelle Vorgehensweise darauf ab, die Nutzung moderner Alternativen zu fördern.
In Bezug auf Threads sind die Methoden stop()
, suspend()
und resume()
nicht mehr zeitgemäß und führen nun zu einer UnsupportedOperationException
. Diese Methoden waren mit einem gewissen Risiko behaftet, da sie unvorhersehbare Zustände in Threads verursachen konnten. Entwicklerinnen und Entwickler sollten daher sicherere Alternativen wie interrupt()
und andere Steuermechanismen für Threads in Betracht ziehen.
Des Weiteren sind die älteren URL-Konstruktoren veraltet da sie eine höhere Fehleranfälligkeit aufweisen. Stattdessen sollte URI.create("https://www.example.com/").toURL()
verwendet werden, da diese Methode eine höhere Sicherheit und Flexibilität bietet.
Java 21 beinhaltet diverse Neuerungen im Bereich Pattern Matching, insbesondere im Hinblick auf Switch-Anweisungen und Record Patterns. Die neuen Möglichkeiten zur Datenanalyse und -verarbeitung erlauben eine elegantere und lesbarere Gestaltung von Code-Strukturen. Zu den wichtigsten Konzepten, welche diese Funktionalitäten unterstützen, zählen Sealed Interfaces und Record Patterns.
permit
-Klausel der Sealed-Klasse aufgeführt sind. Des Weiteren ist zu beachten, dass sich die Erweiterung in der gleichen Datei befinden muss. Diese Funktion zielt darauf ab, die Kontrolle über die Vererbungshierarchie zu optimieren und sicherzustellen, dass nur bekannte und kontrollierte Erweiterungen einer Klasse existieren. Die Spezifikation von Sealed Interfaces erlaubt die Angabe einer begrenzten Anzahl an Implementierungen, die nach demselben Prinzip erfolgen. Dies impliziert, dass alle zulässigen Unterklassen oder Implementierungen eines Sealed Interfaces in der gleichen Datei enthalten sein müssen, wobei eine Kennzeichnung als final class
oder mit der permit
-Klausel üblich ist. Dies resultiert in sichererem und wartbarerem Code.
Ein weiterer Vorteil ist, dass bei Verwendung von Sealed Interfaces in Switch-Anweisungen der default
-Zweig häufig obsolet wird, da der Compiler mit allen möglichen Fällen vertraut ist und sie entsprechend abdecken kann, was zu klareren und präziseren Codes führt.
Anhand eines konkreten Beispiels soll im Folgenden erörtert werden, wie ein Sealed Interface typischerweise aufgebaut ist:
sealed interface Animal {
String getName();
}
final class Cat implements Animal { /* ... */ }
final class Dog implements Animal { /* ... */ }
// Alternative
sealed interface Animal permits Cat, Dog { ... }
Die Definition eines Sealed Interfaces kann ebenfalls unter Zuhilfenahme der sogenannten permits
-Klausel erfolgen, um zu spezifizieren, welche Klassen oder Interfaces eine Implementierung vornehmen dürfen. Die permits
-Klausel wird unmittelbar nach dem Namen des Interfaces angegeben und listet alle zulässigen Implementierungen auf. sealed interface Shape {}
final class Circle implements Shape {
double radius;
Circle(double radius) { this.radius = radius; }
}
final class Rectangle implements Shape {
double length, width;
Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
}
final class Square implements Shape {
double side;
Square(double side) { this.side = side; }
}
public class Main {
public static void main(String[] args) {
Shape shape = new Circle(5.0);
String result = switch (shape) {
case Circle c -> "Kreis mit Radius " + c.radius;
case Rectangle r -> "Rechteck mit Länge " + r.length + " und Breite " + r.width;
case Square s -> "Viereck mit Breite " + s.side;
};
// Kreis mit Radius 5
System.out.println(result);
}
}
Das vorliegende Beispiel demonstriert die Anwendung von Pattern Matching in Switch-Anweisungen zur Behandlung verschiedener Formen (Circle
, Rectangle
, Square
). Der Compiler überprüft die Abdeckung aller möglichen Formen, wodurch die Verwendung eines default
-Zweigs obsolet wird. Dies ist auf die Nutzung von Sealed Interfaces zurückzuführen, welche zu einer klareren und präziseren Codierung führen. public record Point(int x, int y) {}
public class Main {
public static void main(String[] args) {
Point point = new Point(10, 20);
String result = switch (point) {
case Point(int x, int y) -> "Punkt bei (" + x + ", " + y + ")";
};
// Punkt bei (10, 20)
System.out.println(result);
}
}
Das vorliegende Beispiel demonstriert die Definition eines Records Point
sowie die Extraktion seiner Koordinaten x
und y
innerhalb einer Switch-Anweisung. Dies erlaubt eine prägnante und direkte Verarbeitung der Daten innerhalb des Records.
public record Rectangle(Point topLeft, Point bottomRight) {}
public class Main {
public static void main(String[] args) {
Rectangle rectangle = new Rectangle(new Point(0, 0), new Point(10, 20));
String result = switch (rectangle) {
case Rectangle(Point(int x1, int y1), Point(int x2, int y2)) ->
"Rechteck von (" + x1 + ", " + y1 + ") bis (" + x2 + ", " + y2 + ")";
};
// Rechteck von (0, 0) bis (10, 20)
System.out.println(result);
}
}
Im vorliegenden Kontext wird ein verschachtelter Record Rectangle
verwendet, der zwei Point
-Records enthält. Die Switch-Anweisung demonstriert die Möglichkeit des Zugriffs auf die Koordinaten dieser verschachtelten Struktur sowie deren Verarbeitung. HttpClient
für send
und sendAsync
, Reactive Programming (beispielsweise Project Reactor und RxJava) sowie asynchrone Programmierung in Kotlin (Coroutines) bereits effiziente Möglichkeiten des Multithreadings bieten, stellt die Einführung von Virtual Threads einen wesentlichen Fortschritt dar. Virtual Threads stellen eine leichtgewichtige und effiziente Thread-API dar, welche die bewährte Thread-API von Java nutzt. Die Ausführung traditioneller Threads in Java ist durch eine hohe Komplexität sowie einen signifikanten Ressourcenverbrauch gekennzeichnet und die Verwaltung jedes einzelnen Threads obliegt dem Betriebssystem, was zu einem beträchtlichen Overhead führt. Bei Anwendungen, die eine Vielzahl gleichzeitiger Verbindungen oder Aufgaben verwalten müssen, können die klassischen Threads schnell an ihre Grenzen stoßen, was zu erheblichen Skalierungsproblemen führen kann. Der Verwaltungsaufwand für eine große Anzahl von Threads kann die Anwendungsperformance beeinträchtigen.
Im Gegensatz dazu werden Virtual Threads von der JVM verwaltet, was zu einer signifikanten Reduktion des Overheads führt. Dies ermöglicht die gleichzeitige Ausführung einer deutlich größeren Anzahl von Threads und verbessert die Skalierbarkeit und Nutzung der Hardware-Ressourcen erheblich.
Zur Veranschaulichung der Unterschiede zwischen traditionellen Threads und Virtual Threads wird folgendes Beispiel herangezogen:
Es werden 10.000 Threads erstellt, die jeweils für eine Sekunde schlafen, um eine Netzwerkantwort zu simulieren. Die verstrichene Zeit wird dabei in Millisekunden gemessen.
import java.util.ArrayList;
import java.util.List;
public class TraditionalThreadExample {
public static void main(String[] args) throws InterruptedException {
int numberOfThreads = 10000;
List threads = new ArrayList<>();
for (int i = 0; i < numberOfThreads; i++) {
Thread thread = new Thread(() -> {
try {
// Simuliert eine Netzwerkantwort
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
threads.add(thread);
}
long startTime = System.currentTimeMillis();
for (Thread thread : threads) {
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
long endTime = System.currentTimeMillis();
// Herkömmliche Threads: 5548 ms
System.out.println("Herkömmliche Threads: " + (endTime - startTime) + " ms");
}
}
Im Mittel schließt dieses Programm innerhalb von 5.548 Millisekunden ab.
import java.util.ArrayList;
import java.util.List;
public class VirtualThreadExample {
public static void main(String[] args) throws InterruptedException {
int numberOfThreads = 10000;
List threads = new ArrayList<>();
for (int i = 0; i < numberOfThreads; i++) {
Thread thread = Thread.ofVirtual().start(() -> {
try {
// Simuliert eine Netzwerkantwort
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
threads.add(thread);
}
long startTime = System.currentTimeMillis();
for (Thread thread : threads) {
thread.join();
}
long endTime = System.currentTimeMillis();
// Virtuelle Threads: 1032 ms
System.out.println("Virtuelle Threads: " + (endTime - startTime) + " ms");
}
}
Die Ausführung von diesem Programm, welches Virtual Threads nutzt, erfolgt mit einer durchschnittlichen Geschwindigkeit von lediglich 1.032 Millisekunden.
Im Vergleich zu anderen Programmierparadigmen benötigen Virtual Threads deutlich weniger Speicher (lediglich Kilobyte statt Megabyte). Dennoch ist die vertraute Thread-API weiterhin verfügbar. Diese Effizienz ermöglicht die Erstellung von synchronem und blockierendem Code, der dennoch asynchron und nicht blockierend ausgeführt wird. Zudem erleichtert die Debugging-Möglichkeit von Virtual Threads die Fehlersuche.
Neben der Erstellung von Virtual Threads ist ebenfalls die Generierung von herkömmlichen Threads mittels der Methode Thread.ofPlatform()
möglich. Diese Methode erlaubt die Konstruktion von Threads, welche direkt durch das Betriebssystem administriert werden. Dies kann in spezifischen Szenarien vorteilhaft sein, beispielsweise bei der Notwendigkeit einer hohen Performance von nativen Plattform-Threads.
Die Erstellung von Virtual Threads kann auch mittels eines ExecutorService
erfolgen, was insbesondere für Anwendungen von Vorteil ist, die eine Vielzahl kurzer, paralleler Aufgaben ausführen müssen. Eine Adaption des Beispiels kann wie folgt vorgenommen werden. Dadurch lässt sich ein direkter Vergleich zu sauberen und besser lesbaren Code durchführen.
try (var es = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10000; i++) { es.submit(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
}
}
Virtual Threads bieten im Vergleich zu traditionellen Threads signifikante Vorteile, insbesondere hinsichtlich des Ressourcenverbrauchs und der Skalierbarkeit, als auch die Verwaltung, die direkt durch die JVM erfolgt, sodass eine Anwendung eine signifikante Anzahl von Threads effizient erstellen und verwalten kann. Die vertraute Threading-API ermöglicht es Entwicklern, die Vorteile von Virtual Threads zu nutzen, ohne sich mit den Details der leichtgewichtigen Thread-Verwaltung befassen zu müssen, dass es erlaubt synchronem und blockierendem Code zu implementieren, dessen Ausführung jedoch asynchron und nicht blockierend erfolgt.
Insbesondere für blockierende Arbeitslasten erweisen sich Virtual Threads als besonders geeignet. Die Leichtgewichtigkeit der Virtual Threads eliminiert die Notwendigkeit des Poolings oder der Wiederverwendung, was die Implementierung vereinfacht. Dennoch ist zu berücksichtigen, dass bei der Verwendung synchronisierter Blöcke eine Bindung von Virtual Threads an Plattform-Threads erfolgen kann. Zudem kann es bei blockierenden JDK-APIs, wie beispielsweise File I/O, zu einer suboptimalen Funktionsweise in Verbindung mit Virtual Threads kommen. Daher ist eine Berücksichtigung der spezifischen Anforderungen und Einschränkungen der jeweiligen Anwendung von entscheidender Bedeutung.
Des Weiteren sind zusätzliche geplante Funktionalitäten wie „Structured Concurrency“ und „Scoped Values“ zu erwarten, welche die Arbeit mit Threads und paralleler Ausführung weiter optimieren und vereinfachen werden.
Die Einführung von Virtual Threads stellt einen signifikanten Fortschritt für die Java-Plattform dar, da sie die Effizienz und Skalierbarkeit von Anwendungen erheblich verbessert. Entwicklerinnen und Entwickler haben nun die Möglichkeit, hochskalierbare Anwendungen mit einer großen Anzahl gleichzeitiger Threads zu erstellen, ohne die mit den klassischen Performance-Problemen traditioneller, vom Betriebssystem verwalteter Threads einhergehenden Nachteile in Kauf nehmen zu müssen.
Java 21 beinhaltet eine Vielzahl nützlicher und zum Teil lang ersehnter Updates der API. Die Implementierung neuer Methoden innerhalb der Math- und String-Klassen führt zu einer signifikanten Erweiterung der Funktionalität. Zudem erfahren die Sequenced Collections wesentliche Funktionsaktualisierungen. Die Möglichkeit des Pattern Matching erlaubt die Erstellung von deutlich einfacherem und klarer strukturiertem Code, insbesondere durch die Einführung von Sealed Interfaces und Record Patterns. Die Nutzung von Virtual Threads führt zu einer signifikanten Steigerung der Performance, wodurch die Effizienz der Thread-Verwaltung erheblich verbessert wird. Der Artikel beleuchtet diese Aspekte im Detail und zeigt auf, wie sie zur Optimierung moderner Java-Anwendungen beitragen können.