java 18 featuresjava 18 features
HappyCoders Glasses

Java 18 Features
(mit Beispielen)

Sven Woltmann
Sven Woltmann
Aktualisiert: 27. November 2024

Java 18, veröffentlicht am 22. März 2022, ist das erste "Zwischen-Release" nach dem letzten Long-Term-Support (LTS)-Release, Java 17.

Der Umfang an Erweiterungen in Java 18 ist mit neun umgesetzten JEPs gegenüber den vorherigen Releases deutlich zurückgegangen. Nach dem LTS-Release Java 17 sei den JDK-Entwicklern eine kleine Verschnaufspause gegönnt ;-)

Wie immer verwende ich die englischen Bezeichnungen der JEPs. Diese auf deutsch zu übersetzen würde eher verwirren als einen Mehrwert zu bringen.

UTF-8 by Default

Seit langem müssen wir Java-Entwicklerinnen und -Entwickler mit der Tatsache umgehen, dass der Java-Standardzeichensatz je nach Betriebssystem und Spracheinstellungen unterschiedlich ist.

Das ändert sich mit Java 18 :-)

Das Problem

Der Java-Standardzeichensatz bestimmt bei zahlreichen Methoden der JDK-Klassenbibliothek, wie Zeichenfolgen in Bytes umgewandelt werden und umgekehrt (z. B. beim Schreiben und Lesen einer Textdatei). Dazu gehören z. B.:

  • die Konstruktoren von FileReader, FileWriter, InputStreamReader, OutputStreamWriter,
  • die Konstruktoren von Formatter und Scanner,
  • die statischen Methoden URLEncoder.encode() und URLDecoder.decode().

Das kann zu unvorhergesehenem Verhalten führen, wenn eine Anwendung in einer Umgebung entwickelt und getestet – und dann in einer anderen Umgebung (in der sich Java für einen anderen Standardzeichensatz entscheidet) ausgeführt wird.

Führen wir z. B. den folgenden Code unter Linux oder macOS aus (der japanische Text heißt laut Google Translate "Happy Coding!"):

try (FileWriter fw = new FileWriter("happy-coding.txt");
    BufferedWriter bw = new BufferedWriter(fw)) {
  bw.write("ハッピーコーディング!");
}Code-Sprache: Java (java)

... und laden diese Datei dann mit folgendem Code unter Windows:

try (FileReader fr = new FileReader("happy-coding.txt");
    BufferedReader br = new BufferedReader(fr)) {
  String line = br.readLine();
  System.out.println(line);
}Code-Sprache: Java (java)

Dann wird folgendes angezeigt:

�ッピーコーディング�Code-Sprache: Klartext (plaintext)

Das liegt daran, dass Linux und macOS die Datei im UTF-8-Format speichern und Windows sie im Windows-1252-Format versucht zu lesen.

Das Problem – Stufe zwei

Noch chaotischer wird es dadurch, dass neuere Methoden der Klassenbibliothek den Standardzeichensatz nicht berücksichtigen, sondern bei fehlender Angabe eines Zeichensatzes grundsätzlich UTF-8 verwenden. Zu diesen Methoden gehören z. B. Files.writeString(), Files.readString(), Files.newBufferedWriter() und Files.newBufferedReader().

Starten wir einmal das folgende Programm, das den japanischen Text per FileWriter schreibt und direkt danach per Files.readString() wieder einliest:

try (FileWriter fw = new FileWriter("happy-coding.txt");
    BufferedWriter bw = new BufferedWriter(fw)) {
  bw.write("ハッピーコーディング!");
}

String text = Files.readString(Path.of("happy-coding.txt"));
System.out.println(text);Code-Sprache: Java (java)

Unter Linux/macOS wird der korrekte japanische Text angezeigt. Unter Windows hingegen nur Fragezeichen:

???????????Code-Sprache: Klartext (plaintext)

Das liegt daran, dass der FileWriter unter Windows die Datei mit dem Java-Standardzeichensatz Windows-1252 schreibt, Files.readString() die Datei aber als UTF-8 – unabhängig vom Standardzeichensatz – wieder einliest.

Bisherige Lösungsmöglichkeiten

Um eine Anwendung gegen solche Fehler zu wappnen, gab es bisher zwei Möglichkeiten:

  1. Angabe des Zeichensatzes beim Aufruf aller Methoden, welche Zeichenfolgen in Bytes umwandeln und umgekehrt.
  2. Setzen des Standardzeichensatzes per System Property "file.encoding".

Die erste Möglichkeit führt zu einer Menge Code Duplication und ist somit unschön und fehleranfällig:

FileWriter fw = new FileWriter("happy-coding.txt", StandardCharsets.UTF_8);
// ...
FileReader fr = new FileReader("happy-coding.txt", StandardCharsets.UTF_8);
// ...
Files.readString(Path.of("happy-coding.txt"), StandardCharsets.UTF_8);Code-Sprache: Java (java)

Die Angabe der Zeichensatz-Parameter verhindert außerdem, dass wir Methodenreferenzen verwenden können, wie in folgendem Beispiel:

Stream<String> encodedParams = ...
Stream<String> decodedParams = encodedParams.map(URLDecoder::decode);
Code-Sprache: Java (java)

Stattdessen müssten wir schreiben:

Stream<String> encodedParams = ...
Stream<String> decodedParams =
    encodedParams.map(s -> URLDecoder.decode(s, StandardCharsets.UTF_8));
Code-Sprache: Java (java)

Die zweite Möglichkeit (System Property "file.encoding") war zum einen bis einschließlich Java 17 nicht offiziell dokumentiert (s. Dokumentation der System Properties).

Zum anderen wird, wie oben erläutert, der so festgelegte Zeichensatz nicht für alle API-Methoden verwendet. Die Variante ist also ebenfalls fehleranfällig, wie wir an dem Beispiel von oben zeigen können:

public class Jep400Example {
  public static void main(String[] args) throws IOException {
    try (FileWriter fw = new FileWriter("happy-coding.txt");
        BufferedWriter bw = new BufferedWriter(fw)) {
      bw.write("ハッピーコーディング!");
    }

    String text = Files.readString(Path.of("happy-coding.txt"));
    System.out.println(text);
  }
}Code-Sprache: Java (java)

Lassen wir das Programm einmal mit Standard-Encoding US-ASCII laufen:

$ java -Dfile.encoding=US-ASCII Jep400Example.java
?????????????????????????????????Code-Sprache: Klartext (plaintext)

Dabei kommt nur Müll heraus, da zwar FileWriter das eingestellte Standard-Encoding berücksichtigt, Files.readString() es allerdings ignoriert und immer UTF-8 verwendet. Diese Variante funktioniert also nur dann zuverlässig, wenn einheitlich UTF-8 eingesetzt wird:

$ java -Dfile.encoding=UTF-8 Jep400Example.java
ハッピーコーディング!Code-Sprache: Klartext (plaintext)

JEP 400 als Retter in der Not

Per JDK Enhancement Proposal 400 werden ab Java 18 die genannten Probleme – zumindest größtenteils – der Vergangenheit angehören.

Das Standard-Encoding wird auf allen Betriebssystemen und unabhängig von den Gebiets- und Spracheinstellungen immer UTF-8 sein.

Außerdem wird die System Property "file.encoding" dokumentiert sein, womit wir sie legitim einsetzen können. Das sollten wir allerdings mit Bedacht tun. Denn an der Tatsache, dass die Files-Methoden das eingestellte Standard-Encoding ignorieren, wird sich auch durch JEP 400 nichts ändern.

Laut Dokumentation sollten ohnehin nur die Werte "UTF-8" und "COMPAT" verwendet werden, wobei UTF-8 für einheitliche Codierung sorgt und COMPAT das Verhalten von vor Java 18 simuliert. Alle anderen Werte führen zu unspezifiziertem Verhalten.

Gut möglich, dass "file.encoding" in Zukunft "deprecated" und später komplett entfernt wird, um die verbleibende potentielle Fehlerquelle (Methoden die das Standard-Encoding respektieren vs. solche, die es nicht tun) zu eliminieren.

Im besten Fall setzen wir "-Dfile.encoding" immer auf UTF-8 oder lassen es komplett weg.

Auslesen der Encodings zur Laufzeit

Das aktuelle Standard-Encoding kann zur Laufzeit über Charset.defaultCharset() oder die System Property "file.encoding" ausgelesen werden. Seit Java 17 gibt es außerdem die System Property "native.encoding", über die das Encoding ausgelesen werden kann, das – vor Java 18 – das Standard-Encoding wäre, wenn keines angegeben wird:

System.out.println("Default charset : " + Charset.defaultCharset());
System.out.println("file.encoding   : " + System.getProperty("file.encoding"));
System.out.println("native.encoding : " + System.getProperty("native.encoding"));Code-Sprache: Java (java)

Ohne Angabe von -Dfile.encoding gibt das Programm unter Linux und macOS unter Java 17 und Java 18 das Folgende aus:

Default charset : UTF-8
file.encoding   : UTF-8
native.encoding : UTF-8Code-Sprache: Klartext (plaintext)

Unter Windows und Java 17 lautet die Ausgabe wie folgt:

Default charset : windows-1252
file.encoding   : Cp1252
native.encoding : Cp1252Code-Sprache: Klartext (plaintext)

Und unter Windows und Java 18:

Default charset : UTF-8
file.encoding   : UTF-8
native.encoding : Cp1252Code-Sprache: Klartext (plaintext)

Das native Encoding unter Windows bleibt also gleich, das Standard-Encoding hingegen ändert sich entsprechend dieses JEPs auf UTF-8.

Der bisherige "default"-Zeichensatz

Wenn wir das kleine Programm von oben unter Linux oder macOS und Java 17 mit dem Parameter -Dfile.encoding=default aufrufen, bekommen wir die folgende Ausgabe:

Default charset : US-ASCII
file.encoding   : default
native.encoding : UTF-8Code-Sprache: Klartext (plaintext)

Das liegt daran, dass der Name "default" bisher als Alias für das Encoding "US-ASCII" verwendet werden durfte.

Unter Java 18 wird das geändert: "default" wird nicht mehr erkannt; die Ausgabe sieht wie folgt aus:

Default charset : UTF-8
file.encoding   : default
native.encoding : UTF-8Code-Sprache: Klartext (plaintext)

Als System Property "file.encoding" wird "default" zwar noch ausgegeben – an der Stelle würden wir aber auch jede andere ungültige Eingabe sehen. Der Default-Zeichensatz ist bei einer ungültigen "file.encoding"-Eingabe immer UTF-8 ab Java 18 bzw. entspricht dem nativen Encoding bis Java 17.

Charset.forName() Taking Fallback Default Value

Nicht Teil des o. g. JEPs und auch in keinem anderen JEP definiert ist die neue Methode Charset.forName(String charsetName, Charset fallback). Diese gibt bei unbekanntem Zeichensatz-Namen oder nicht unterstütztem Zeichensatz den angegebenen Fallback-Wert zurück anstatt eine IllegalCharsetNameException oder eine UnsupportedCharsetException zu werfen.

Simple Web Server

Fast alle modernen Programmiersprachen bieten die Möglichkeit, einen rudimentären HTTP-Server hochzufahren, z. B. um schnell einige Webfunktionen zu testen.

Durch JDK Enhancement Proposal 408 bietet auch Java ab Version 18 diese Möglichkeit.

Der einfachste Weg den mitgelieferten Webserver zu starten ist das Kommando jwebserver. Es startet den Server auf localhost:8000 und liefert dort einen Datei-Browser für das aktuelle Verzeichnis:

$ jwebserver
Binding to loopback by default. For all interfaces use "-b 0.0.0.0" or "-b ::".
Serving /home/sven and subdirectories on 127.0.0.1 port 8000
URL http://127.0.0.1:8000/Code-Sprache: Klartext (plaintext)

Wie angezeigt, kannst du mit dem Parameter -b die IP-Adresse angeben, auf der der Webserver hören soll. Mit -p kannst du den Port ändern und mit -d das Verzeichnis, das der Server ausliefern soll. Mit -o kannst du die Logausgaben konfigurieren. Also z. B.:

$ jwebserver -b 127.0.0.100 -p 4444 -d /tmp -o verbose
Serving /tmp and subdirectories on 127.0.0.100 port 4444
URL http://127.0.0.100:4444/Code-Sprache: Klartext (plaintext)

Eine Liste der Optionen mit Erklärungen bekommst du mit jwebserver -h angezeigt.

Funktionsumfang

Der Webserver ist sehr rudimentär und hat folgende Einschränkungen:

  • Das einzig unterstützte Protokoll ist HTTP/1.1.
  • HTTPS wird nicht angeboten.
  • Erlaubt sind nur die HTTP-Methoden GET und HEAD.

Java-API: SimpleFileServer

jwebserver ist kein eigenständiges Tool, sondern lediglich ein Wrapper, der folgendes aufruft:

java -m jdk.httpserver

Dadurch wird die main-Methode der Klasse sun.net.httpserver.simpleserver.Main des Moduls jdk.httpserver aufgerufen. Diese ruft SimpleFileServerImpl.start(…) auf. Dieser Starter wiederum wertet die Kommandozeilen-Parameter aus und erzeugt schließlich den eigentlichen Server mit SimpleFileServer.createFileServer(…).

Mit dieser Methode kannst du auch per Java-Code einen Server starten:

HttpServer server =
    SimpleFileServer.createFileServer(
        new InetSocketAddress(8080), Path.of("tmp"), OutputLevel.INFO);
server.start();Code-Sprache: Java (java)

Über die Java-API kannst du den Webserver erweitern, z. B. kannst du bestimmte Verzeichnisse des Dateisystems über andere HTTP-Pfade erreichbar machen und den Server um eigene Handler für bestimmte Pfade und HTTP-Methoden (z. B. PUT) erweitern.

Ein vollständiges Tutorial würde den Rahmen dieses Artikels sprengen. Weitere Details findest du in den Abschnitten "API" und "Enhanced request handling" im JEP.

Code Snippets in Java API Documentation

Wenn wir bisher mehrzeilige Code-Schnipsel in JavaDoc integrieren wollten, mussten wir das recht umständlich über <pre>…</pre> ggf. in Kombination mit {@code … } machen. Dabei mussten wir auf zwei Dinge achten:

  1. Zwischen <pre> und Code sowie zwischen Code und </pre> dürfen keine Zeilenumbrüche erfolgen.
  2. Der Code beginnt direkt hinter den Sternchen; d. h. wenn zwischen Sternchen und Code Leerzeichen stehen, landen diese auch im JavaDoc. Der Code muss also um ein Zeichen nach links verschoben sein gegenüber dem restlichen Text im JavaDoc-Kommentar.

Hier ein Beispiel mit <pre>:

/**
 * How to write a text file with Java 7:
 *
 * <pre><b>try</b> (BufferedWriter writer = Files.<i>newBufferedWriter</i>(path)) {
 *  writer.write(text);
 *}</pre>
 */Code-Sprache: Java (java)

Und eines mit <pre> und {@code … }:

/**
 * How to write a text file with Java 7:
 *
 * <pre>{@code try (BufferedWriter writer = Files.newBufferedWriter(path)) {
 *  writer.write(text);
 *}}</pre>
 */Code-Sprache: Java (java)

Der Unterschied zwischen beiden Varianten ist, dass wir in der ersten Variante den Code mit HTML-Tags wie z. B. <b> und <i> formatieren können, während in der zweiten Variante solche Tags nicht ausgewertet, sondern dargestellt werden würden.

Das @snippet-Tag in Java 18

JDK Enhancement Proposal 413 erweitert die JavaDoc-Syntax um das @snippet-Tag, das speziell für die Darstellung von Quellcode entwickelt wurde. Mit dem @snippet-Tag können wir den Kommentar wie folgt schreiben:

/**
 * How to write a text file with Java 7:
 *
 * {@snippet :
 * try (BufferedWriter writer = Files.newBufferedWriter(path)) {
 *   writer.write(text);
 * }
 * }
 */Code-Sprache: Java (java)

Wir können außerdem Teile des Codes mit @highlight hervorheben, z. B. alle Vorkomnisse von "text" innerhalb der zweiten Codezeile:

/**
 * {@snippet :
 * try (BufferedWriter writer = Files.newBufferedWriter(path)) {
 *   writer.write(text);  // @highlight substring="text"
 * }
 * }
 */Code-Sprache: Java (java)

Das folgende Beispiel markiert innerhalb des mit @highlight region und @end gekennzeichneten Blocks alle Wörter, die mit "write" beginnen. Mit type="…" können wir außerdem die Art der Hervorhebung festlegen: bold, italic or highlighted (farblich hinterlegt).

/**
 * {@snippet :
 * // @highlight region regex="bwrite.*?b" type="highlighted"
 * try (BufferedWriter writer = Files.newBufferedWriter(path)) {
 *   writer.write(text);                                          
 * }
 * // @end
 * }
 */Code-Sprache: Java (java)

Mit @link können wir einen Teil des Texts verlinken, z. B. BufferedWriter mit dessen JavaDoc:

/**
 * {@snippet :
 * // @link substring="BufferedWriter" target="java.io.BufferedWriter" :
 * try (BufferedWriter writer = Files.newBufferedWriter(path)) {
 *   writer.write(text);
 * }
 * }
 */Code-Sprache: Java (java)

Achtung: der Doppelpunkt am Ende der Zeile mit dem @link-Tag ist in diesem Fall wichtig. Er bedeutet, dass der Kommentar sich auf die nächste Zeile bezieht. Wir könnten den Kommentar genauso an das Ende der folgenden Zeile schreiben, so wie im ersten @highlight-Beispiel – oder mit @link region und @end einen Bereich festlegen, innerhalb dessen alle Vorkommnisse von BufferedWriter verlinkt werden sollen.

Snippets aus anderen Dateien integrieren

Laut JEP soll es auch möglich sein, auf markierten Code in einer anderen Datei zu verweisen:

/**
 * How to write a text file with Java 7:
 *
 * {@snippet file="FileWriter.java" region="writeFile"}
 */Code-Sprache: Java (java)

In der Datei FileWriter.java würden wir den Code wie folgt markieren:

// @start region="writeFile"
try (BufferedWriter writer = Files.newBufferedWriter(path)) {
  writer.write(text);
}
// @endCode-Sprache: Java (java)

Diese Variante führt allerdings mit dem aktuellen Early-Access-Release (Build 18-ea+29-2007) zu einer "File not found"-Fehlermeldung beim Aufruf des javadoc-Kommandos. Dieser JEP ist offenbar zum aktuellen Zeitpunkt noch nicht vollständig implementiert.

Das waren meiner Meinung nach die wichtigsten @snippet-Tags. Eine vollständige Referenz findest du im JEP.

Internet-Address Resolution SPI

Um in Java zu einem Hostname die IP-Adresse(n) herauszufinden, können wir InetAddress.getByName(…) oder InetAddress.getAllByName(…) verwenden. Hier ein Beispiel:

InetAddress[] addresses = InetAddress.getAllByName("www.happycoders.eu");
System.out.println("addresses = " + Arrays.toString(addresses));
Code-Sprache: Java (java)

Der Code gibt bei mir das Folgende aus (die Zeilenumbrüche habe ich der Übersichtlichkeit halber manuell eingefügt):

addresses = [www.happycoders.eu/104.26.15.71,
             www.happycoders.eu/172.67.71.232, 
             www.happycoders.eu/104.26.14.71]
Code-Sprache: Klartext (plaintext)

Für Reverse Lookups (also das Auflösen einer IP-Adresse zu einem Hostnamen) gibt es die Methoden InetAddress::getCanonicalHostName und InetAddress::getHostName.

Standardmäßig verwendet InetAddress den Resolver des Betriebssystems, d. h. in der Regel wird zunächst die hosts-Datei konsultiert und danach die konfigurierten DNS-Server.

Diese feste Verdrahtung hat ein paar Nachteile:

  • Es ist nicht möglich innerhalb von Tests einen Hostnamen auf die URL eines gemockten Servers zu mappen.
  • Neue Hostname-Lookup-Protokolle (wie DNS über QUIC, TLS oder HTTPS) können nicht ohne weiteres in Java implementiert werden.
  • Die aktuelle Implementierung führt zu einem blockierenden Betriebssystem-Aufruf. Das allein ist schon unschön, da dieser Aufruf teilweise lange dauern kann und nicht unterbrechbar ist. Beim Einsatz von virtuellen Threads führt dies sogar soweit, dass der Betriebssystem-Thread währenddessen keine anderen virtuellen Threads bedienen kann.

Durch JDK Enhancement Proposal 418 wird ein Service Provider Interface (SPI) eingeführt, um den eingebauten Standard-Resolver der Plattform durch andere Resolver ersetzen zu können.

Internet-Address Resolution SPI / JEP 418 – Beispiel

Das folgende Beispiel zeigt, wie du einen einfachen Resolver implementierst und registriert, der jede Anfrage mit der IP-Adresse 127.0.0.1 beantwortet. Du findest den Code auch in diesem GitHub-Repository.

Wir schreiben zunächst den Resolver, indem wir das in Java 18 eingeführte Interface java.net.spi.InetAddressResolver.InetAddressResolver implementieren (Klasse HappyCodersInetAddressResolver in GitHub):

public class HappyCodersInetAddressResolver implements InetAddressResolver {
  @Override
  public Stream<InetAddress> lookupByName(String host, LookupPolicy lookupPolicy)
      throws UnknownHostException {
    return Stream.of(InetAddress.getByAddress(new byte[] {127, 0, 0, 1}));
  }

  @Override
  public String lookupByAddress(byte[] addr) {
    throw new UnsupportedOperationException();
  }
}Code-Sprache: Java (java)

Da ich hier nur das grundsätzliche Prinzip vorstellen möchte, habe ich den Resolver so einfach wie möglich gehalten, und er unterstützt auch keine Reverse Lookups.

Als zweites benötigen wir einen Resolver-Provider (Klasse HappyCodersInetAddressResolverProvider in GitHub):

public class HappyCodersInetAddressResolverProvider extends InetAddressResolverProvider {
  @Override
  public InetAddressResolver get(Configuration configuration) {
    return new HappyCodersInetAddressResolver();
  }

  @Override
  public String name() {
    return "HappyCoders Internet Address Resolver Provider";
  }
}
Code-Sprache: Java (java)

Der Provider erstellt in der get()-Methode eine neue Instanz des zuvor implementierten Resolvers.

Im dritten Schritt müssen wir den Resolver registrieren. Dazu legen wir im Verzeichnis META-INF/services eine Datei mit dem Namen java.net.spi.InetAddressResolverProvider und mit folgendem Inhalt an (Datei in GitHub):

eu.happycoders.jep416.HappyCodersInetAddressResolverProviderCode-Sprache: Klartext (plaintext)

Jetzt führen wir erneut den Code von oben aus (Klasse Jep418Demo in GitHub):

InetAddress[] addresses = InetAddress.getAllByName("www.happycoders.eu");
System.out.println("addresses = " + Arrays.toString(addresses));Code-Sprache: Java (java)

Die Ausgabe lautet nun:

addresses = [/127.0.0.1]Code-Sprache: Klartext (plaintext)

Das ist genau die IP-Adresse, die wir in unserem Resolver zurückgegeben haben.

Preview- und Incubator-Features

In den folgenden Abschnitten findest du Preview- und Incubator-Features, die wir bereits aus den vorangegangenen Releases kennen. Es handelt sich um Wiedervorlagen mit kleinen Änderungen.

Pattern Matching for switch (Second Preview)

"Pattern Matching for switch" wurde in Java 17 erstmals vorgestellt und ermöglicht switch-Statements (und -Ausdrücke) wie z. B. das folgende (mehr dazu findest du im verlinkten Java-17-Artikel):

switch (obj) {
  case String s && s.length() > 5 -> System.out.println(s.toUpperCase());
  case String s                   -> System.out.println(s.toLowerCase());

  case Integer i                  -> System.out.println(i * i);

  default -> {}
}Code-Sprache: Java (java)

Durch JDK Enhancement Proposal 420 wurden in Java 18 zwei Änderungen vorgenommen – eine in der Dominanzprüfung und eine in der Vollständigkeitsanalyse bei der Kombination mit versiegelten Typen.

Verbesserung der Dominanzprüfung

Was Dominanzprüfung ist, habe ich im oben verlinkten Artikel zu Java 17 beschrieben. In Kürze: der folgende Code führt zu einem Compilerfehler:

Object obj = ...
switch (obj) {
  case String s                   -> System.out.println(s.toLowerCase());
  case String s && s.length() > 5 -> System.out.println(s.toUpperCase());
  ...
}
Code-Sprache: Java (java)

Der Grund dafür ist, dass das Pattern in Zeile 3 das längere Pattern aus Zeile 4 "dominiert": Wenn obj ein String ist, wird es durch das Pattern in Zeile 3 gematcht, egal wie lang der String ist. Es gibt also kein Objekt, das durch das Pattern in Zeile 4 gematcht wird.

Ein Fall wurde jedoch bisher nicht bedacht – und zwar die Kombination aus Konstante und Guarded Pattern (ein Pattern mit &&). So ist der folgende Code unter Java 17 noch erlaubt:

String string = ...
switch (string) {
  case String s && s.length() > 5 -> System.out.println(s.toUpperCase());
  case "foobar"                   -> System.out.println("baz");
  ...
}
Code-Sprache: Java (java)

Wenn obj gleich "foobar" ist, wird es allerdings nicht durch Zeile 4 gematcht, sondern bereits durch Zeile 3 (denn es ist ja auch länger als 5 Zeichen).

Da nicht erreichbarer Code offensichtlich nicht beabsichtigt ist, bekommen wir in Java 18 den folgenden Compiler-Fehler:

java --enable-preview --source 18 SwitchTest.java
SwitchTest.java:9: error: this case label is dominated by a preceding case label
      case "foobar"                   -> System.out.println("baz");
           ^Code-Sprache: Klartext (plaintext)

Bugfix in der Vollständigkeitsanalyse mit Sealed Types

Was Vollständigkeitsanalyse ist, erfährst du im Artikel über versiegelte Typen.

Die Änderung in Java 18 erkläre ich an folgender versiegelter Beispiel-Klassenhierarchie aus dem JEP:

sealed interface I<T> permits A, B {}
final class A<X> implements I<String> {}
final class B<Y> implements I<Y> {}Code-Sprache: Java (java)

Der folgende Code ist nicht compilierbar:

I<Integer> i = ...
switch (i) {
  case A<Integer> a -> System.out.println("It's an A");  // not compilable
  case B<Integer> b -> System.out.println("It's a B");
}Code-Sprache: Java (java)

Sowohl Java 17 als auch Java 18 erkennen, dass I<Integer> nicht nach A<Integer> konvertiert werden kann (da A<Integer> ein I<String> ist) und melden:

incompatible types: I<Integer> cannot be converted to A<Integer>

Tatsächlich ist aufgrund der versiegelten Klassenhierarchie B<Integer> die einzige Klasse, die I<Integer> implementieren kann. Das switch-Statement ist also wie folgt vollständig:

I<Integer> i = ...
switch (i) {
  case B<Integer> b -> System.out.println("It's a B");
}Code-Sprache: Java (java)

Java 17 meldet hier allerdings:

the switch statement does not cover all possible input values

Das ist ein offensichtlicher Bug, der in Java 18 behoben wurde.

Vector API (Third Incubator)

Die Vector API wurde bereits in Java 16 und Java 17 als Incubator-Feature vorgestellt. Dabei geht es nicht um die java.util.Vector-Klasse aus Java 1.0, sondern um Vektorrechnung im mathematischen Sinn und deren Abbildung auf moderne Single-Instruction-Multiple-Data-(SIMD)-Architekturen.

Durch JDK Enhancement Proposal 417 wurde erneut die Leistung verbessert und der Support auf "ARM Scalable Vector Extension" – eine optionale Erweiterung der ARM64-Plattform – erweitert.

Inkubator-Stadium bedeutet, dass das Feature noch erhebliche Änderungen durchlaufen kann. Ich werde die Vector API ausführlicher vorstellen, sobald sie den Preview-Status erreicht hat.

Foreign Function & Memory API (Second Incubator)

Die Foreign Function & Memory API entstand in Java 17 durch Kombination der "Foreign Memory Access API" und der "Foreign Linker API", die beide zuvor – jeweils einzeln – durch mehrere Incubator-Phasen liefen.

Die neue API wird in Project Panama entwickelt und ist als Ersatz für JNI (Java Native Interface) vorgesehen, welches bereits seit Java 1.1 Teil der Plattform ist. JNI ermöglicht den Aufruf von C-Code aus Java heraus. Wer einmal mit JNI gearbeitet hat, weiß: JNI ist äußerst kompliziert zu implementieren, fehleranfällig und langsam.

Ziel der neuen API ist es den Implementierungsaufwand um 90 % zu reduzieren und die Leistung der API um Faktor 4 bis 5 zu beschleunigen.

Durch JDK Enhancement Proposal 419 wurden weitgehende Änderungen an der API vorgenommen. Im nächsten Release, Java 19, wird die API das Preview-Stadium erreichen.

Deprecations und Löschungen

Auch in Java 18 wurden wieder einige Features als "deprecated for removal" markiert oder komplett entfernt.

Deprecate Finalization for Removal

Finalization existiert seit Java 1.0 und soll helfen Resource-Leaks zu vermeiden, indem Klassen eine finalize()-Methode implementieren können, in der sie vom Betriebssystem angeforderte Resourcen (wie Datei-Handles oder Nicht-Heap-Speicher) wieder freigeben können.

Die finalize()-Methode wird vom Garbage Collector aufgerufen, bevor er den Speicherplatz eines Objekts freigibt.

Das scheint eine sinnvolle Lösung zu sein. Es hat sich allerdings gezeigt, dass Finalization einige grundlegende, kritische Mängel hat:

Performance:

  • Da nicht vorhersehbar ist, wann der Garbage Collector ein Objekt aufräumt (und ob er das überhaupt tut), kann es sein, dass – nachdem ein Objekt nicht mehr referenziert wird – es sehr lange dauert, bis dessen finalize()-Methode aufgerufen wird (oder dass sie nie aufgerufen wird).
  • Wenn der Garbage Collector eine Full GC durchführt, kann es zu spürbaren Latenzen kommen, wenn viele der aufzuräumenden Objekte finalize()-Methoden haben.
  • Die finalize()-Methode wird für jede Instanz einer Klasse aufgerufen, selbst wenn es eigentlich nicht nötig wäre. Es gibt keine Möglichkeit in einzelnen Objekten festzulegen, dass diese keine Finalization benötigen.

Sicherheitsrisiken:

  • Die finalize()-Methode kann beliebigen Code ausführen, z. B. auch eine Referenz des zu löschenden Objekts speichern. Somit wird der Garbage Collector es nicht aufräumen. Wird die Referenz auf das Objekt später wieder entfernt und das Objekt durch den Garbage Collector gelöscht, wird die finalize()-Methode nicht erneut aufgerufen.
  • Wenn der Konstruktor einer Klasse eine Exception wirft, liegt das Objekt zunächst trotzdem auf dem Heap. Wenn der Garbage Collector es entfernt, ruft er dessen finalize()-Methode auf. Diese kann dann Operationen auf einem ggf. unvollständig initialisierten Objekt ausführen oder dieses im Objektgraphen speichern.

Fehleranfälligkeit:

  • Eine finalize()-Methode sollte immer auch die finalize()-Methode der Elternklasse aufrufen. Der Compiler fordert dies allerdings nicht ein (im Gegensatz zum Konstruktor). Selbst wenn wir unseren eigenen Code fehlerfrei schreiben, könnte jemand anderes unsere Klasse erweitern, die finalize()-Methode überschreiben ohne die überschriebene Methode aufzurufen und damit ein Resource Leak verursachen.

Multithreading:

  • Die finalize()-Methode wird in einem unspezifizierten Thread aufgerufen, so dass die Thread-Sicherheit des gesamten Objekts sichergestellt werden muss – selbst in einer Anwendung, die eigentlich kein Multithreading nutzt.

Alternativen zur Finalization

Es existieren folgende Alternativen zur Finalization:

  • Das in Java 7 eingeführte "try-with-resources" generiert für alle Klassen, die das AutoCloseable-Interface implementieren, automatisch einen finally-Block, in dem die entsprechenden close()-Methoden aufgerufen werden. Gängige Tools für statische Codeanalyse finden und bemängeln Code, der AutoCloseable-Objekte nicht innerhalb von "try-with-resources"-Blöcken erzeugt.
  • Durch die in Java 9 eingeführte Cleaner-API können sogenannte "Cleaner Actions" registriert werden. Diese werden aufgerufen, wenn ein Objekt nicht mehr erreichbar ist (nicht erst dann, wenn es aufgeräumt wird). Cleaner Actions haben keinen Zugriff auf das Objekt selbst (können also auch keine Referenz darauf speichern); wir brauchen sie für ein Objekt nur dann zu registrieren, wenn dieses spezifische Objekt sie benötigt; und wir können selbst bestimmen, in welchem Thread sie aufgerufen werden.

Aus den o. g. Gründen und aufgrund der Verfügbarkeit von hinreichenden Alternativen wurden die finalize()-Methoden in Object und zahlreichen weiteren Klassen der JDK-Klassenbibliothek bereits in Java 9 als "deprecated" markiert.

Durch JDK Enhancement Proposal 421 werden die Methoden in Java 18 als "deprecated for removal" markiert.

Weiterhin wird die VM-Option --finalization=disabled eingeführt, welche die Finalization komplett deaktiviert. Damit können wir Applikationen testen, bevor wir sie auf eine zukünftige Java-Version aktualisieren, in der Finalization vollständig entfernt wurde.

JDK Flight Recorder Event for Finalization

Nicht Teil des o. g. JEPs ist das neue Flight Recorder Event "jdk.FinalizerStatistics". Dieses ist standardmäßig aktiviert und protokolliert jede instantiierte Klasse mit einer nicht leeren finalize()-Methode. So kann man leicht diejenigen Klassen identifizieren, die noch einen Finalizer verwenden.

Wenn Finalization per --finalization=disabled deaktiviert ist, werden diese Events nicht getriggert.

Terminally Deprecate Thread.stop

Thread.stop() wird in Java 18 als "deprecated for removal" markiert – endlich, nachdem es bereits seit Java 1.2 "deprecated" ist. In einer der nächsten Versionen wird es dann hoffentlich – zusammen mit suspend() und resume() sowie den korrespondierenden Methoden in ThreadGroup – komplett entfernt.

(Zu dieser Änderung gibt es keinen JDK Enhancement Proposal.)

Remove the Legacy PlainSocketImpl and PlainDatagramSocketImpl Implementation

In Java 13 wurden die Socket API und in Java 15 die DatagramSocket API neu implementiert.

Die alten Implementierungen konnten seither über die System Properties jdk.net.usePlainSocketImpl bzw. jdk.net.usePlainDatagramSocketImpl reaktiviert werden.

In Java 18 wurde der alte Code entfernt und die o. g. System Properties entfernt.

(Zu dieser Änderung gibt es keinen JDK Enhancement Proposal.)

Sonstige Änderungen in Java 18

In diesem Abschnitt findest du diejenigen Änderungen, mit denen du in der täglichen Programmierarbeit eher selten in Berührung kommen wirst. Es schadet dennoch sicher nicht, sie einmal zu überfliegen.

Reimplement Core Reflection with Method Handles

Wenn du viel mit Java-Reflection zu tun hast, wirst du wissen, dass sprichtwörtlich mehrere Wege nach Rom führen. Um beispielsweise das private value-Feld eines Strings per Reflection auszulesen, gibt es zwei Möglichkeiten:

1. Per sogenannter "Core Reflection":

Field field = String.class.getDeclaredField("value");
field.setAccessible(true);
byte[] value = (byte[]) field.get(string);
Code-Sprache: Java (java)

2. Über "Method Handles":

VarHandle handle =
    MethodHandles.privateLookupIn(String.class, MethodHandles.lookup())
        .findVarHandle(String.class, "value", byte[].class);
byte[] value = (byte[]) handle.get(string);
Code-Sprache: Java (java)

(Wichtig: Seit Java 16 musst du bei beiden Varianten das Paket java.lang aus dem Modul java.base für das aufrufende Modul öffnen, z. B. per VM-Option --add-opens java.base/java.lang=ALL-UNNAMED.)

Es gibt noch eine dritte Form, die wir nicht direkt sehen können: Core Reflection verwendet für die ersten paar Aufrufe nach dem Start der JVM zusätzliche native JVM-Methoden und beginnt erst nach einer Weile damit den Java-Bytecode der Reflection-Aufrufe zu compilieren und zu optimieren.

Alle drei Varianten zu maintainen bedeutet einen erheblichen Aufwand für die JDK-Entwickler. Daher wurde im Rahmen von JDK Enhancement Proposal 416 entschieden den Code der Reflection-Klassen java.lang.reflect.Method, Field und Constructor auf Basis von Method Handles neu zu implementieren und somit den Entwicklungsaufwand zu reduzieren.

ZGC / SerialGC / ParallelGC Support String Deduplication

Seit Java 18 unterstützen auch der in Java 15 als produktionsreif veröffentlichte Z Garbage Collector sowie der serielle und der parallele Garbage Collector String Deduplication.

String Deduplication bedeutet, dass der Garbage Collector Strings erkennt, deren value- und coder-Felder die gleichen Bytes enthalten. Der GC löscht diese Byte-Arrays bis auf eines und lässt alle String-Instanzen dieses eine Byte-Array referenzieren.

Das heißt, es werden eigentlich nicht die Strings dedupliziert (wie der Name des Features impliziert), sondern nur deren Byte-Arrays. An den Identitäten der String-Objekte selbst ändert sich nichts.

String Deduplication ist standardmäßig deaktiviert (da es einen potentiellen Angriffsvektor per Deep Reflection darstellt) und muss per VM-Option -XX:+UseStringDeduplication explizit aktiviert werden.

(String Deduplication wurde erstmals mit dem JDK Enhancement Proposal 192 in Java 8u20 für den G1 veröffentlicht. Für den Einbau in den ZGC, den seriellen und den parallelen GC in Java 18 gibt es keinen separaten JEP.)

Allow G1 Heap Regions up to 512MB

Der G1 Garbage Collector bestimmt die Größe der Heap-Regionen in der Regel automatisch. Je nach Heap-Größe wird die Größe der Regionen auf einen Wert zwischen 1 MB und 32 MB festgelegt.

Per VM-Option -XX:G1HeapRegionSize kann die Größe der Regionen auch manuell gesetzt werden. Auch hierbei waren bisher Größen zwischen 1 MB und 32 MB erlaubt.

In Java 18 wird die maximale Größe der Regionen auf 512 MB erhöht. Dies soll insbesondere die Heap-Fragmentierung bei sehr großen Objekten reduzieren.

Die Änderung bezieht sich nur auf das manuelle Festlegen der Regionengröße. Bei der automatischen Bestimmung durch die JVM (also ohne Angabe der VM-Option) bleibt die maximale Größe bei 32 GB.

(Zu dieser Änderung gibt es keinen JDK Enhancement Proposal.)

Vollständige Liste aller Änderungen in Java 18

Neben den in diesem Artikel präsentierten JDK Enhancement Proposals und Änderungen an den Klassenbibliotheken gibt es noch weitere Änderungen (z. B. an den Kryptographie-Modulen), die den Rahmen dieses Artikels sprengen würden. Eine vollständige Liste findest du in den JDK 18 Release Notes.

Fazit

Mit Java 18 beginnt der nächste Zyklus von Non-LTS-Releases – bis voraussichtlich im September 2023 mit Java 21 die nächste LTS-Version veröffentlicht wird. Wer mitgerechnet hat, stellt fest: Zwischen Java 11 und Java 17 lagen fünf Non-LTS-Releases und drei Jahre – zwischen Java 17 und 21 werden nur drei Non-LTS-Releases und zwei Jahre liegen. Oracle hat nämlich kurz vor dem Release von Java 17 angekündigt den Release-Zyklus zu verkürzen.

Die Releases werden deshalb nicht voller gepackt werden als bisher. Tatsächlich ist Java 18 recht übersichtlich und enthält seit langem keine Änderung an der Sprache selbst (nach zahlreichen Spracherweiterungen wie Records und Sealed Classes).

Die wichtigsten Änderungen von Java 18 sind:

  • UTF-8 wird in Zukunft auf allen Betriebssystemen und unabhängig von Sprach- und Gebietseinstellungen der Standard-Zeichensatz sein.
  • Per jwebserver-Kommando (oder mit der SimpleFileServer-Klasse) können wir schnell einen rudimentären Webserver starten.
  • Mit dem @snippet-Tag bekommen wir ein mächtiges Werkzeug, um Quellcode-Schnipsel in unsere JavaDoc-Dokumentation zu integrieren.
  • Mit dem "Internet-Address Resolution SPI" können wir den Standard-Resolver für IP-Adressen ersetzen, was insbesondere in Tests hilfreich ist.
  • Die Preview- und Incubator Features "Pattern Matching for switch", "Vector API" und "Foreign Function & Memory API" wurden in die jeweils nächste Preview- bzw. Incubator-Runde geschickt.
  • Finalization und Thread.stop() wurden als "deprecated for removal" markiert.

Diverse sonstige Änderungen runden wie immer das Release ab. Java 18 kannst du hier herunterladen.

Du möchtest keinen HappyCoders.eu-Artikel verpassen und immer über neue Java-Features informiert werden? Dann klicke hier, um dich für den kostenlosen HappyCoders-Newsletter anzumelden.