Java 16 Features mit Beispielen

Java 16 Features (mit Beispielen)

Autor-Bild
von Sven Woltmann – 7. Dezember 2021

Mit dem am 16. März 2021 veröffentlichten Java 16 erreichen zwei neue Sprach-Features aus Project Amber die Produktionsreife: "Pattern Matching for instanceof" und Records.

Insgesamt wurden für dieses Release beeindruckende 17 JDK Enhancement Proposals umgesetzt.

Ich habe wie immer versucht die Erweiterungen nach Relevanz für die tägliche Programmierarbeit zu sortieren. D. h. du findest am Anfang des Artikels die bereits erwähnten neuen Sprach-Features, wichtige Änderungen an der JDK-Klassenbibliothek und neue Tools. Danach folgen Performance-Verbesserungen, Preview- und Incubator-Features und zuletzt sonstige Änderungen.

Wie immer verwende ich die englischen Bezeichnungen der JEPs, da eine Übersetzung ins Deutsche hier eher verwirrt als dass sie einen Mehrwert bringt.

Pattern Matching for instanceof

Kommen wir zur ersten großen Neuerung in Java 16. Nach zwei Preview-Runden wurde "Pattern Matching for instanceof" durch JDK Enhancement Proposal 394 als produktionsreif veröffentlicht.

Diese nunmehr vierte Spracherweiterung aus Project Amber eliminiert die Notwendingkeit von Casts nach einer instanceof-Prüfung durch eine implizite Typumwandlung.

Was das bedeutet, erkläre ich am besten an einem Beispiel. Der folgende Code prüft die Klasse eines Objekts. Ist das Objekt ein String, der außerdem länger als fünf Zeichen ist, wird er in Großbuchstaben umgewandelt und ausgegeben. Ist das Objekt hingegen ein Integer, wird die Zahl quadriert und ausgegeben.

Object obj = getObject(); if (obj instanceof String) { String s = (String) obj; if (s.length() > 5) { System.out.println(s.toUpperCase()); } } else if (obj instanceof Integer) { Integer i = (Integer) obj; System.out.println(i * i); }
Code-Sprache: Java (java)

In den Zeilen 4 und 9 müssen wir das Objekt nach String bzw. Integer casten. Wie haben uns so an diese Schreibweise gewöhnt, dass wir den notwendigen Boilerplate-Code gar nicht mehr in Frage stellen.

Wie es seit Java 16 besser geht, zeigt der folgende Code:

if (obj instanceof String s) { // <-- implicit cast to String s if (s.length() > 5) { System.out.println(s.toUpperCase()); } } else if (obj instanceof Integer i) { // <-- implicit cast to Integer i System.out.println(i * i); }
Code-Sprache: Java (java)

Anstatt explizite Casts zu programmieren, müssen wir lediglich einen Variablennamen hinter die instanceof-Prüfung setzen (Zeilen 1 und 5). Diese Variable ist dann vom in instanceof geprüften Zieltyp und innerhalb des if-Blocks sichtbar.

Wir können noch einen Schritt weitergehen und die ersten beiden if-Statements kombinieren:

if (obj instanceof String s && s.length() > 5) { System.out.println(s.toUpperCase()); } else if (obj instanceof Integer i) { System.out.println(i * i); }
Code-Sprache: Java (java)

Der Code ist mit fünf statt neun Zeilen nun deutlich prägnanter. Durch Pattern Matching wurde Redundanz eliminiert und die Lesbarkeit erhöht.

Pattern Matching for instanceof – Scope

Eine gematchte Variable ist nur innerhalb des if-Blocks bekannt. Das ist logisch, denn nur wenn der if-Vergleich positiv ausfüllt, kann die Variable auf den gewünschten Typ gecastet werden.

Wenn ein Feld mit gleichem Namen innerhalb der Klasse existiert, dann wird dieses Feld durch eine Pattern-Matching-Variable "geshadowed". Was das bedeutet, zeigt folgendes Beispiel:

public class PatternMatchingScopeTest { public static void main(String[] args) { new PatternMatchingScopeTest().processObject("Happy Coding!"); } private String s = "Hello, world!"; private void processObject(Object obj) { System.out.println(s); // Prints "Hello, world!" if (obj instanceof String s) { System.out.println(s); // Prints "Happy Coding!" System.out.println(this.s); // Prints "Hello, world!" } } }
Code-Sprache: Java (java)

Was gibt dieses Programm aus?

  • In Zeile 10 wird das in Zeile 7 definierte Feld s ausgegeben.
  • In Zeile 12 wird die im instanceof-Ausdruck zugewiesene Variable s ausgegeben, also das auf einen String gecastete, der Methode übergebene Objekt obj.
  • Um innerhalb des if-Blocks auf das Feld s zuzugreifen, wird in Zeile 13 this.s verwendet.

Nicht erlaubt ist es eine Pattern-Matching-Variable so zu benennen wie eine bereits in der Methode definierte Variable, wie in folgendem Beispiel:

private void processObject(Object obj) { String s = "Hello, world"; if (obj instanceof String s) { // Compiler error // ... } }
Code-Sprache: Java (java)

Der Compiler bricht hier mit der Fehlermeldung "Variable 's' is already defined in the scope" ab.

Pattern Matching for instanceof – Änderungen in Java 16

Gegenüber den ersten zwei Previews in Java 14 und Java 15 wurden für das finale Release zwei Verfeinerungen vorgenommen:

1. Pattern-Variablen sind nicht mehr implizit final, d. h. sie können verändert werden. Der folgende Code ist in Java 16 erlaubt; in Java 15 führte er noch zu einem "pattern binding may not be assigned" Compiler-Fehler:

if (obj instanceof String s && s.length() > 5) { s = s.toUpperCase(); // Compiler error in Java 15, allowed in Java 16 System.out.println(s); } else if (obj instanceof Integer i) { i = i * i; // Compiler error in Java 15, allowed in Java 16 System.out.println(i); }
Code-Sprache: Java (java)

2. Ein "Pattern Matching for instanceof"-Ausdruck führt zu einem Compiler-Fehler, wenn ein Ausdruck des Typs S mit einem Pattern des Typs T verglichen wird, wobei S ein Subtyp von T ist. Hier auch dafür ein Beispiel:

private static void processInteger(Integer i) { if (i instanceof Number n) { // Compiler error in Java 16 // ... } }
Code-Sprache: Java (java)

Die konkrete Fehlermeldung in diesem Beispiel lautet "pattern type Number is a subtype of expression type Integer". Was genau bedeutet das?

Da Integer von Number erbt, sind sowohl der instanceof-Check als auch der Cast auf Number überflüssig. Das Integer-Objekt kann auch ohne Cast an allen Stellen eingesetzt werden, an denen Number erwartet wird.

Pattern Matching – Ausblick

Im nächsten Release, Java 17, wird die nächste Pattern-Matching-Funktion, "Pattern Matching for switch" als Preview-Feature erstmals veröffentlicht werden.

Records

Ebenfalls produktionsreif – und das ebenfalls nach zwei Preview-Runden – sind in Java 16 die Records.

Records bieten eine kompakte Notationsmöglichkeit, um Klassen mit ausschließlich finalen Feldern zu definieren, wie in folgendem Beispiel:

record Point(int x, int y) {}
Code-Sprache: Java (java)

Was genau Records sind, wie man sie implementiert und verwendet, wie man sie um zusätzliche Funktionen erweitert und welche Besonderheiten bei ihnen zu beachten sind (z. B. bei der Vererbung oder der Deserialisierung) liest du aufgrund des Themenumfangs in einem separaten Artikel: Records in Java

(Records wurden erstmal als Preview-Feature in Java 14 vorgestellt. Im zweiten Preview wurden in Java 15 einige Verfeinerungen vorgenommen. Durch JDK Enhancement Proposal 395 wurden Records mit einer weiteren Änderung – sie dürfen jetzt auch innerhalb von inneren Klassen definiert werden – als produktionsreif eingestuft.)

Migrate from Mercurial to Git + Migrate to GitHub

Bisher wurde Java mit dem Versionskontrollsystem Mercurial entwickelt. Durch das JDK Enhancement Proposal 357 wurde der Java-Quellcode zu Git migriert. Dafür gab es mehrere Gründe:

  • Verbreitung: Mit Git sind weitaus mehr Entwickler vertraut als mit Mercurial. Der Umstieg soll es für die Developer Community attraktiver machen sich an der JDK-Entwicklung zu beteiligen.
  • Größe der Metadaten: Das Mercurial-Repository benötigt etwa 1,2 GB an Metadaten. Git kommt mit nur 300 MB aus, spart also Plattenplatz und Downloadzeit. Darüber hinaus bietet Git mit dem Parameter --depth sogenanntes "Shallow Cloning", wobei nur ein Teil der Commit-Historie geklont wird.
  • Tools: Git-Support ist in jede IDE und zahlreiche Texteditoren integriert. Und es gibt grafische Tools für alle Betriebssysteme.
  • Hosting: Es steht eine große Auswahl an Git-Hosting-Anbietern zur Verfügung.

Bleiben wir beim Thema Hosting: Im JDK Enhancement Proposal 369 wurde entschieden das JDK auf GitHub zu hosten. Die Gründe dafür sind:

  • GitHub bietet hervorragende Performance.
  • GitHub ist der weltweit größte Git-Hoster.
  • GitHub verfügt über eine umfassende API.

Die GitHub-API wiederum wird von zahlreichen IDEs angebunden und ermöglicht es z. B. Pull Requests direkt in der IDE zu erstellen, zu reviewen und zu kommentieren.

Warnings for Value-Based Classes

Für die Beschreibung dieses JEPs muss ich etwas ausholen:

Project Valhalla steht für eine Erweiterung von Java um sogenannte Value Types: Unveränderliche Objekte, die im Speicher durch ihren Wert repräsentiert sind – und nicht durch eine Referenz auf eine Objekt-Instanz (analog zu den primitiven Datentypen wie int, long und double).

Value Types werden demzufolge keinen Konstruktor haben, der bei jedem Aufruf eine neue Instanz mit eindeutiger Identität erstellt.

Value-Type-Instanzen, die per equals() als gleich identifiziert werden, werden auch per == als identisch gelten.

Durch JDK Enhancement Proposal 390 wurden existierende Klassen des JDK als Kandidaten für zukünftige Value Types identifiziert. Diese wurden mit der neuen Annotation @ValueBased versehen und deren Konstruktoren als "deprecated for removal markiert".

Dazu gehören u. a.:

  • alle Wrapper-Klassen der primitiven Datentypen (Byte, Short, Integer, Long, Float, Double, Boolean und Character),
  • Optional und dessen primitive Varianten,
  • Zahlreiche Klassen der Date/Time-API, wie z. B. LocalDateTime,
  • die durch List.of(), Set.of() und Map.of() erzeugten Collections.

Eine vollständige Liste aller als @ValueBased markierten Klassen findest du im oben verlinkten JEP.

Ohne Identität können diese Objekte nicht mehr als Monitore für die Synchronisation verwendet werden. Daher werden ab Java 16 Warnungen ausgegeben, wenn auf Instanzen dieser Objekte synchronisiert wird.

In Zukunft (wann genau, steht noch nicht fest – in Java 18 wird es noch nicht so weit sein) werden die Konstruktoren vollständig entfernt werden, und der Versuch auf Value Types zu synchronisieren wird zu einem Compiler-Fehler oder zu einer Exception führen.

Strongly Encapsulate JDK Internals by Default

In Java 9 wurde das Modulsystem (Project Jigsaw) eingeführt. Die meisten Programme liefen ohne große Anpassungen weiter. Wir mussten maximal einige Java-EE-Dependencies hinzufügen, die seither nicht mehr Teil von Java SE sind.

Vor Java 16: Relaxed Strong Encapsulation

Der Grund für die problemslose Migration ist, dass die JDK-Entwickler uns für eine Übergangszeit den sogenannten "Relaxed strong encapsulation"-Modus zur Verfügung gestellt haben.

Dieser Modus bedeutet, dass alle Packages, die es schon vor Java 9 gab, für alle unbenannten Module offen für Deep Reflection sind – also den Zugriff auf nicht-öffentliche Klassen und Methoden per setAccessible(true).

Seit Java 16: Strong Encapsulation

In Java 16 existiert dieser Modus zwar noch, ist aber standardmäßig deaktiviert.

Java 16 läuft stattdessen im "Strong encapsulation"-Modus, d. h. dass jeglicher Zugriff auf nicht-öffentliche Klassen und Methoden untersagt ist, sofern er nicht explizit per "opens" in der Moduldeklaration oder "--add-opens" auf der Kommandozeile erlaubt wird.

Von daher ist es, wenn du auf Java 16 aktualisierst, gut möglich, dass du Fehlermeldungen dieser Art sehen wirst:

java.lang.reflect.InaccessibleObjectException: Unable to make java.lang.invoke.MethodHandles$Lookup(java.lang.Class) accessible: module java.base does not "opens java.lang.invoke" to unnamed module @2de8da52
Code-Sprache: Klartext (plaintext)

In diesem Beispiel bedeutet das, dass der Code versucht, den paket-privaten Konstruktor Lookup(Class lookupClass) der inneren Klasse MethodHandles$Lookup über Reflection zugänglich zu machen. Das ist im "Strong encapsulation"-Modus nicht mehr erlaubt. Du musst dies nun explizit mit "--add-opens" erlauben. Die Syntax lautet:

--add-opens module/package=target-module(,target-module)*

Was muss man anstelle der Platzhalter "module", "package" und "target-module" eintragen?

Du kannst diese Werte quasi direkt aus der letzten Zeile der Fehlermeldung übernehmen:

  • module: "java.base"
  • package: "java.lang.invoke"
  • target-module: Wenn du ein Modul für deinen Code definiert hast, steht am Ende der Fehlermeldung der Modulname. Ansonsten steht dort "unnamed module" gefolgt von einem Hashwert. Als "target-module" trägst du, wenn vorhanden, den Modulnamen ein, ansonsten "ALL-UNNAMED". (Den konkreten Hashwert "@2de8da52" kannst du nicht verwenden, da dieser sich bei jedem Start der Anwendung ändert.)

Übertragen wir die Werte aus der Fehlermeldung, dann lautet die anzugebende VM-Option:

--add-opens java.base/java.lang.invoke=ALL-UNNAMED

Und wenn ich diese Option nicht angeben will und stattdessen lieber den alten Modus zurückhaben möchte?

VM-Option --illegal-access

Über die VM-Option "--illegal-access" kannst du das bisherige Verhalten wiederherstellen. Folgende Modi kannst du darüber einstellen:

--illegal-access=deny"Strong encapsulation":
Deep Reflection von anderen Modulen aus ist grundsätzlich verboten (Standardeinstellung in Java 16).
--illegal-access=permit"Relaxed strong encapsulation":
Deep Reflection von anderen Modulen auf Packages, die vor Java 9 existierten, ist erlaubt. Beim ersten Zugriff wird eine Warnung ausgegeben. Deep Reflection auf Packages, die seit Java 9 hinzugefügt wurden, ist verboten (Standardeinstellung von Java 9 bis 15).
--illegal-access=warnWie "permit", allerdings wird nicht nur beim ersten, sondern bei jedem Zugriff eine Warnung ausgegeben.
--illegal-access=debugWie "warn" mit zusätzlicher Ausgabe eines Stack Traces.

Ich rate dir allerdings dringend davon ab diese VM-Option einzusetzen. Bereits im nächsten Release, Java 17, wird sie nicht mehr zur Verfügung stehen, und es wird nur noch den Modus "Strong encapsulation" geben.

(Die standardmäßige Aktivierung von "Strong Encapsulation" ist im JDK Enhancement Proposal 396 definiert.)

Neue Stream-Methoden

Java 16 führt die folgenden zwei neuen Stream-Methoden ein:

Stream.toList()

Um einen Stream in eine Liste zu terminieren, waren bisher folgende Varianten möglich:

// ArrayList: Stream.of("foo", "bar", "baz").collect(Collectors.toList()); // ImmutableCollections$ListN: Stream.of("foo", "bar", "baz").collect(Collectors.toUnmodifiableList()); // LinkedList: Stream.of("foo", "bar", "baz").collect(Collectors.toCollection(LinkedList::new));
Code-Sprache: Java (java)

Die Rückgabetypen der ersten zwei Varianten sind nicht garantiert. Bei der ersten Variante Collectors.toList() wird tatsächlich nicht einmal garantiert, dass die Liste modifizierbar ist. Bei der zweiten Variante Collectors.toUnmodifiableList() wird zumindest garantiert, dass der Rückgabewert eine unmodifizierbare Liste ist.

Mit Stream.toList() kommt eine vierte Variante hinzu, die ebenfalls eine unmodifizierbare Liste generiert:

// ImmutableCollections$ListN: Stream.of("foo", "bar", "baz").toList();
Code-Sprache: Java (java)

Diese Methode ist als Default-Methode im Stream-Interface implementiert und wird in den meisten Stream-Implementierungen durch eine Stream-spezifische Optimierung überschrieben,

Stream.mapMulti()

Um in einem Stream enthaltene Collections in eine einzige Collection zu mergen, benutzen wir in der Regel flatMap():

Stream<List<Integer>> stream = Stream.of( List.of(1, 2, 3), List.of(4, 5, 6), List.of(7, 8, 9)); List<Integer> list = stream.flatMap(List::stream).toList();
Code-Sprache: Java (java)

Als Parameter für flatMap() müssen wir eine Mapper-Funktion angeben, die jede im Stream enthaltene Collection wiederum in einen Stream konvertiert.

Dieses Beispiel war stark vereinfacht. Der Stream muss nicht direkt Collections enthalten. Er könnte z. B. auch Customer-Objekte enthalten, deren getOrders()-Methode eine Liste von Bestellungen zurückgibt. Mit flatMap() könnten wir dann eine Liste aller Bestellungen der Kunden zusammenstellen:

List<Customer> customers = getCustomers(); List<Order> allOrders = customers.stream() .flatMap(customer -> customer.getOrders().stream()) .toList();
Code-Sprache: Java (java)

Beide Beispiele haben gemeinsam, dass für jedes Element des ursprünglichen Streams ein neuer Stream generiert wird. Das ist mit einem gewissen Overhead verbunden.

Daher wurde in Java 16 mit Stream.mapMulti() eine effizientere, imperative Alternative zum deklarativen flatMap() eingeführt: Während wir bei flatMap() festlegen, welche Daten wir zusammenführen wollen, implementieren wir bei mapMulti(), wie wir diese Daten zusammenführen.

Dazu übergeben wir einen BiConsumer, dem während des Mapping-Vorgangs folgende zwei Elemente übergeben werden:

  1. Das Element des Streams, also die einzusammelnde Collection (die Liste im ersten Beispiel) oder das Objekt, aus dem eine Collection extrahiert wird (der Customer im zweiten Beispiel).
  2. Einen Consumer, dem wir die Elemente der Collection einzeln übergeben.

Hier das erste Beispiel konvertiert zu mapMulti():

List<Integer> list = stream .mapMulti( (List<Integer> numbers, Consumer<Integer> consumer) -> numbers.forEach(number -> consumer.accept(number))) .toList();
Code-Sprache: Java (java)

Den Lambda-Body können wir durch eine einzelne Methodenreferenz ersetzen:

List<Integer> list = stream .mapMulti((BiConsumer<List<Integer>, Consumer<Integer>>) Iterable::forEach) .toList();
Code-Sprache: Java (java)

Was wir hier sagen ist: Iteriere jeweils über die Elemente der im Stream enthaltenen Listen, und übergebe alle Einzelelemente an den bereitgestellten Consumer. Der Zwischenschritt über die Erzeugung eines neuen Streams pro Liste entfällt.

Und hier das zweite Beispiel:

List<Order> allOrders = customers.stream() .mapMulti( (Customer customer, Consumer<Order> consumer) -> customer.getOrders().forEach(consumer)) .toList();
Code-Sprache: Java (java)

Wir iterieren über die Bestellungen jedes Kunden und übergeben diese an den bereitgestellten Consumer.

Sollte man nun immer mapMulti() statt flatMap() einsetzen? Nein, mapMulti() ist lediglich ein weiteres Tool in unserem Werkzeugkasten. Wir sollten generell nicht vorzeitig optimieren und diejenige Methode einsetzen, die im jeweiligen Fall am besten lesbar ist. In den Beispielen oben würde ich bei flatMap() bleiben.

Sollte sich der Code, der flatMap() aufruft, als Hotspot erweisen, kann man testen, ob mapMulti() zu einer messbaren Leistungssteigerung führt und, nur wenn dem so ist, umstellen.

Packaging Tool

Seit der in Java 8 eingeführte javapackager mitsamt JavaFX in Java 11 wieder entfernt wurde, wartete die Java-Community sehnlichst auf Ersatz.

Als Nachfolger wurde in Java 14 das Tool jpackage im Incubator-Status präsentiert. Mit dem JDK Enhancement Proposal 392 wird jpackage in Java 16 als produktionsreif eingestuft.

jpackage verpackt eine Java-Anwendung mitsamt der Java-Laufzeitumgebung (also der JVM und der Klassenbibliothek*) in ein Installationspaket für verschiedene Betriebssysteme, um Endbenutzern ein einfaches und für sie natürliches Installationserlebnis zu bieten.

Unterstützt werden:

  • Windows (exe und msi)
  • macOS (pkg und dmg)
  • Linux (deb und rpm)

(* Bei einer Anwendung, die das Java-Modulsystem nutzt, wird die Klassenbibliothek auf die tatsächlich genutzten Module komprimiert.)

Verwendung von jpackage

Das folgende Beispiel zeigt, wie ein minimales, nicht-modulares Java-Programm compiliert und mit jpackage in einen Installer des aktuell genutzten Betriebssystems verpackt wird.

Die folgende Datei Main.java liegt im Verzeichnis src/eu/happycoders:

package eu.happycoders; public class Main { public static void main(String[] args) { System.out.println("Happy Coding!"); } }
Code-Sprache: Java (java)

Wir compilieren die Datei wie folgt (die letzten zwei Zeilen musst du unter Windows als eine Zeile schreiben):

javac -d target/classes src/eu/happycoders/Main.java jar cf lib/happycoders.jar -C target/classes . jpackage --name happycoders --input lib \ --main-jar happycoders.jar --main-class eu.happycoders.Main
Code-Sprache: Klartext (plaintext)

Unter Windows wird dadurch der ausführbare Installer happycoders-1.0.exe erstellt, unter Debian-Linux das Software-Paket happycoders_1.0-1_amd64.deb.

Mit der Option --type kann ein anderes Format erstellt werden, unter macOS z. B. eine pkg-Datei anstelle der standarmäßigen dmg-Datei:

jpackage --name happycoders --input lib \ --main-jar happycoders.jar --main-class eu.happycoders.Main --type pkg
Code-Sprache: Klartext (plaintext)

Die Erzeugung von Installern für andere als das aktuell eingesetzte Betriebssystem wird nicht unterstützt.

Weitere jpackage-Optionen

Wie du jpackage für eine modulare Anwendung verwendest und welche weiteren Optionen das Tool bietet, findest du in der jpackage-Dokumentation.

Performance-Verbesserungen

Java 16 bringt Performance-Verbesserungen an den Garbage Collectoren und am Metaspace und erlaubt eine effizientere Interprozesskommunikation über die neu unterstützten Unix-Domain-Sockets.

ZGC: Concurrent Thread-Stack Processing

Ziel des in Java 15 final veröffentlichten Z Garbage Collectors (ZGC) ist es, Stop-the-World-Phasen so kurz wie möglich (d. h. im einstelligen Millisekundenbereich) zu halten.

Erreicht werden soll das, indem möglichst viele Operationen des Garbage Collectors aus den sogenannten Safepoints (während derer die Anwendung angehalten wird) herausgenommen und parallel zur Anwendung ausgeführt werden.

Mit dem JDK Enhancement Proposal 376 wird nun auch die letzte dieser Operationen, das sogenannte "Thread-Stack Processing", aus den Safepoints entfernt.

Die Safepoints sind damit auf das absolut Notwendige reduziert. Sie erhalten keine Operationen mehr, deren Ausführungszeit mit der Größe des Heaps skaliert. Die Stop-the-World-Phasen des ZGC dauern nun in der Regel weniger als eine Millisekunde, unabhängig von der Heap-Größe.

Weitere Details findest du im oben verlinkten JEP und im ZGC-Wiki.

Concurrently Uncommit Memory in G1

Die Bestimmung, wie viel Speicher der G1 Garbage Collector an das Betriebssystem zurückgibt, und die eigentliche Rückgabe erfolgte bisher in einer Stop-the-World-Pause.

Dies wurde dahingehend optimiert, dass nur noch die Berechnung während der Pause stattfindet, die eigentliche Freigabe aber parallel zur Anwendung läuft.

(Für diese Optimierung gibt es kein JDK Enhancement Proposal.)

Elastic Metaspace

Im sogenannten "Metaspace" speichert die JVM Klassen-Metadaten, also alle Informationen über eine Klasse, wie z. B. die Elternklasse, Methoden und Feldnamen – nicht aber den Inhalt der Felder (der liegt auf dem Heap).

Ja nach Anwendungsprofil kann der Metaspace einen übermäßig hohen Speicherverbrauch aufweisen.

Durch JDK Enhancement Proposal 387 wird der Memory-Footprint generell gesenkt, und Metaspace-Speicher wird schneller ans Betriebssystem zurückgegeben.

Darüber hinaus wurde der Quellcode für die Verwaltung des Metaspace vereinfacht, um die Maintenance-Kosten zu reduzieren.

Unix-Domain Socket Channels

Unix-Domain-Sockets werden für die Interprozesskommunikation (IPC) innerhalb eines Hosts verwendet.

Sie ähneln TCP/IP-Sockets, werden aber über Dateisystem-Pfade adressiert, nicht über IP-Adressen. Sie sind sicherer (kein Zugriff von außerhalb des Hosts möglich) und bieten einen schnelleren Verbindungsaufbau und einen höheren Datendurchsatz als TCP/IP-Loopback-Verbindungen.

Durch den JDK Enhancement Proposal 380 können nun auch Java-Entwicklerinnen und -Entwickler Unix-Domain-Sockets einsetzen.

Aus Programmiersicht ändert sich wenig im Vergleich zu TCP/IP-Sockets: Die Unix-Domain-Socket-Unterstützung wurde in die bestehenden SocketChannel- und ServerSocketChannel-APIs integriert.

Das folgende (sehr rudimentäre) Beispiel zeigt, wie ein TCP/IP-Server-Socket auf Port 8080 geöffnet wird und wie ein Client sich mit diesem Server verbindet:

var socketAddress = new InetSocketAddress(8080); // Server var serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.bind(socketAddress); // Client var socketChannel = SocketChannel.open(); socketChannel.connect(remoteAddress);
Code-Sprache: Java (java)

Und hier das analoge Beispiel mit dem Unix-Domain-Socket-Pfad "~/happycoders.socket":

var socketPath = Path.of(System.getProperty("user.home")).resolve("happycoders.socket"); var socketAddress = UnixDomainSocketAddress.of(socketPath); // Server var serverSocketChannel = ServerSocketChannel.open(StandardProtocolFamily.UNIX); serverSocketChannel.bind(socketAddress); // Client var socketChannel = SocketChannel.open(StandardProtocolFamily.UNIX); socketChannel.connect(remoteAddress);
Code-Sprache: Java (java)

Du musst also lediglich beim Öffnen eines Channels den Typ StandardProtocolFamily.UNIX angeben und eine Adresse vom Typ UnixDomainSocketAddress anstelle von InetSocketAddress verwenden. Die weitere Arbeit mit dem Channel ist für beide Typen gleich.

Unix-Domain-Sockets sind übrigens nicht auf Unix-Plattformen beschränkt; sie werden auch von Windows 10 und Windows Server 2019 unterstützt.

Experimentelle, Preview- und Incubator-Features

Seit Java 10 wird alle sechs Monate ein neues Java-Release veröffentlicht. So können neue Features in nicht-finalem Zustand mit ausgeliefert und getestet werden. Die Java-Community kann dann Feedback geben, das bei der Weiterentwicklung der Features berücksichtigt wird.

Auch in Java 16 gibt es weiterentwickelte und neue Preview- und Incubator-Features. Ich werde insbesondere die Incubator-Features nicht detailliert vorstellen, sondern, soweit bekannt, auf die Java-Version verweisen, in der diese Features Produktionsreife erlangen.

Sealed Classes (Second Preview)

Sealed Classes wurden in Java 15 als Preview vorgestellt.

Durch JDK Enhancement Proposal 397 wurden für Java 16 drei kleine Änderungen vorgenommen:

1. In der Java Language Specification (JLS) ersetzt das Konzept der "contextual keyword" die bisherigen "restricted identifiers" and "restricted keywords". "Contextual keywords" sorgen dafür, dass neue Keywords wie sealed, permits (oder yield aus den Switch Expressions) außerhalb des jeweiligen Kontexts z. B. als Variablen- oder Methodennamen weiterhin verwendet werden dürfen. So muss bestehender Code beim Upgrade auf eine neue Java-Version nicht geändert werden.

Folgendes ist also erlaubt:

public void sealed() { int permits = 5; }
Code-Sprache: Java (java)

2. Das Keyword "permits" kann weggelassen werden, wenn innerhalb einer Klassendatei ("compilation unit") von einer versiegelten Klasse abgeleitete Unterklassen definiert werden. Diese gelten dann als "implizit deklarierte zulässige Unterklassen" ("implicitly declared permitted subclasses").

Ein Beispiel dafür findest du im Artikel über Sealed Classes.

Was im zweiten Preview von Sealed Classes geändert wurde, ist, dass lokale (also innerhalb von Methoden definierte) Klassen versiegelte Klassen nicht erweitern dürfen.

Auch dafür findest du ein Beispiel im Artikel über Sealed Classes.

3. Bei instanceof-Tests prüft der Compiler, ob die Klassenhierarchie es zulässt, dass der Check jemals true ergeben kann. Seit dem zweiten Preview der Sealed Classes werden die Informationen aus versiegelten Klassenhierarchien mit in diese Prüfung aufgenommen.

Was das bedeutet, erkläre ich im Artikel über Sealed Classes an einem Beispiel.

Vector API (Incubator)

Vector ... gibt es den nicht schon seit Java 1.0?

Nein, hier geht es nicht um die List-Implementierung java.util.Vector, sondern um Vektorrechnung im mathematischen Sinn.

Ein Vektor entspricht im Grunde einem Array von skalaren Werten (byte, short, int, long, float oder double). In der Vektorrechnung werden skalare Operationen (z. B. Addition) auf zwei Vektoren der gleichen Größe angewendet. Dabei wird diese Operation paarweise auf jedes Element der Vektoren angewendet.

Bei der Vektoraddition bspw. addiert man das erste Element des ersten Vektors mit dem ersten Element des zweiten Vektors, das zweite Element des ersten Vektors mit dem zweiten Element des zweiten Vektors, usw. (das kennst du vermutlich aus dem Mathe-Unterricht):

Beispiel einer Vektoraddition
Beispiel einer Vektoraddition

Moderne CPUs und GPUs können solche Operationen bis zu einer bestimmten Vektorgröße innerhalb von einem einzigen CPU-Zyklus ausführen und damit die Leistung erheblich steigern.

Die mit dem JDK Enhancement Proposal 338 als Incubator-Feature erstmals vorgestellte Vektor-API ermöglicht es uns solche Operationen in Java zu implementieren. Die JVM wird diese auf die effizientesten CPU-Instruktionen der zugrunde liegenden Hardware-Architektur abbilden.

Incubator-Features unterliegen in der Regel noch wesentlichen Änderungen. Ich werde die Vector-API daher dann vorstellen, wenn sie den Preview-Status erreicht hat.

Foreign Linker API (Incubator) + Foreign-Memory Access API (Third Incubator)

Bereits seit Java 1.1 gibt es mit dem Java Native Interface (JNI) die Möglichkeit, von Java aus auf nativen C-Code zuzugreifen. Wer JNI einmal eingesetzt hat, weiß: Es ist aufwändig, fehleranfällig und langsam. Man muss eine Menge Java- und C-Boilerplate-Code schreiben und synchron halten, was selbst mit Tool-Unterstützung noch kompliziert ist.

Um JNI durch eine modernere API zu ersetzen, wurde Project Panama ins Leben gerufen.

Die Foreign Linker API (JDK Enhancement Proposal 389) zusammen mit der in Java 14 als Incubator vorgestellten, in Java 15 und Java 16 noch einmal verfeinerten Foreign-Memory Access API (JDK Enhancement Proposal 393) stellen diesen Ersatz dar.

Die Panama-Entwickler haben sich folgende Ziele gesetzt:

  1. Der bisher aufwändige und fehleranfällige Prozess soll vereinfacht werden (angepeilt ist eine Reduktion um 90 % des Aufwands).
  2. Die Leistung soll im Vergleich zu JNI wesentlich erhöht werden (angepeilt ist Faktor 4 bis 5).

Foreign Linker API und Foreign-Memory Access API werden in Java 17 zur "Foreign Function & Memory API" zusammengefasst werden. Diese wird noch bis Java 18 im Incubator-Status bleiben und in Java 19 das Preview-Stadium erreichen.

Deprecations

In Java 16 wurden einige Funktionen als "deprecated" markiert. Eine davon möchte ich hier mit auflisten, die restlichen findest du in den Release Notes.

Terminally Deprecated ThreadGroup stop, destroy, isDestroyed, setDaemon and isDaemon

In Java 14 wurde damit begonnen Thread- und ThreadGroup-Methoden, die seit Java 1.2 "deprecated" sind, als "deprecated for removal" zu markieren.

In Java 16 wurden nun auch die ThreadGroup-Methoden stop(), destroy(), isDestroyed(), setDaemon() and isDaemon() als "deprecated for removal" markiert.

Der Mechanismus zum Zerstören einer Thread-Gruppe war im JDK von Anfang an mangelhaft implementiert und soll in einer zukünftigen Version komplett entfernt werden; damit ist auch das Konzept der Daemon-Thread-Gruppe hinfällig.

Sonstige Änderungen in Java 16

In diesem Kapitel liste ich Änderungen auf, mit denen die meisten Java-Entwicklerinnen und -Entwickler in ihrer täglichen Arbeit nicht in Berührung kommen werden. Darüber einmal gelesen zu haben, schadet aber auch nicht :-)

Add InvocationHandler::invokeDefault Method for Proxy's Default Method Support

Wenn du mit Dynamic Proxies arbeitest, wird diese Erweiterung interessant für dich sein. Am besten lässt sie sich an einem Beispiel erklären. Nehmen wir folgendes Interface:

public interface GreetingInterface { String getName(); default String greet() { return "Hello, " + getName(); } }
Code-Sprache: Java (java)

Mit dem folgenden Code erstellen wir dafür einen dynamischen Proxy (das ist kein neues Feature – Dynamic Proxies gibt es seit Java 1.3):

GreetingInterface greetingProxy = (GreetingInterface) Proxy.newProxyInstance( GreetingTest.class.getClassLoader(), new Class[] {GreetingInterface.class}, (proxy, method, args) -> { if (method.getName().equals("getName")) { return "Sven"; } else if (method.getName().equals("greet")) { return "Hello, " + ((GreetingInterface) proxy).getName(); } else { throw new IllegalStateException( "Method not implemented: " + method); } });
Code-Sprache: Java (java)

Den dynamischen Proxy können wir dann über die GreetingInterface-Methoden nutzen:

System.out.println("name = " + greetingProxy.getName()); System.out.println("greet = " + greetingProxy.greet());
Code-Sprache: Java (java)

Die Ausgabe ist:

name = Sven greet = Hello, Sven
Code-Sprache: Klartext (plaintext)

Wer gut aufgepasst hat, wird feststellen, dass wir etwas Code duplizieren mussten, nämlich die Implementierung der greet()-Methode. Diese ist einmal als Default-Methode im GreetingInterface implementiert – und noch einmal im InvocationHandler-Lambda (Zeile 8 des zweiten Listings).

In Java 16 wurde die InvocationHandler-Klasse um die statische Methode invokeDefault() erweitert, mit der wir den duplizierten Code eliminieren und stattdessen die Default-Methode des Interfaces aufrufen können (Zeile 8):

GreetingInterface greetingProxy = (GreetingInterface) Proxy.newProxyInstance( GreetingTest.class.getClassLoader(), new Class[] {GreetingInterface.class}, (proxy, method, args) -> { if (method.getName().equals("getName")) { return "Sven"; } else if (method.isDefault()) { return InvocationHandler.invokeDefault(proxy, method, args); } else { throw new IllegalStateException( "Method not implemented: " + method); } });
Code-Sprache: Java (java)

In Zeile 7 habe ich außerdem die Prüfung der Methode auf den Namen "greet" durch if (method.isDefault()) ersetzt und damit den if-Zweig auf alle Default-Methoden erweitert. Somit müssen wir den InvocationHandler nicht anpassen, sollten wir dem Interface weitere Default-Methoden hinzufügen.

(Diese Erweiterung ist in keinem JDK Enhancement Proposal definiert.)

Day Period Support Added to java.time Formats

Mit der Klasse DateTimeFormatter kannst du Datumswerte der Java Date/Time API, also z. B. LocalDate, LocalTime, LocalDateTime, oder auch Instant, Year und YearMonth formatieren.

Den aktuellen Zeitpunkt kannst du bspw. wie folgt formatieren:

DateTimeFormatter.ofPattern("EEEE, d. MMMM yyyy, H:mm", Locale.GERMANY) .format(LocalDateTime.now());
Code-Sprache: Java (java)

Heraus kommt dabei z. B.:

Mittwoch, 1. Dezember 2021, 21:12

In manchen Gegenden ist es üblich die Uhrzeit nicht im 24-Stunden-Format anzugeben, sondern im 12-Stunden-Format und dann mit "AM" und "PM" zu kennzeichnen, um welche Tageszeit es sich handelt:

DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy, h:mm a", Locale.US) .format(LocalDateTime.now());
Code-Sprache: Java (java)

Das ergibt folgendes:

Wednesday, December 1, 2021, 9:14 PM

In Java 16 wurde die Liste der verfügbaren Format-Zeichen um den Buchstaben "B" erweitert. Dieser steht für eine ausgeschriebene Form der Tageszeit:

DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy, h:mm B", Locale.US) .format(LocalDateTime.now());
Code-Sprache: Java (java)

Der erzeugte String lautet nun:

Wednesday, December 1, 2021, 9:16 at night

Das funktioniert auch im Deutschen:

DateTimeFormatter.ofPattern("EEEE, d. MMMM yyyy, h:mm B", Locale.GERMANY) .format(LocalDateTime.now());
Code-Sprache: Java (java)

Das ergibt die eher gewöhnungsbedürfte Schreibweise:

Mittwoch, 1. Dezember 2021, 9:18 abends

(Für diese Erweiterung existiert kein JDK Enhancement Proposal.)

Alpine Linux Port

Das besonders im Cloud-Umfeld sehr beliebte Alpine-Linux basiert auf der C-Bibliothek "musl". Das bedeutet, dass Code, der gegen die in den meisten Linux-Distributionen verwendete C-Bibliothek "glibc" kompiliert wurde, nicht ohne weiteres auf Alpine Linux läuft.

Dazu gehört auch die JVM. Um diese auf Alpine laufen zu lassen, benötigte man bisher eine glibc-Portabilitätsschicht.

Alpine Linux ist gerade deshalb so beliebt, weil es nur wenige Megabytes groß ist. Zusammen mit einer abgespeckten JVM-Version kann man ein Docker-Image mit nur 38 MB Größe generieren.

Die glibc-Portabilitätsschicht würde dem weitere 26 MB hinzufügen.

Durch den JDK Enhancement Proposal 386 wird das JDK auf Alpine Linux (und alle anderen Linux-Distributionen, die "musl" verwenden) portiert. Damit wird die glibc-Portabilitätsschicht nicht mehr benötigt, was die Docker-Images wesentlich verkleinert.

Windows/AArch64 Port

Auch Windows auf der ARM64/AArch64-Prozessorarchitektur ist zu einer wichtigen Plattform geworden. Daher wurde das JDK durch den JDK Enhancement Proposal 388 auf Windows/AArch64 portiert.

Dazu wurde der bereits seit Java 9 existierende Linux/AArch64-Port als Grundlage genommen, so dass der Aufwand überschaubar blieb.

Enable C++14 Language Features

Diese Änderung ist für C++-Entwickler interessant, die an der Entwicklung des JDK selbst mitarbeiten wollen. Der C++-Anteil des JDK war bisher auf die Sprachspezifikation C++98/03 – also eine über 20 Jahre alte Spezifikation – beschränkt.

Durch JDK Enhancement Proposal 347 wird die Unterstützung auf C++14 angehoben.

Da die Zielgruppe dieses Artikels Java-Entwickler sind, werde ich auf die Bedeutung dieser Änderung für die eingesetzten Build-Systeme und die im JDK zugelassenen C++-Sprachmittel nicht weiter eingehen. Du findest diese Details im oben verlinkten JEP.

Vollständige Liste aller Änderungen in Java 16

In diesem Artikel habe ich alle Java-16-Änderungen vorgestellt, die in JDK Enhancement Proposals definiert sind, sowie einige Erweiterungen der JDK-Klassenbibliothek und Performance-Optimierungen, für die keine JEPs existieren.

Eine vollständige Liste aller Änderungen findest du in den offiziellen Java 16 Release Notes.

Fazit

Java 16 war ein sehr umfangreiches Release:

  • "Pattern Matching for instanceof" und Records sind der Preview-Phase entwachsen und können in produktivem Code eingesetzt werden. Insbesondere mit Records wird man viele Zeilen an Boilerplate Code eliminieren können.
  • Der Umzug auf Git und GitHub macht die Beteiligung an der JDK-Entwicklung attraktiver für die Developer Community.
  • Mit "Warnings for Value-Based Classes" werden die ersten Schritte Richtung Value Types (Project Valhalla) vollzogen.
  • "Strong Encapsulation" ist nun der Standard, d. h. Zugriff auf andere Module per Deep Reflection muss explizit (mit opens oder --add-opens) gestattet werden.
  • Stream.toList() und Stream.mapMulti() erweitern unseren Stream-Werkzeugkasten.
  • Mit jpackage können wir nach der Entfernung von javapackager in Java 11 endlich wieder Installationspakete erstellen.
  • An den Garbage Collectoren und am Metaspace wurden Performance-Verbesserungen vorgenommen, und durch die Einführung von Unix-Domain-Socket-Channels kann die Interprozesskommunikation innerhalb eines Hosts effizienter implementiert werden.
  • Als weitere Incubator-Projekte kamen die Vector-API und die Foreign Linker API hinzu.
  • Kleinere Erweiterungen der Klassenbibliothek und zwei neue Ports runden das Release ab.

Wenn dir der Artikel gefallen hat, hinterlasse mir gerne einen Kommentar, oder teile den Artikel über einen der Share-Butons am Ende.

Wir nähern uns mit großen Schritten dem nächsten Long-Term-Support-Release, Java 17. Möchtest du informiert werden, wenn der nächste Artikel veröffentlicht wird? Dann klick hier, um dich für den HappyCoders-Newsletter anzumelden.