Zum Inhalt springen

Java 9 Features (mit Beispielen)

Tasse schwarzer Kaffee neben Kaffeemühle, Mokkakanne und Kaffeebohnen auf Holztisch – Java 9 Features

Java 9 wurde am 21. September 2017 veröffentlicht – dreieinhalb Jahre nach Java 8. Es war das letzte Release nach altem Muster: ein Bündel von 91 JDK Enhancement Proposals (JEPs), das erst erschien, als das größte davon fertig war – das Modulsystem. Ein halbes Jahr später begann mit Java 10 der sechsmonatige Release-Zyklus – und die Releases wurden deutlich kleiner.

Java 9 war kein Long-Term-Support-Release. Der Support endete mit dem Erscheinen von Java 10. Trotzdem lohnt sich der Blick zurück: Fast alles, was du heute in Java als selbstverständlich betrachtest – List.of(), jshell, das Modulsystem, die Flow-API, takeWhile(), private Methoden in Interfaces –, kam mit diesem Release.

Ich habe die 91 JEPs nach Relevanz für die tägliche Entwicklungsarbeit sortiert: Zuerst kommen die Änderungen an der Sprache, dann das Modulsystem und die Erweiterungen der Klassenbibliothek, danach die Tools, die JVM, Incubator-Features und Deprecations. Die 49 JEPs, die du als Java-Entwickler:in nicht kennen musst, habe ich am Ende in thematische Gruppen zusammengefasst – jeder JEP ist dort mit einem Satz und einem Link vertreten.

Als Kapitel-Überschriften verwende ich die englischen Bezeichnungen der JEPs. Bei übersetzten Fachbegriffen müsstest du erst überlegen, was gemeint ist – im Original erkennst du sie sofort wieder.

Die Referenzimplementierung von Java 9 kannst du hier herunterladen. Ich empfehle das allerdings nur zum Nachvollziehen der Beispiele – für produktiven Code nimmst du ein aktuelles LTS-Release.

Kleine Sprachänderungen: Milling Project Coin – JEP 213

Java 7 brachte mit „Project Coin“ eine Handvoll kleiner Sprachänderungen: den Diamond-Operator, try-with-resources, Strings in switch. JDK Enhancement Proposal 213 schleift („to mill“) die Kanten dieser Änderungen ab – und erlaubt zusätzlich private Methoden in Interfaces.

Fünf Änderungen sind es insgesamt. Ich zeige sie in der Reihenfolge ihrer Relevanz.

Private Methoden in Interfaces

Seit Java 8 dürfen Interfaces default- und static-Methoden mit Implementierung enthalten. Was fehlte, war eine Möglichkeit, gemeinsamen Code mehrerer Default-Methoden auszulagern, ohne ihn als Teil der öffentlichen API zu veröffentlichen.

Seit Java 9 geht das mit einer privaten Methode:

public interface Greeter {
  default String greet(String name) {
    return prefix() + "Hello " + name + "!";
  }

  default String greetAll(List<String> names) {
    return prefix() + "Hello " + String.join(", ", names) + "!";
  }

  private String prefix() {
    return "[" + getClass().getSimpleName() + "] ";
  }
}

Die Methode prefix() ist von außen nicht sichtbar – auch nicht für implementierende Klassen. Private statische Methoden sind ebenfalls erlaubt.

Effectively final Variablen in try-with-resources

Bisher musste jede Ressource im try-with-resources-Statement in einer neuen Variablen deklariert werden – auch wenn sie längst in einer Variablen steckte:

BufferedReader reader = new BufferedReader(new StringReader("foo\nbar"));

try (BufferedReader r = reader) {
  System.out.println(r.readLine());
}

Seit Java 9 reicht es, wenn die Variable final oder effectively final ist (d. h. nach der Initialisierung nicht mehr verändert wird):

try (reader) {
  System.out.println(reader.readLine());
}

In diesem Beispiel bringt das wenig – die Deklaration von reader hätte genauso gut in den Kopf des try gepasst. Der Gewinn zeigt sich, wenn die Variable von außen kommt, z. B. als Methodenparameter:

void printFirstLine(BufferedReader reader) throws IOException {
  try (reader) {
    System.out.println(reader.readLine());
  }
}

Bisher musstest du den Parameter in eine neue Variable umkopieren, nur um ihn am Ende schließen zu lassen.

Diamond-Operator bei anonymen Klassen

Der Diamond-Operator <> durfte in Java 7 und 8 nicht mit anonymen Klassen kombiniert werden. Java 9 hebt diese Einschränkung auf:

Comparator<String> byLength = new Comparator<>() {
  @Override
  public int compare(String a, String b) {
    return Integer.compare(a.length(), b.length());
  }
};

Vor Java 9 musstest du hier new Comparator<String>() schreiben.

@SafeVarargs auf privaten Methoden

Die Annotation @SafeVarargs unterdrückt die Warnung „unchecked generic array creation“ bei Varargs-Parametern mit generischem Typ. Sie war bisher nur auf Konstruktoren sowie static- und final-Methoden erlaubt – also auf Methoden, die nicht überschrieben werden können.

Private Instanzmethoden können ebenfalls nicht überschrieben werden. Seit Java 9 darf die Annotation deshalb auch dort stehen:

@SafeVarargs
private void printAll(List<String>... lists) {
  System.out.println(Arrays.toString(lists));
}

Unterstrich ist kein Bezeichner mehr

Java 8 warnte bereits davor, einen einzelnen Unterstrich als Variablennamen zu verwenden. Seit Java 9 ist es ein Compiler-Fehler:

Underscore.java:3: error: as of release 9, '_' is a keyword, and may not be used as an identifier
    int _ = 5;
        ^

Die JDK-Entwickler:innen haben damit ein Feature zehn Jahre im Voraus vorbereitet: Java 8 warnte 2014, Java 9 verbot den Bezeichner – und seit Java 22 steht der Unterstrich für unbenannte Variablen und Patterns.

Java Platform Module System – JEP 261

Das Modulsystem ist das Feature, für das Java 9 so lange gebraucht hat. Unter dem Namen Project Jigsaw wurde seit 2008 daran gearbeitet; die Spezifikation trägt die Nummer JSR 376, die Implementierung ist JDK Enhancement Proposal 261. Fünf weitere JEPs gehören dazu – 200, 201, 220, 260 und 282 –, ich stelle sie in den folgenden Unterkapiteln vor.

Das Modulsystem löst drei Probleme, die der Classpath nie lösen konnte:

  • Reliable configuration: Ein Modul deklariert, welche anderen Module es benötigt. Fehlt eines, bricht die JVM beim Start ab – nicht irgendwann zur Laufzeit mit einem NoClassDefFoundError.
  • Strong encapsulation: Ein Modul deklariert, welche Packages es exportiert. Alle anderen Packages sind für Code außerhalb des Moduls unsichtbar – auch wenn die Klassen darin public sind.
  • Keine Split Packages: Zwei Module dürfen nicht dasselbe Package enthalten. Auf dem Classpath konnten zwei JARs dieselbe Klasse mitbringen, und welche davon geladen wurde, entschied die Reihenfolge auf dem Classpath – die berüchtigte JAR Hell. Das Modulsystem lehnt eine solche Konfiguration ab: Enthalten zwei Module eu.happycoders.greeting und eu.happycoders.farewell beide das Package eu.happycoders.shared, meldet der Compiler „module eu.happycoders.app reads package eu.happycoders.shared from both eu.happycoders.greeting and eu.happycoders.farewell“ – und die JVM startet gar nicht erst.

module-info.java: Module definieren

Ein Modul ist ein Verzeichnis (oder ein JAR) mit einer Datei module-info.java im Wurzelverzeichnis. Hier ist ein Modul, das ein Package exportiert:

module eu.happycoders.greeting {
  exports eu.happycoders.greeting.api;
}

Und hier ein zweites Modul, das das erste benötigt:

module eu.happycoders.app {
  requires eu.happycoders.greeting;
}

Das requires nennt den Modulnamen, das exports einen Package-Namen. Die Klasse App im zweiten Modul kann jetzt auf die Klasse Greeting im exportierten Package eu.happycoders.greeting.api zugreifen. Würde das erste Modul das Package nicht exportieren, wäre Greeting für App nicht sichtbar – der Compiler würde den Zugriff mit „package eu.happycoders.greeting.api is not visible“ ablehnen.

Neben requires und exports kennt die Modul-Deklaration drei weitere Schlüsselwörter:

  • requires transitive reicht die Abhängigkeit an alle Module weiter, die das eigene Modul benötigen. Das ist nötig, wenn Typen der Abhängigkeit in der eigenen öffentlichen API auftauchen.
  • opens öffnet ein Package für Deep Reflection (also setAccessible(true)) – ohne es zur Compile-Zeit sichtbar zu machen. Frameworks wie Hibernate oder Jackson brauchen das, um private Felder deiner Klassen ohne Getter und Setter zu lesen und zu schreiben.
  • uses und provides ... with deklarieren Services, die über den ServiceLoader gefunden werden. Das JDK selbst nutzt das ausgiebig; das Modul java.sql deklariert z. B. uses java.sql.Driver. Das heißt: java.sql lädt zur Laufzeit alle Module, die provides java.sql.Driver with ... deklarieren – ohne selbst einen einzigen Treiber zu kennen. Dein JDBC-Treiber liegt einfach auf dem Module Path, und DriverManager findet ihn.

Kompiliert und gestartet werden Module über den Module Path – das Gegenstück zum Classpath:

$ javac -d out --module-source-path src $(find src -name "*.java")
$ java --module-path out --module eu.happycoders.app/eu.happycoders.app.App

Hello Module!

Die Optionen gibt es auch in Kurzform: -p für --module-path und -m für --module.

Code, der ohne module-info.java auf dem Classpath liegt, landet im sogenannten unnamed module. Es liest alle anderen Module und exportiert alle seine Packages. Dadurch laufen bestehende Anwendungen auf Java 9 weiter – Modularisierung ist also optional.

Für die Fälle, in denen die Modulgrenzen im Weg stehen, gibt es Kommandozeilen-Optionen, die sie aufweichen:

  • --add-modules fügt Module hinzu, die kein anderes Modul per requires anfordert – z. B. ein Incubator-Modul oder einen Service-Provider, den nur der ServiceLoader kennt.
  • --add-exports <Modul>/<Package>=<Zielmodul> exportiert ein Package nachträglich.
  • --add-opens <Modul>/<Package>=<Zielmodul> öffnet ein Package nachträglich für Deep Reflection.
  • --add-reads <Modul>=<Zielmodul> erlaubt einem Modul, ein anderes zu verwenden, als stünde ein requires in seiner Modul-Deklaration – typisch für Tests, damit das Test-Framework nicht als requires im Produktivcode landet.

Als Zielmodul ist ALL-UNNAMED erlaubt – dann gilt die Option für allen Code auf dem Classpath. Diese Optionen sind für Übergangsphasen und Tests gedacht, nicht für die dauerhafte Konfiguration einer Anwendung.

The Modular JDK – JEP 200 und 201

Das JDK selbst wurde mit JEP 200 in Module zerlegt. Die Namensregel ist einfach: Module, deren Spezifikation vom Java Community Process verwaltet wird, heißen java.*; alles andere heißt jdk.*. Ganz unten im Modulgraphen liegt java.base mit Paketen wie java.lang, java.util und java.io – jedes andere Modul hängt von ihm ab, und es selbst hängt von nichts ab.

Welche Module dein JDK enthält, zeigt dir java --list-modules. Und was ein einzelnes Modul exportiert und benötigt, verrät java --describe-module:

$ java --describe-module java.sql

java.sql@21.0.2
exports java.sql
exports javax.sql
requires java.transaction.xa transitive
requires java.base mandated
requires java.xml transitive
requires java.logging transitive
uses java.sql.Driver

JEP 201 ist die Vorarbeit dazu: Er hat den Quellcode des JDK in eine Modulstruktur umsortiert, damit die Modulgrenzen schon beim Bauen des JDK geprüft werden können.

Modular Run-Time Images – JEP 220

Wer sich vor Java 9 ein JDK-Verzeichnis angesehen hat, fand dort ein jre-Unterverzeichnis und darin die Datei rt.jar mit der gesamten Klassenbibliothek. Beides ist seit Java 9 verschwunden.

JEP 220 hat die Verzeichnisstruktur neu geordnet:

  • Die Unterscheidung zwischen JRE und JDK gibt es nicht mehr. Ein JDK ist ein Laufzeit-Image, das zufällig auch die Entwicklungswerkzeuge enthält.
  • Die Klassen aller Module liegen in einem einzigen, für schnellen Zugriff optimierten Container-Format im lib-Verzeichnis – nicht mehr in JAR-Dateien.
  • Konfigurationsdateien, die du bearbeiten darfst, liegen im neuen Verzeichnis conf. Alles andere in lib ist Implementierungsdetail.
  • Die Datei release im Wurzelverzeichnis listet unter MODULES alle enthaltenen Module auf.

Da es rt.jar nicht mehr gibt, liefern APIs wie ClassLoader.getSystemResource() für JDK-Klassen keine jar:-URLs mehr, sondern URLs mit dem neuen Schema jrt: – z. B. jrt:/java.base/java/lang/Class.class. Über ein NIO-Dateisystem für dieses Schema kannst du den Inhalt eines Laufzeit-Images durchsuchen, ohne sein internes Format zu kennen.

Wenn das JDK aus Modulen besteht und deine Anwendung auch, dann liegt ein Gedanke nahe: Warum nicht ein Laufzeit-Image bauen, das nur die benötigten Module enthält?

Genau das macht jlink, definiert in JEP 282. Für die Beispielanwendung von oben sieht der Aufruf so aus:

$ jlink --module-path out --add-modules eu.happycoders.app --output image

Das Ergebnis ist ein vollständiges Laufzeit-Image mit eigenem java-Befehl, das nur drei Module enthält – java.base und die beiden Anwendungsmodule:

$ ./image/bin/java --list-modules

eu.happycoders.app
eu.happycoders.greeting
java.base@21.0.2

Auf meinem Rechner ist dieses Image 30 MB groß (mit den Optionen --strip-debug, --no-man-pages, --no-header-files und --compress zip-6), das vollständige JDK 21 dagegen 327 MB. Ein Container-Image, das dein Programm samt Laufzeitumgebung enthält, schrumpft damit um rund 300 MB – und dasselbe gilt für den Installer einer Desktop-Anwendung, die ihre Laufzeitumgebung mitbringt.

jlink verlangt allerdings, dass alle Abhängigkeiten echte Module sind. Ein JAR ohne module-info.java (ein sogenanntes automatic module) kann jlink nicht verarbeiten. In der Praxis ist das die häufigste Hürde.

Der Java Packager (javapackager) wurde mit JEP 275 an das Modulsystem angepasst und erzeugt die mitgelieferte Laufzeitumgebung seitdem mit jlink. Genutzt hat es ihm nicht lange: In Java 11 wurde der Java Packager zusammen mit JavaFX aus dem JDK entfernt.

Encapsulate Most Internal APIs – JEP 260

Mit dem Modulsystem hätte das JDK alle internen Packages – sun.*, com.sun.*, jdk.internal.* – auf einen Schlag unzugänglich machen können. Das hätte einen großen Teil des Java-Ökosystems gebrochen, denn viele Bibliotheken nutzten sun.misc.Unsafe und andere Interna.

JEP 260 teilt die internen APIs deshalb in drei Gruppen:

  1. Nicht-kritische interne APIs – solche, die außerhalb des JDK kaum verwendet werden oder die sich leicht durch eine unterstützte API oder eine Bibliothek ersetzen lassen, z. B. sun.misc.BASE64Decoder – sind seit Java 9 zur Compile-Zeit unzugänglich, ob Ersatz existiert oder nicht. Wer sie weiterhin verwendet, muss --add-exports setzen.
  2. Kritische interne APIs, für die es in Java 8 einen unterstützten Ersatz gab, sind ebenfalls unzugänglich.
  3. Kritische interne APIs ohne Ersatz – allen voran sun.misc.Unsafe, außerdem sun.misc.Signal und sun.reflect.ReflectionFactory – bleiben im Modul jdk.unsupported zugänglich. Kommt der Ersatz erst mit Java 9 (wie VarHandle für Unsafe), wird die alte API deprecated und in einem späteren Release gekapselt oder entfernt.

Die drei Gruppen gelten für die Compile-Zeit. Zur Laufzeit war Java 9 nachsichtiger: Die Option --illegal-access stand standardmäßig auf permit, so dass Deep Reflection auf JDK-Interna weiterhin funktionierte und lediglich beim ersten Zugriff eine Warnung ausgab (WARNING: An illegal reflective access operation has occurred). Mit --illegal-access=warn oder debug konntest du dir alle Zugriffe anzeigen lassen, mit deny das zukünftige Verhalten testen.

Das Ende dieser Übergangsphase kam in zwei Schritten: Java 16 stellte den Standard auf deny um, und Java 17 entfernte die Option ganz. Seitdem hilft nur noch --add-opens – oder besser: eine Bibliotheksversion, die keine Interna mehr braucht.

Welche JDK-Interna dein Code oder deine Abhängigkeiten verwenden, verrät dir das Tool jdeps mit der Option --jdk-internals.

Convenience Factory Methods for Collections – JEP 269

Eine kleine, unveränderliche Liste anzulegen war bis Java 8 erstaunlich umständlich:

List<String> list = new ArrayList<>();
list.add("a");
list.add("b");
list.add("c");
list = Collections.unmodifiableList(list);

Oder kürzer, aber nicht verständlicher: Collections.unmodifiableList(Arrays.asList("a", "b", "c")). Für Sets und Maps war es noch umständlicher.

Dazu kommt: Collections.unmodifiableList() erzeugt keine neue Liste, sondern nur eine unveränderliche View auf die übergebene. Wer noch eine Referenz auf die Originalliste hat, kann sie weiter ändern – und jede Änderung ist durch die View hindurch sichtbar.

JEP 269 bringt statische Factory-Methoden auf den Interfaces List, Set und Map:

List<String> list = List.of("a", "b", "c");
Set<Integer> set = Set.of(1, 2, 3);
Map<String, Integer> map = Map.of("one", 1, "two", 2, "three", 3);

Map.of() gibt es für bis zu zehn Schlüssel-Wert-Paare. Für größere Maps nimmst du Map.ofEntries() zusammen mit Map.entry():

import static java.util.Map.entry;

Map<String, Integer> map = Map.ofEntries(
    entry("one", 1),
    entry("two", 2),
    entry("three", 3));

Die so erzeugten Collections haben vier Eigenschaften, die du kennen solltest:

  • Sie sind unveränderlich. add(), put(), remove() und alle anderen schreibenden Methoden werfen eine UnsupportedOperationException.
  • Sie erlauben kein null – weder als Element noch als Schlüssel oder Wert. Der Versuch endet mit einer NullPointerException.
  • Set.of() und Map.of() lehnen Duplikate ab – mit einer IllegalArgumentException („duplicate element“). Bei Set.of(1, 1) ist das vermutlich ein Programmierfehler, und den siehst du lieber sofort.
  • Die Iterationsreihenfolge von Set.of() und Map.of() ist nicht definiert – und ändert sich von JVM-Start zu JVM-Start. Wer sich auf eine Reihenfolge verlässt, findet den Fehler dadurch schon im ersten Test. Das ist Absicht.

Im Gegensatz zu den Collections.unmodifiable...()-Wrappern sind das keine Views auf eine andere Collection, sondern kompakte, auf kleine Größen zugeschnittene Implementierungen. Eine mit Map.of() oder Map.ofEntries() erzeugte Map verzichtet z. B. auf das Bucket-Array und die Node-Objekte, die eine HashMap für wenige Elemente anlegen würde.

Was noch fehlte, war ein Weg, eine bestehende Collection unveränderlich zu kopieren. Den brachte Java 10 mit List.copyOf(), Set.copyOf() und Map.copyOf().

Neue Stream-Methoden

Die Stream-API aus Java 8 bekommt in Java 9 vier Ergänzungen. Sie sind in keinem JEP definiert, aber im Alltag nützlicher als manches, was einen JEP hat.

takeWhile() liefert alle Elemente vom Anfang des Streams, bis das Prädikat zum ersten Mal false ergibt – und dropWhile() überspringt genau diese Elemente:

Stream.of(1, 2, 3, 4, 5, 1, 2)
    .takeWhile(i -> i < 4)
    .forEach(System.out::print);
123
Stream.of(1, 2, 3, 4, 5, 1, 2)
    .dropWhile(i -> i < 4)
    .forEach(System.out::print);
4512

Du siehst am zweiten Beispiel: Die 1 und die 2 am Ende bleiben erhalten. Anders als filter() prüft dropWhile() das Prädikat nur so lange, bis es einmal fehlschlägt.

Stream.iterate() gibt es seit Java 9 mit einem zusätzlichen Prädikat, das den Stream beendet – der Ersatz für die Kombination aus iterate() und limit(), wenn du nicht die Anzahl, sondern die Abbruchbedingung kennst:

Stream.iterate(1, i -> i <= 100, i -> i * 2)
    .forEach(System.out::println);
1
2
4
8
16
32
64

Stream.ofNullable() erzeugt einen Stream mit einem Element – oder einen leeren Stream, wenn das Argument null ist. Das ist praktisch in flatMap()-Aufrufen, in denen sonst eine null-Prüfung stünde. Im folgenden Beispiel enthält config nur einen Eintrag für "host". config.get("port") liefert deshalb null, Stream.ofNullable() macht daraus einen leeren Stream, und das Ergebnis enthält nur den Wert für "host":

Map<String, String> config = Map.of("host", "localhost");

List<String> values = Stream.of("host", "port")
    .flatMap(key -> Stream.ofNullable(config.get(key)))
    .collect(Collectors.toList());
[localhost]

Dazu kommen zwei neue Collectors: Collectors.filtering() und Collectors.flatMapping(). Beide sind für den Einsatz als nachgelagerte Collectors in groupingBy() oder partitioningBy() gedacht. Das folgende Beispiel gruppiert Wörter nach ihrem Anfangsbuchstaben und behält in jeder Gruppe nur die Wörter mit mehr als fünf Buchstaben:

List<String> words = List.of("apple", "fig", "banana", "cherry");

Map<Character, List<String>> longWords = words.stream()
    .collect(Collectors.groupingBy(word -> word.charAt(0),
        Collectors.filtering(word -> word.length() > 5, Collectors.toList())));
{a=[], b=[banana], c=[cherry], f=[]}

Der Unterschied zu einem filter() vor dem groupingBy(): Die Gruppen a und f, in denen kein Wort den Filter passiert, bleiben als leere Listen erhalten. Mit filter() davor würden sie aus der Map verschwinden, und das Ergebnis wäre {b=[banana], c=[cherry]}. Collectors.flatMapping() tut dasselbe für flatMap(): Jedes Element wird in einen Stream aufgelöst, dessen Elemente in der Gruppe landen.

Neue Optional-Methoden

Auch Optional bekommt drei Methoden, die sich Entwickler:innen seit Java 8 gewünscht hatten.

ifPresentOrElse() ergänzt ifPresent() um den Fall, dass kein Wert vorhanden ist:

optional.ifPresentOrElse(
    value -> System.out.println("Wert: " + value),
    () -> System.out.println("kein Wert"));

or() liefert ein anderes Optional, wenn das eigene leer ist – im Gegensatz zu orElse() also nicht den Wert, sondern wieder ein Optional, mit dem du weiterarbeiten kannst:

Optional<String> result = fromCache(key).or(() -> fromDatabase(key));

Und stream() wandelt ein Optional in einen Stream mit null oder einem Element um. Damit lässt sich ein Stream von Optionals in einem Schritt auf die vorhandenen Werte reduzieren:

List<String> values = optionals.stream()
    .flatMap(Optional::stream)
    .collect(Collectors.toList());

Reactive Streams und CompletableFuture-Erweiterungen – JEP 266

JEP 266 trägt den unscheinbaren Titel „More Concurrency Updates“ und enthält zwei Dinge, die einen eigenen Titel verdient hätten.

Flow API

Die neue Klasse java.util.concurrent.Flow enthält vier Interfaces: Publisher, Subscriber, Subscription und Processor. Sie entsprechen eins zu eins der Reactive-Streams-Spezifikation – dem Standard, auf den sich RxJava, Project Reactor und Akka Streams geeinigt hatten, damit ihre Komponenten zusammenarbeiten.

Das Grundprinzip heißt Backpressure: Ein Subscriber sagt dem Publisher über Subscription.request(n), wie viele Elemente er verarbeiten kann. Der Publisher schickt nie mehr als angefordert. So kann ein schneller Produzent einen langsamen Konsumenten nicht überfluten.

Das JDK liefert mit SubmissionPublisher eine Implementierung des Publishers mit. Hier ist ein Subscriber, der die Elemente einzeln anfordert:

SubmissionPublisher<String> publisher = new SubmissionPublisher<>();

publisher.subscribe(new Flow.Subscriber<>() {
  private Flow.Subscription subscription;

  @Override
  public void onSubscribe(Flow.Subscription subscription) {
    this.subscription = subscription;
    subscription.request(1);
  }

  @Override
  public void onNext(String item) {
    System.out.println("Received: " + item);
    subscription.request(1);
  }

  @Override
  public void onError(Throwable throwable) {
    throwable.printStackTrace();
  }

  @Override
  public void onComplete() {
    System.out.println("Done");
  }
});

publisher.submit("foo");
publisher.submit("bar");
publisher.submit("baz");
publisher.close();
Received: foo
Received: bar
Received: baz
Done

Die Elemente werden asynchron in einem Thread des ForkJoinPool zugestellt – in einem main()-Programm musst du also kurz warten, bevor es endet.

Für Anwendungscode wirst du weiterhin eine Bibliothek wie Project Reactor verwenden; die vier Interfaces bieten keine Operatoren wie map() oder filter(). Ihr Zweck ist ein anderer: Über sie reicht eine Bibliothek Daten an eine andere weiter, ohne von ihr abzuhängen. Der HTTP Client aus Java 11 verwendet sie z. B. für Request- und Response-Bodies: Ein BodyPublisher ist ein Flow.Publisher<ByteBuffer>, und jede Bibliothek, die die Flow-Interfaces implementiert, kann einen Body liefern oder verarbeiten, ohne den HTTP Client zu kennen.

Neue CompletableFuture-Methoden

CompletableFuture bekommt Methoden für alles, was mit Zeit zu tun hat:

  • orTimeout(timeout, unit) lässt das Future nach Ablauf der Zeit mit einer TimeoutException fehlschlagen.
  • completeOnTimeout(value, timeout, unit) vervollständigt es stattdessen mit einem Standardwert.
  • CompletableFuture.delayedExecutor(delay, unit) liefert einen Executor, der Tasks erst nach der Verzögerung startet. Zusammen mit supplyAsync() ergibt das ein verzögertes Future.

Ein Beispiel für completeOnTimeout():

CompletableFuture<String> future = fetchFromRemoteService();
String result = future.completeOnTimeout("default", 500, TimeUnit.MILLISECONDS).get();

Antwortet der Service nicht innerhalb von 500 Millisekunden, enthält result den String "default".

Dazu kommen vier weitere Methoden:

  • failedFuture(exception) erzeugt ein bereits fehlgeschlagenes Future – das Gegenstück zu completedFuture(value).
  • completeAsync(supplier) vervollständigt ein bestehendes Future mit dem Ergebnis des Suppliers, der dafür asynchron ausgeführt wird – im Standard-Executor oder in einem, den du als zweites Argument übergibst.
  • copy() liefert ein neues Future, das denselben Wert (oder dieselbe Exception) bekommt wie das Original – wie thenApply(x -> x). Damit gibst du ein Future heraus, an das Aufrufer Folgeaktionen hängen können, ohne dass sie das Original per complete() abschließen könnten.
  • newIncompleteFuture() und defaultExecutor() sind für Unterklassen gedacht: Die erste erzeugt die Futures, die thenApply(), thenCompose() und die anderen verkettenden Methoden zurückgeben, so dass eine Unterklasse ihren eigenen Typ zurückgeben kann. Die zweite legt den Executor fest, den die ...Async()-Methoden ohne Executor-Argument verwenden – standardmäßig der Common Pool des ForkJoinPool.

Process API Updates – JEP 102

Mit ProcessBuilder und Process konntest du bis Java 8 einen Prozess starten, seine Ein- und Ausgabe umleiten, ihn beenden und auf sein Ende warten. Was fehlte, waren Informationen über den Prozess – nicht einmal seine Prozess-ID gab die Klasse heraus. Wer sie brauchte, griff zu Reflection auf plattformspezifische Felder – oder zu nativem Code.

JEP 102 führt das Interface ProcessHandle ein. Während Process weiterhin für einen Prozess steht, den deine JVM gestartet hat – samt Zugriff auf seine Streams –, steht ein ProcessHandle für einen beliebigen Betriebssystemprozess: den eigenen, einen gestarteten oder einen fremden. Ein Handle kennt Prozess-ID, Metadaten und Prozessbaum, aber keine Streams:

ProcessHandle current = ProcessHandle.current();
System.out.println("PID: " + current.pid());

ProcessHandle.Info info = current.info();
System.out.println("Command: " + info.command().orElse("?"));
System.out.println("Start: " + info.startInstant().orElse(null));
System.out.println("User: " + info.user().orElse("?"));
PID: 66097
Command: /Users/sven/.sdkman/candidates/java/21.0.2-open/bin/java
Start: 2026-09-18T11:38:00.970Z
User: sven

Die Methoden von Info liefern Optional, weil nicht jedes Betriebssystem jede Information herausgibt.

Über parent(), children() und descendants() navigierst du im Prozessbaum, mit destroy() beendest du einen Prozess, und onExit() liefert ein CompletableFuture, das nach dem Ende des Prozesses ausgelöst wird:

Process process = new ProcessBuilder("sleep", "1").start();
process.toHandle().onExit()
    .thenAccept(h -> System.out.println("Process " + h.pid() + " exited"));

Von einem Process kommst du mit toHandle() zu seinem ProcessHandle. Die Abfragemethoden pid(), info(), onExit(), children() und descendants() hat Process außerdem selbst bekommen – sie delegieren an das Handle, so dass du für einen selbst gestarteten Prozess nicht umschalten musst.

Stack-Walking API – JEP 259

Wer bisher wissen wollte, von wo eine Methode aufgerufen wurde, hatte zwei Optionen: Thread.getStackTrace() und new Throwable().getStackTrace(). Beide erzeugen einen Schnappschuss des gesamten Stacks als StackTraceElement-Array – auch wenn dich nur die obersten zwei Frames interessieren.

In einer Spring- oder Jakarta-EE-Anwendung sind das schnell einige hundert Frames. Jeder davon wird zu einem StackTraceElement-Objekt auf dem Heap, das die JVM erst füllen und der Garbage Collector kurz darauf wieder einsammeln muss – CPU-Zeit und Speicher für Daten, die niemand liest. Ein Logger, der die Position in jede Zeile schreibt, zahlt diesen Preis für jede einzelne Log-Zeile.

Ein zweites Problem: Beide liefern Klassennamen als Strings, keine Class-Objekte. Wer die Klasse selbst braucht – etwa um mit ihrem Class Loader weitere Klassen zu laden –, kann sie aus dem Namen nicht zuverlässig zurückgewinnen, denn Class.forName() sucht mit dem Class Loader des Aufrufers, nicht mit dem der gesuchten Klasse.

Die Stack-Walking API macht daraus einen Stream, der Frames erst dann materialisiert, wenn du sie liest. Ein Logger, der die aufrufende Klasse und Methode in jede Zeile schreibt, braucht damit nur bis zum ersten Frame außerhalb seiner eigenen Klasse zu suchen:

class Logger {
  static void log(String message) {
    Optional<StackFrame> caller = StackWalker.getInstance()
        .walk(frames -> frames
            .filter(frame -> !frame.getClassName().equals(Logger.class.getName()))
            .findFirst());
    System.out.println(
        caller.map(f -> f.getClassName() + "." + f.getMethodName()).orElse("?")
            + ": " + message);
  }
}

Aufgerufen aus einer Methode App.run(), gibt Logger.log("Hello") aus:

App.run: Hello

findFirst() beendet den Stream nach dem zweiten Frame – die restlichen Frames werden nie angelegt. Genau das kann Thread.getStackTrace() nicht: Es liefert immer das ganze Array.

Der Stream ist nur innerhalb der an walk() übergebenen Funktion gültig – deshalb die Lambda-Form. Solange walk() läuft, hält die JVM den Stack des Threads unverändert. Sobald walk() zurückkehrt, darf sie die Frames wieder verändern, z. B. indem der JIT-Compiler eine Methode deoptimiert; die Frames des Streams wären dann nicht mehr die des Stacks.

Für den Fall, dass du nur die aufrufende Klasse brauchst – etwa um wie ResourceBundle.getBundle() den Class Loader des Aufrufers zu verwenden –, gibt es eine Abkürzung:

class Util {
  static Class<?> caller() {
    return StackWalker.getInstance(Option.RETAIN_CLASS_REFERENCE).getCallerClass();
  }
}

getCallerClass() liefert die Klasse, aus der die umgebende Methode aufgerufen wurde: Ruft App.run() die Methode Util.caller() auf, kommt App.class zurück.

Damit das funktioniert, muss der StackWalker mit der Option RETAIN_CLASS_REFERENCE erzeugt werden. Ohne die Option wirft getCallerClass() eine UnsupportedOperationException.

Die Option ist ein bewusstes Opt-in: Ein Class-Objekt gibt Zugriff auf den Class Loader und damit auf mehr als nur einen Namen. Deshalb prüft die JVM die Berechtigung dafür einmal beim Erzeugen des StackWalker (unter einem Security Manager die RuntimePermission getStackWalkerWithClassReference) und nicht bei jedem Frame.

Logging-Frameworks haben davon am meisten profitiert: Sie mussten die aufrufende Klasse für jede Log-Zeile bisher entweder aus dem kompletten Stack-Array lesen oder das JDK-interne sun.reflect.Reflection.getCallerClass() verwenden. Mit StackWalker gibt es dafür zum ersten Mal eine offizielle API – und die interne belässt JEP 260 nur noch als Übergangslösung im Modul jdk.unsupported.

Variable Handles – JEP 193

Wer atomare Operationen auf einem Feld brauchte – ein compareAndSet() etwa –, hatte bis Java 8 die Wahl zwischen einer AtomicInteger-Instanz (bzw. AtomicLong oder AtomicReference – in jedem Fall ein zusätzliches Objekt pro Feld), einem AtomicIntegerFieldUpdater (langsam) und sun.misc.Unsafe (nicht unterstützt und gefährlich: Die Methoden dort schreiben an beliebige Speicheradressen, ohne Typ- oder Grenzprüfung – ein falscher Offset beschädigt den Heap oder bringt die JVM zum Absturz).

JEP 193 bringt mit VarHandle eine offizielle API für genau diese Operationen. Ein VarHandle ist eine typisierte Referenz auf eine Variable – ein Instanzfeld, ein statisches Feld oder ein Array-Element –, über die du mit verschiedenen Access Modes auf die Variable zugreifst: get() und set() in plain, opaque, acquire/release oder volatile Semantik (siehe die Box unten), dazu compareAndSet(), getAndAdd(), getAndBitwiseOr() und weitere.

Ein VarHandle erzeugst du über einen Lookup, so wie einen MethodHandle:

public class Counter {
  private static final VarHandle COUNT;

  static {
    try {
      COUNT = MethodHandles.lookup().findVarHandle(Counter.class, "count", int.class);
    } catch (ReflectiveOperationException e) {
      throw new ExceptionInInitializerError(e);
    }
  }

  private volatile int count;

  void increment() {
    COUNT.getAndAdd(this, 1);
  }

  boolean resetIfEquals(int expected) {
    return COUNT.compareAndSet(this, expected, 0);
  }
}

Die Methoden für die Access Modes – get(), set(), compareAndSet(), getAndAdd() und die übrigen – sind signature polymorphic, wie MethodHandle.invoke(). Das heißt: Sie sind mit Object... deklariert, getAndAdd() etwa als Object getAndAdd(Object... args). Der Compiler erzeugt für jeden Aufruf trotzdem eine Signatur aus den tatsächlichen Typen: Aus COUNT.getAndAdd(this, 1) im Beispiel oben wird im Bytecode ein Aufruf mit der Signatur (Counter, int)void – die 1 wird nicht zu einem Integer geboxt, und für die Varargs entsteht kein Object[]. Die Schreibweise ist die der Klasse MethodType: in den Klammern die Parametertypen, dahinter der Rückgabetyp, hier also void.

Damit der JIT-Compiler diese Aufrufe voll optimieren kann, sollte das VarHandle in einem static final-Feld liegen: Dann ist es für ihn eine Konstante, und die Typprüfungen der Aufrufe fallen weg.

VarHandle ist der offizielle Ersatz für die meisten Unsafe-Methoden. Damit begann für die alten Methoden der übliche Weg zur Ablösung: Java 23 markierte die Speicherzugriffs-Methoden von sun.misc.Unsafe als „deprecated for removal“, Java 24 gibt bei ihrer Verwendung eine Warnung aus.

Enhanced Method Handles – JEP 274

JEP 274 richtet sich an Autor:innen von Laufzeitumgebungen anderer JVM-Sprachen – Nashorn, JRuby, Groovy – und von Bytecode-Frameworks, die Code aus MethodHandles zusammensetzen. Er ergänzt MethodHandles um Kombinatoren, mit denen sich Kontrollfluss ohne eigene Bytecode-Erzeugung ausdrücken lässt: whileLoop(), doWhileLoop(), countedLoop() und iteratedLoop() bauen aus Handles für Initialisierung, Bedingung und Rumpf eine Schleife, und tryFinally() verbindet ein Handle mit einer Aufräumaktion, die auch bei einer Exception läuft:

MethodHandle target = lookup.findStatic(Demo.class, "work",
    MethodType.methodType(String.class, String.class));
MethodHandle cleanup = lookup.findStatic(Demo.class, "cleanup",
    MethodType.methodType(String.class, Throwable.class, String.class, String.class));

MethodHandle guarded = MethodHandles.tryFinally(target, cleanup);
String result = (String) guarded.invoke("hello");

cleanup() bekommt die Exception (oder null), das Ergebnis von work() und dessen Argument und liefert das endgültige Ergebnis. Dazu kommen Varianten von foldArguments(), asSpreader() und asCollector(), die an beliebiger Position in der Argumentliste ansetzen statt nur am Ende, sowie neue Lookups: findClass() lädt eine Klasse unter den Zugriffsregeln des Lookups, und Default-Methoden in Interfaces sind jetzt per Lookup erreichbar.

Platform Logging API and Service – JEP 264

Das JDK selbst loggt über java.util.logging. Wer in seiner Anwendung Log4j oder Logback verwendet, bekam die Log-Ausgaben des JDK deshalb in einem anderen Format und an einem anderen Ort.

JEP 264 trennt Schnittstelle und Implementierung: System.Logger ist die minimale Logging-Schnittstelle, die das JDK selbst verwendet, und System.LoggerFinder der Service, über den eine Anwendung ihr eigenes Logging-Framework als Backend einhängt. Für dich heißt das: Die Log-Meldungen des JDK landen im selben Log wie deine eigenen – mit demselben Format, in derselben Datei und mit derselben Level-Konfiguration.

Wird kein LoggerFinder über den ServiceLoader gefunden, landen die Meldungen wie bisher in java.util.logging.

Du kannst System.Logger auch direkt verwenden:

private static final Logger LOGGER = System.getLogger(MyClass.class.getName());

LOGGER.log(Level.INFO, "Application started");
LOGGER.log(Level.WARNING, "Config value {0} is deprecated", "foo");

Log4j 2 (Modul log4j-jpl) und SLF4J (slf4j-jdk-platform-logging) liefern eine LoggerFinder-Implementierung mit – die JDK-eigenen Meldungen landen dann im Log deiner Anwendung.

Filter Incoming Serialization Data – JEP 290

Java-Deserialisierung ist ein klassisches Einfallstor für Angriffe auf Server-Anwendungen. Überall dort, wo eine Anwendung serialisierte Objekte von außen entgegennimmt – in einem Session-Cookie, einer Nachricht aus einer Queue, einem RMI-Aufruf –, liest ein ObjectInputStream Daten, die jemand anders geschrieben hat.

Das Problem: Der Stream enthält nicht nur Daten, sondern auch die Namen der Klassen, aus denen die Objekte wiederhergestellt werden sollen. Der Absender bestimmt also, welche Klassen readObject() lädt und instanziiert – und zwar aus allem, was im Classpath liegt.

Damit wird jede Klasse zur Waffe, die beim Deserialisieren von sich aus Code ausführt, etwa in readObject(), equals() oder hashCode(). Angreifer:innen verketten mehrere solcher Klassen aus verbreiteten Bibliotheken zu einer sogenannten Gadget Chain, an deren Ende ein Runtime.exec() steht: Der Server führt dann den Befehl aus, den der Absender mitgeschickt hat – eine Remote Code Execution. Genau so funktionierte die bekannteste dieser Ketten: Sie baut aus Klassen von Apache Commons Collections eine Kette, die in Runtime.exec() endet – und traf im November 2015 unter anderem WebLogic, WebSphere, JBoss, Jenkins und OpenNMS (CVE-2015-7501).

JEP 290 führt Deserialisierungsfilter ein. Ein Filter entscheidet für jede Klasse im Stream, ob sie erlaubt ist, und begrenzt zusätzlich Array-Größen, Objekt-Tiefe, Referenzanzahl und Stream-Länge.

Am einfachsten definierst du einen Filter als Muster-String:

ObjectInputFilter filter =
    ObjectInputFilter.Config.createFilter("java.util.*;java.lang.*;!*");

try (ObjectInputStream ois = new ObjectInputStream(inputStream)) {
  ois.setObjectInputFilter(filter);
  Object object = ois.readObject();
}

Das Muster erlaubt Klassen aus java.util und java.lang und lehnt alles andere ab (!*). Wird eine nicht erlaubte Klasse gefunden, bricht readObject() mit einer InvalidClassException („filter status: REJECTED“) ab.

Einen prozessweiten Filter setzt du über die System-Property jdk.serialFilter – ohne Codeänderung, z. B. -Djdk.serialFilter='java.lang.*;maxarray=1000;!*'.

Java 17 hat den Mechanismus um kontextspezifische Filter erweitert, die pro Stream dynamisch bestimmt werden.

Weitere Erweiterungen der Klassenbibliothek

Einige Erweiterungen sind zu klein für ein eigenes Kapitel, aber zu nützlich, um sie zu verschweigen:

  • InputStream.readAllBytes() liest einen Stream vollständig in ein Byte-Array, readNBytes() eine bestimmte Anzahl Bytes, und transferTo(OutputStream) kopiert den gesamten Inhalt in einen Output-Stream – drei Methoden, für die man bisher Apache Commons IO brauchte.
  • Objects.requireNonNullElse(obj, default) und requireNonNullElseGet(obj, supplier) liefern einen Standardwert, wenn obj null ist. Objects.checkIndex(), checkFromToIndex() und checkFromIndexSize() prüfen Array- und Listenindizes und werfen eine IndexOutOfBoundsException mit lesbarer Meldung („Index 3 out of bounds for length 3“).
  • Arrays.equals(), compare() und mismatch() gibt es jetzt auch für Teilbereiche zweier Arrays. mismatch() liefert den Index des ersten Unterschieds – oder -1, wenn die Arrays gleich sind.
  • Enumeration.asIterator() macht aus der Uralt-Schnittstelle Enumeration einen Iterator, auf dem forEachRemaining() funktioniert.
  • Matcher.results() liefert alle Treffer eines regulären Ausdrucks als Stream<MatchResult>, und Matcher.replaceAll(Function<MatchResult, String>) berechnet den Ersatz pro Treffer mit einer Funktion. Scanner.tokens() und findAll() liefern ebenfalls Streams.
  • Math.fma() berechnet a * b + c mit einer einzigen Rundung (fused multiply-add, per CPU-Instruktion, wo verfügbar), Math.multiplyHigh() die oberen 64 Bit eines 128-Bit-Produkts zweier long-Werte und Math.multiplyFull() das exakte long-Produkt zweier int-Werte.
  • Integer.parseInt() und parseUnsignedInt() (ebenso die Long-Varianten) parsen jetzt einen Teilbereich einer CharSequence – ohne vorheriges substring(). Mehr dazu im Artikel String in int umwandeln.
  • Thread.onSpinWait() aus JEP 285 signalisiert der CPU, dass der Thread in einer Spin-Schleife wartet – auf x86 wird daraus die PAUSE-Instruktion, die Latenz und Stromverbrauch der Schleife senkt.
  • java.lang.ref.Cleaner ist der Ersatz für finalize(): Du registrierst ein Objekt zusammen mit einer Aufräum-Aktion, die läuft, sobald das Objekt nicht mehr erreichbar ist. Java 18 hat finalize() konsequenterweise als „deprecated for removal“ markiert.

Use CLDR Locale Data by Default – JEP 252

Wie ein Datum, eine Zahl oder ein Währungsbetrag in einer bestimmten Sprache aussieht, stand bis Java 8 in Locale-Daten, die Sun Microsystems in den 1990er-Jahren zusammengetragen hatte. JEP 252 stellt sie auf das Common Locale Data Repository (CLDR) des Unicode-Konsortiums um, das denselben Zweck für Betriebssysteme, Browser und Programmiersprachen erfüllt und laufend gepflegt wird.

Diese Änderung kann unerwartete Folgen haben, denn die Java-9-Datumsformate unterscheiden sich in Details von den alten. Drei Beispiele für die deutsche Locale:

Java 8seit Java 9
DateFormat.SHORT, Datum und Zeit21.03.17 10:3021.03.17, 10:30 (mit Komma)
Wochentag (EEE)DiDi. (mit Punkt)
DateFormat.FULL, Datum und ZeitDienstag, 21. März 2017 10:30 Uhr MEZDienstag, 21. März 2017 um 10:30:00 Mitteleuropäische Normalzeit

Solange die Ausgaben nur gelesen werden, fällt das kaum auf. Kritisch wird es dort, wo ein Programm seine eigenen Ausgaben wieder einliest oder wo Tests auf exakte Strings prüfen: Das Komma im kurzen Datum reicht, um einen Parser scheitern zu lassen.

Für die Übergangszeit kannst du die alten Daten zurückholen:

$ java -Djava.locale.providers=COMPAT,CLDR MyApp

Ich empfehle, das nur zu tun, um Zeit zu gewinnen, und die betroffenen Stellen in dieser Zeit umzustellen. Denn diese Brücke ist inzwischen abgerissen: Bis Java 22 funktionierte COMPAT mit einer Warnung, seit Java 23 meldet die JVM „COMPAT locale provider has been removed“ und formatiert grundsätzlich nach CLDR.

jshell: The Java Shell – JEP 222

Jede Skriptsprache hat eine sogenannte Read-Eval-Print Loop (REPL): eine Eingabeaufforderung, in der du einen Ausdruck eintippst und sofort das Ergebnis siehst. Java hatte bis Version 8 keine – wer eine API ausprobieren wollte, musste eine Wegwerf-Klasse mit main()-Methode und System.out.println() schreiben.

JEP 222 bringt die jshell:

$ jshell

jshell> int x = 10
x ==> 10

jshell> x * 2
$2 ==> 20

jshell> List.of(1, 2, 3).stream().mapToInt(i -> i).sum()
$3 ==> 6

Semikolons sind optional, Ausdrücke ohne Zuweisung landen in automatisch benannten Variablen ($2, $3), und die gängigen Packages wie java.util, java.io und java.util.stream sind bereits importiert. Die Tab-Taste vervollständigt Namen.

Mit den Kommandos /vars, /methods und /types siehst du, was du bisher definiert hast, mit /edit öffnest du einen Editor, mit dem du u. a. Variablen oder Methoden verändern kannst, und /exit beendet die Sitzung.

Ich empfehle dir, die jshell einmal auszuprobieren. Sie ist der schnellste Weg, eine unbekannte API kennenzulernen, eine Regex gegen ein paar Beispielstrings zu testen oder nachzusehen, was eine Methode bei einem Randfall zurückgibt – ganz ohne Projekt, Klasse und Build.

Hinter dem Tool steckt die API jdk.jshell, mit der auch IDEs und Notebooks Java-Snippets auswerten können.

Multi-Release JAR Files – JEP 238

Bibliotheksautor:innen stehen vor einem Dilemma: Neue JDK-APIs zu verwenden heißt, alte Java-Versionen nicht mehr zu unterstützen – oder zwei Artefakte zu pflegen. Wer z. B. sun.misc.BASE64Decoder durch das in Java 8 hinzugekommene java.util.Base64 ersetzte, verlor damit die Nutzer:innen von Java 7.

JEP 238 erlaubt, mehrere Versionen einer Klasse in einem JAR auszuliefern. Der Aufbau:

jar root
  - A.class
  - B.class
  - C.class
  - META-INF
     - MANIFEST.MF  (enthält "Multi-Release: true")
     - versions
        - 9
           - A.class
           - B.class

Auf Java 8 sieht der Class Loader nur die Klassen im Wurzelverzeichnis. Auf Java 9 und neuer sucht er zuerst im Verzeichnis der laufenden Version, dann in den Verzeichnissen niedrigerer Versionen und zuletzt im Wurzelverzeichnis. C gibt es nur einmal, A und B in zwei Fassungen.

Das jar-Tool baut solche Archive mit der Option --release:

$ jar --create --file library.jar -C classes . --release 9 -C classes-9 .

Alle Versionen einer Klasse müssen dieselbe öffentliche API haben, also dieselben public- und protected-Methoden und -Felder; private Methoden dürfen sich unterscheiden. Das jar-Tool prüft das und verweigert das Archiv sonst mit „contains a class with different api from earlier version“. Es geht um unterschiedliche Implementierungen, nicht um unterschiedliche Schnittstellen.

Build-Tools wie Maven und Gradle unterstützen das Format inzwischen; viele bekannte Bibliotheken (z. B. Log4j 2 und Byte Buddy) liefern Multi-Release-JARs aus.

Compile for Older Platform Versions – JEP 247

Die Optionen -source und -target gibt es seit den frühen JDK-Versionen: Mit javac -source 8 -target 8 kompilierst du beispielsweise mit einem neuen Java-Compiler Bytecode für Java 8 – aber gegen die Klassenbibliothek des neuen JDK. Rufst du versehentlich eine Methode auf, die es in Java 8 noch nicht gab, merkst du das erst zur Laufzeit auf Java 8 mit einem NoSuchMethodError.

JEP 247 führt dafür die neue Option --release ein, die beide Angaben zusammenfasst und zusätzlich die Klassenbibliothek der Zielversion vorgibt:

$ javac --release 8 -d out Rel.java

Rel.java:2: error: cannot find symbol
  symbol:   method of(int,int)
  location: interface List

Der Aufruf von List.of() schlägt jetzt schon beim Kompilieren fehl, statt erst auf dem Zielsystem. Möglich macht das die Datei lib/ct.sym im JDK: Sie enthält die Signaturen der öffentlichen APIs aller unterstützten älteren Versionen.

--release ersetzt -source und -target – die Optionen lassen sich nicht kombinieren, javac bricht mit „option --source cannot be used together with --release“ ab. Maven und Gradle bieten die neue Option als maven.compiler.release bzw. options.release an.

Ich empfehle, überall --release (oder die entsprechenden Maven- und Gradle-Optionen) zu verwenden.

Enhanced Deprecation – JEP 277

Die Annotation @Deprecated gibt es seit Java 5. Bis Java 8 sagte sie nur: „Bitte nicht mehr verwenden.“ Der Annotation war nicht anzusehen, ob eine API irgendwann entfernt werden würde und wann. Aus dem JDK verschwand jahrzehntelang ohnehin fast nichts, so dass viele die Markierung als folgenlos betrachteten.

JEP 277 gibt der Annotation zwei Elemente:

@Deprecated(since = "9", forRemoval = true)
public void oldMethod() { ... }

since nennt die Version, seit der die API deprecated ist. forRemoval = true kündigt an, dass sie in einem zukünftigen Release entfernt wird – die Java-Entwickler:innen nennen das „terminally deprecated“. Der Compiler gibt für solche Aufrufe eine eigene Warnung aus, die sich nicht mit @SuppressWarnings("deprecation") unterdrücken lässt, sondern nur mit @SuppressWarnings("removal"):

Dep.java:12: warning: [removal] Integer(int) in Integer has been deprecated and marked for removal
    Integer i = new Integer(42);
                ^

Dazu kommt das Tool jdeprscan, das kompilierte Klassen oder JARs nach der Verwendung deprecateter JDK-APIs durchsucht – auch ohne Quellcode:

$ jdeprscan --for-removal library.jar

Seit Java 9 meinen es die JDK-Entwickler:innen mit der Ankündigung ernst: Was als forRemoval markiert ist, verschwindet in der Regel innerhalb weniger Releases. Thread.stop(Throwable) etwa war seit Java 1.2 deprecated, wurde in Java 9 zusätzlich als forRemoval markiert – und ist seit Java 11 weg. Die Konstruktoren der Wrapper-Klassen (new Integer(42)) haben den Weg bis zur Hälfte hinter sich: in Java 9 deprecated, in Java 16 für die Entfernung markiert.

Eine kleine Ergänzung liefert JEP 211: Bis Java 8 warnte der Compiler zweimal – einmal an der import-Zeile und einmal an jeder Verwendung. Die Warnung am Import ließ sich nicht mit @SuppressWarnings unterdrücken, weil ein Import keine Anweisung ist, an die sich eine Annotation hängen ließe. Seit Java 9 entfällt sie; es bleibt die Warnung an der tatsächlichen Verwendung. Ein Import ohne Verwendung erzeugt damit gar keine Warnung mehr; auf einen überflüssigen Import weist dich stattdessen deine IDE hin.

New Version-String Scheme – JEP 223

Welche Version hat mehr Sicherheits-Fixes: JDK 7 Update 55 oder Update 60? Die Antwort lautet: beide dieselben. Update 60 erschien zwar später, war aber ein Feature-Update; seine Security Baseline war 1.7.0_55, es brachte also keinen einzigen Sicherheits-Fix über Update 55 hinaus. Der Abstand von fünf Nummern legte das Gegenteil nahe, und ansehen konnte man es der Versionsnummer nicht.

JEP 223 ersetzt das Schema 1.8.0_60 durch $MAJOR.$MINOR.$SECURITY, angelehnt an Semantic Versioning. Java 9 heißt 9, das erste Security-Update 9.0.1, das erste Minor-Update 9.1.2 – die Security-Zahl wird beim Minor-Update nicht zurückgesetzt, so dass eine höhere dritte Zahl immer mehr Sicherheits-Fixes bedeutet. Die Build-Nummer folgt nach einem Pluszeichen: 9.0.1+11.

Die neue Klasse Runtime.Version parst und vergleicht solche Strings:

Runtime.Version version = Runtime.version();
System.out.println(
    version.major() + "." + version.minor() + "." + version.security());

Die System-Property java.version liefert seit Java 9 also 9.0.1 statt 1.9.0_01. Wer sie selbst zerlegt, bekommt damit falsche Ergebnisse – lies die Version stattdessen über Runtime.version() aus, dann bleibt dein Code auch beim nächsten Schema-Wechsel richtig.

Javadoc: HTML5, Suche und neue Doclet API – JEP 224, 225 und 221

Drei JEPs modernisieren das javadoc-Tool:

  • JEP 224 ergänzt die Option -html5, mit der Javadoc semantisches HTML5 mit WAI-ARIA-Rollen für Barrierefreiheit erzeugt. In Java 9 ist HTML 4.01 noch der Standard, seit Java 10 HTML5; wer das alte Markup brauchte, konnte es bis Java 12 mit -html4 anfordern. Seit Java 13 gibt es nur noch HTML5, und -html5 wird nur noch geduldet („This option is no longer required“).
  • JEP 225 fügt jeder generierten API-Dokumentation ein Suchfeld hinzu. Die Suche läuft rein clientseitig in JavaScript, versteht CamelCase-Abkürzungen (addFL findet addFocusListener) und durchsucht Module, Packages, Typen, Member und mit {@index ...} markierte Begriffe. Die Suche in der offiziellen JDK-Dokumentation ist genau dieses Feature.
  • JEP 221 ersetzt die alte Doclet API (com.sun.javadoc) durch jdk.javadoc.doclet, das auf der Language Model API (javax.lang.model) und der Compiler Tree API aufsetzt. Für dich ist das nur relevant, wenn du eigene Doclets schreibst.

Unified JVM Logging und Unified GC Logging – JEP 158 und 271

Bis Java 8 hatte jedes JVM-Subsystem – Garbage Collector, Klassenlader, JIT-Compiler, Threads – seine eigenen Logging-Optionen: -XX:+PrintGC, -XX:+PrintGCDetails, -XX:+TraceClassLoading und Dutzende mehr, jede mit eigenem Ausgabeformat.

JEP 158 ersetzt sie durch ein Logging-System mit einer Option: -Xlog. Jede Meldung trägt ein oder mehrere Tags (gc, class, compiler, safepoint, …) und einen Level (error, warning, info, debug, trace). Der einfachste Aufruf nennt nur ein Tag und überlässt den Rest den Standardwerten – Level info, Ausgabe nach stdout:

$ java -Xlog:gc MyApp

[0.003s][info][gc] Using G1
[0.153s][info][gc] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation) 27M->3M(32M) 1.524ms

Die eckigen Klammern am Anfang sind die Decorations: hier Uptime, Level und Tags.

Die vollständige Form der Option lautet -Xlog:<Tags>=<Level>:<Ausgabe>:<Decorations>, und jeder Teil hinter den Tags darf fehlen. Damit wählst du gezielt aus:

  • -Xlog:gc* – alle Meldungen, die das Tag gc in Kombination mit anderen Tags tragen (das Gegenstück zu -XX:+PrintGCDetails).
  • -Xlog:gc*:file=gc.log:time,uptime – dasselbe in eine Datei, mit Uhrzeit und Uptime als Decorations. Log-Rotation ist eingebaut.
  • -Xlog:class+load=info – jede geladene Klasse.
  • -Xlog:help – die vollständige Syntax und die Liste aller Tags.

JEP 271 hat das GC-Logging auf dieses System umgestellt. Wer seine GC-Logs bisher mit -XX:+PrintGCDetails -Xloggc:gc.log erzeugte, muss ab Java 9 -Xlog:gc*:file=gc.log verwenden – und bekommt ein Format, das seitdem stabil geblieben ist.

Die Konfiguration lässt sich zur Laufzeit per jcmd <pid> VM.log ändern.

Performance

Drei JEPs verändern das Laufzeitverhalten jeder Java-Anwendung. Zwei davon wirken sofort beim Wechsel auf Java 9; das dritte, sobald du deinen Code neu kompilierst.

Compact Strings – JEP 254

Bis Java 8 speicherte ein String seine Zeichen in einem char[] – zwei Byte pro Zeichen, auch wenn der String nur aus ASCII-Zeichen bestand. Und das tun die meisten Strings in einer typischen Anwendung: Klassennamen, JSON-Schlüssel, URLs, Log-Meldungen.

JEP 254 ersetzt das char[] durch ein byte[] plus ein coder-Feld: Strings, die nur Zeichen aus Latin-1 enthalten, belegen ein Byte pro Zeichen; alle anderen zwei Byte in UTF-16. Nach außen ändert sich nichts – charAt(), length() und alle anderen Methoden verhalten sich wie zuvor. Nach innen halbiert sich der Speicherbedarf der meisten Strings, und mit ihm die Zahl der Garbage-Collector-Läufe.

Das zusätzliche coder-Feld kostet dabei nichts: Die JVM richtet jedes Objekt auf ein Vielfaches von acht Byte aus, und ein String-Objekt hatte bis Java 8 vier Byte Verschnitt am Ende. Genau dort sitzt seit Java 9 das eine Byte für den coder. Ein String-Objekt selbst ist in beiden Versionen 24 Byte groß – gespart wird im byte[], nicht im String.

Was das für einzelne Methoden bedeutet, kannst du in meinem Artikel über substring() nachlesen. Mit -XX:-CompactStrings lässt sich die Optimierung abschalten – ich kenne keinen Grund, das zu tun.

Indify String Concatenation – JEP 280

Für "Hello " + name + "!" erzeugte javac bisher eine Kette von StringBuilder.append()-Aufrufen. Wollte das JDK-Team die Verkettung schneller machen, musste es den Compiler ändern – und jede Anwendung hätte neu kompiliert werden müssen, um davon zu profitieren.

JEP 280 lässt javac stattdessen eine einzige invokedynamic-Instruktion erzeugen. Welcher Code die Strings dann tatsächlich zusammenfügt, entscheidet zur Laufzeit die Klasse StringConcatFactory. Die Strategie kann sich von JDK-Version zu JDK-Version verbessern, ohne dass sich der Bytecode ändern muss.

(Das „indify“ im JEP-Titel ist ein Kunstwort der JDK-Entwickler:innen und heißt so viel wie „auf invokedynamic umstellen“.)

Ein Effekt dieser Änderung: Seit Java 9 ist "" + i genauso schnell wie Integer.toString(i). Den Bytecode vor und nach der Änderung zeige ich im Artikel int in String umwandeln.

Make G1 the Default Garbage Collector – JEP 248

Bis Java 8 war der Parallel GC der Standard-Garbage-Collector – optimiert auf Durchsatz, mit entsprechend langen Stop-the-World-Pausen. JEP 248 macht den G1 („Garbage First“) zum Standard auf Server-Konfigurationen. G1 begrenzt die Pausenzeiten, indem er den Heap in Regionen aufteilt und pro Zyklus nur so viele davon aufräumt, wie ins Pausenziel passen. Dieses Ziel liegt standardmäßig bei 200 Millisekunden und lässt sich mit -XX:MaxGCPauseMillis verschieben.

Die Begründung im JEP: Für die meisten Anwendungen sind kurze Pausen wichtiger als maximaler Durchsatz. Wer den Durchsatz braucht, wählt den Parallel GC weiterhin explizit mit -XX:+UseParallelGC.

Die oben genannte Einschränkung „auf Server-Konfigurationen“ hatte einen langen Nachhall: Auf Maschinen mit nur einer CPU oder weniger als 1.792 MB RAM blieb der Serial GC der Standard. Das traf vor allem kleine virtuelle Maschinen und Container – also genau die Umgebungen, in denen Java-Anwendungen in den Jahren danach überwiegend liefen. Erst Java 27 machte den G1 in allen Umgebungen zum Standard.

Incubator- und experimentelle Features

Java 9 ist das erste Release mit einem Incubator-Modul – dem in JEP 11 definierten Mechanismus, um eine API auszuliefern, bevor sie Teil von Java SE wird. Incubator-Module heißen jdk.incubator.*, werden nicht automatisch aufgelöst und können sich in jedem Release ändern.

Preview-Features, wie du sie aus neueren Java-Versionen kennst, gab es 2017 noch nicht: Der Mechanismus dafür kam mit JEP 12 erst in Java 12. Der Unterschied zum Incubator liegt im Reifegrad. Ein Preview-Feature ist vollständig spezifiziert und implementiert und wartet nur auf das Urteil der Praxis; eine Incubator-API ist ausdrücklich noch nicht fertig und steht deshalb in einem eigenen Modul außerhalb von Java SE.

Beides ist von experimentellen Features zu unterscheiden: Das sind Eigenschaften der JVM, die hinter -XX-Optionen liegen und in keiner Spezifikation stehen – wie die beiden Compiler in den folgenden Abschnitten.

HTTP/2 Client (Incubator) – JEP 110

HttpURLConnection stammt aus Java 1.1, kennt nur blockierende Aufrufe und ist – so formuliert es der JEP – „hard to use, with many undocumented behaviors“. JEP 110 liefert einen neuen HTTP-Client mit Builder-API, synchronem und asynchronem Modus (auf Basis von CompletableFuture), HTTP/2 und WebSocket.

In Java 9 liegt er im Modul jdk.incubator.httpclient und im Package jdk.incubator.http. Um ihn zu verwenden, musst du das Modul beim Kompilieren und Starten mit --add-modules jdk.incubator.httpclient aktivieren:

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://www.happycoders.eu/"))
    .GET()
    .build();

HttpResponse<String> response =
    client.send(request, HttpResponse.BodyHandler.asString());

System.out.println(response.statusCode());

In Java 11 wurde der Client unter dem Package java.net.http standardisiert – mit kleineren Änderungen an der API (aus BodyHandler.asString() wurde z. B. BodyHandlers.ofString()). Dort zeige ich ihn ausführlich.

Ahead-of-Time Compilation (Experimental) – JEP 295

Der JIT-Compiler braucht Zeit zum Aufwärmen, und selten aufgerufene Methoden werden nie kompiliert. JEP 295 führt mit jaotc ein Tool ein, das Java-Klassen vor dem Start in nativen Code übersetzt:

$ jaotc --output libHelloWorld.so HelloWorld.class
$ java -XX:AOTLibrary=./libHelloWorld.so HelloWorld

Als Code-Generator dient der in Java geschriebene Graal-Compiler; das Feature war auf Linux/x64 beschränkt und experimentell. Es hat sich nicht durchgesetzt: Java 17 hat jaotc wieder entfernt. Das Thema Startzeit wird seitdem von Project Leyden mit einem anderen Ansatz angegangen.

Java-Level JVM Compiler Interface (Experimental) – JEP 243

JEP 243 definiert eine Schnittstelle (JVMCI), über die ein in Java geschriebener Compiler von der JVM als JIT-Compiler verwendet werden kann. Der einzige nennenswerte Nutzer war der Graal-Compiler, den du in Java 10 mit -XX:+UseJVMCICompiler aktivieren konntest.

Auch dieses Kapitel ist inzwischen abgeschlossen: Graal wird als GraalVM außerhalb des JDK weiterentwickelt, und Java 27 hat das JVMCI aus dem JDK entfernt.

Deprecations und Löschungen

Deprecate the Applet API – JEP 289

2017 hatten die meisten Browser-Hersteller die Plug-in-Schnittstelle NPAPI bereits abgeschafft oder deren Ende angekündigt – und damit die Grundlage, auf der das Java-Plug-in Applets im Browser ausführte. JEP 289 markiert java.applet.Applet, javax.swing.JApplet und die zugehörigen Klassen als @Deprecated(since = "9") – zunächst ohne forRemoval. Das folgte in Java 17, und Java 26 hat die API entfernt.

Deprecate the Concurrent Mark Sweep (CMS) Garbage Collector – JEP 291

Mit G1 als neuem Standard hatte der Concurrent Mark Sweep Collector seinen Zweck verloren: Beide sind auf kurze Pausen optimiert, aber CMS ist in der Pflege aufwendig und bremst die Weiterentwicklung der GC-Codebasis. JEP 291 markiert ihn als deprecated – die Option -XX:+UseConcMarkSweepGC gibt seitdem eine Warnung aus. Entfernt wurde CMS in Java 14.

Remove GC Combinations Deprecated in JDK 8 – JEP 214

Java 8 hatte einige selten genutzte Kombinationen von Young- und Old-Generation-Collectoren als deprecated markiert, JEP 214 entfernt sie: DefNew + CMS, ParNew + SerialOld sowie den inkrementellen CMS-Modus (-Xincgc, -XX:+CMSIncrementalMode). Eine JVM, die mit einer dieser Optionen gestartet wird, bricht seit Java 9 mit einer Fehlermeldung ab.

Remove the JVM TI hprof Agent und Remove the jhat Tool – JEP 240 und 241

Der hprof-Agent (-agentlib:hprof) war Demo-Code für das JVM Tool Interface, der nie als Produktivwerkzeug gedacht war. Seine Funktionen haben bessere Nachfolger: Heap-Dumps erzeugst du mit jcmd <pid> GC.heap_dump oder jmap -dump, für Profiling gibt es VisualVM und – seit Java 11 kostenlos – den Java Flight Recorder. JEP 240 entfernt den Agenten, JEP 241 das zugehörige Analysewerkzeug jhat.

Remove Launch-Time JRE Version Selection – JEP 231

Seit JDK 5 konnte eine Anwendung per Manifest-Eintrag JRE-Version oder per Option java -version:<Version> verlangen, mit einer bestimmten JRE-Version gestartet zu werden – ein nie vollständig dokumentiertes Feature, das mit Java Web Start und nativen Installern längst bessere Alternativen hatte. JEP 231 entfernt es: Die Option führt zu einem Fehler, der Manifest-Eintrag zu einer Warnung.

Remove Demos and Samples – JEP 298

Die Verzeichnisse demo und sample des JDK enthielten Code, der seit Jahren nicht mehr gepflegt wurde. JEP 298 entfernt sie.

Sonstige Änderungen in Java 9

Die folgenden 49 JEPs wirst du im Alltag kaum bemerken. Damit die Liste vollständig bleibt, führe ich sie hier in thematischen Gruppen mit je einem Satz auf.

Sicherheit

  • JEP 219 implementiert DTLS 1.0 und 1.2 – TLS für UDP-basierte Protokolle – in javax.net.ssl.
  • JEP 244 ergänzt die TLS-Erweiterung ALPN (Application-Layer Protocol Negotiation), über die Client und Server das Anwendungsprotokoll aushandeln – die Voraussetzung für HTTP/2 über TLS.
  • JEP 249 implementiert OCSP Stapling: Der Server liefert den Gültigkeitsnachweis seines Zertifikats gleich mit, so dass der Client nicht selbst bei der Zertifizierungsstelle nachfragen muss.
  • JEP 229 macht PKCS12 statt des JDK-eigenen JKS-Formats zum Standardformat neuer Keystores; bestehende JKS-Keystores werden weiterhin erkannt.
  • JEP 273 fügt SecureRandom die drei DRBG-Mechanismen aus NIST SP 800-90Ar1 hinzu, konfigurierbar über die neue Klasse DrbgParameters – mehr dazu im Artikel über Zufallszahlen in Java.
  • JEP 287 implementiert die Hash-Funktionen SHA3-224, SHA3-256, SHA3-384 und SHA3-512.
  • JEP 288 erweitert die Security-Property jdk.certpath.disabledAlgorithms um Bedingungen (jdkCA, denyAfter, usage), mit denen sich SHA-1-signierte Zertifikate gezielt sperren lassen.
  • JEP 246 beschleunigt GHASH (Teil von AES-GCM) und RSA über CPU-Instruktionen von Intel x64 und SPARC.
  • JEP 232 verringert den Performance-Verlust von 10 bis 15 %, den Anwendungen mit aktiviertem Security Manager bisher hinnehmen mussten – vor allem durch ConcurrentHashMap statt synchronisierter Maps in den Permission-Klassen.

JVM-Interna

  • JEP 143 beschleunigt umkämpfte („contended“) Monitore: monitorenter, monitorexit, notify() und notifyAll() sowie das Aufwecken geparkter Threads.
  • JEP 197 teilt den Code-Cache in drei Segmente – JVM-interner Code, profilierter Code mit kurzer Lebensdauer und voll optimierter Code –, damit der JIT-Compiler nicht mehr in einem einzigen Heap nach Platz suchen muss.
  • JEP 250 legt internierte Strings mit im Class-Data-Sharing-Archiv ab, so dass mehrere JVM-Prozesse sie sich teilen können.
  • JEP 270 reserviert am Ende jedes Thread-Stacks einen Bereich, den kritische Abschnitte (z. B. in ReentrantLock) auch bei einem Stack Overflow noch nutzen dürfen, damit sie keine inkonsistenten Datenstrukturen hinterlassen.
  • JEP 228 ergänzt jcmd um weitere Diagnosekommandos, etwa zum Auflisten geladener Klassen und der Compiler-Warteschlangen.
  • JEP 165 erlaubt, das Verhalten der JIT-Compiler pro Methode über eine JSON-ähnliche Direktiven-Datei zu steuern (-XX:CompilerDirectivesFile) – auch zur Laufzeit per jcmd.

Desktop, Grafik und JavaFX

  • JEP 251 führt das Interface MultiResolutionImage ein, mit dem ein Bild in mehreren Auflösungen vorliegt und die passende für den Bildschirm gewählt wird.
  • JEP 263 bringt HiDPI-Unterstützung für Windows und Linux, die es unter macOS bereits gab.
  • JEP 262 ergänzt Image I/O um ein Plugin für das TIFF-Format.
  • JEP 272 definiert eine öffentliche API für plattformspezifische Desktop-Funktionen – Taskleiste und Dock, Login- und Logout-Ereignisse – als Ersatz für die macOS-internen Packages com.apple.eawt und com.apple.eio.
  • JEP 265 macht den Marlin-Renderer zum Standard-Rasterizer von Java 2D.
  • JEP 258 ersetzt die ICU-Font-Layout-Engine durch HarfBuzz.
  • JEP 283 erlaubt AWT, Swing und JavaFX unter Linux die Verwendung von GTK 3.
  • JEP 253 macht bisher interne APIs der JavaFX-UI-Controls und des CSS-Systems öffentlich, damit sie die Modularisierung überstehen.
  • JEP 257 aktualisiert das in JavaFX/Media enthaltene GStreamer.
  • JEP 256 ersetzt die @beaninfo-Javadoc-Tags durch Annotationen, aus denen BeanInfo-Klassen zur Laufzeit erzeugt werden.

Internationalisierung und XML

  • JEP 226 liest .properties-Dateien für ResourceBundle standardmäßig als UTF-8 – Umlaute als \u00e4 zu maskieren ist damit Geschichte. Ist eine Datei kein gültiges UTF-8, wird sie wie bisher als ISO-8859-1 gelesen.
  • JEP 227 und JEP 267 aktualisieren Character, String und die übrigen Unicode-abhängigen Klassen auf Unicode 7.0 und 8.0.
  • JEP 268 bringt eine öffentliche API für XML-Kataloge nach dem OASIS-Standard 1.1, mit der externe Referenzen in XML, XSD und XSL auf lokale Kopien umgeleitet werden.
  • JEP 255 übernimmt ausgewählte Änderungen aus Xerces 2.11.0 in den XML-Parser des JDK.

Nashorn

Drei JEPs erweitern die JavaScript-Engine Nashorn: JEP 236 definiert eine öffentliche Parser-API, mit der IDEs den Syntaxbaum von ECMAScript-Code analysieren können; JEP 292 implementiert einen Teil von ECMAScript 6 (Arrow Functions, Klassen, Template Strings, let und const); und JEP 276 macht Nashorns Linker-Mechanismus für invokedynamic als eigenständiges Modul jdk.dynalink verfügbar.

Nashorn wurde in Java 11 als deprecated markiert und in Java 15 entfernt.

Ports

JEP 237 portiert das JDK auf Linux/AArch64 (64-Bit-ARM), JEP 294 auf Linux/s390x (IBM-Mainframes), und JEP 297 integriert den von Oracle beigesteuerten gemeinsamen Port für arm32 und arm64.

Entwicklung des JDK selbst

13 JEPs betreffen ausschließlich den Bau und Test des JDK: den Compiler-Server sjavac (JEP 199), das Beheben von Lint- und Doclint-Warnungen im JDK-Quellcode (JEP 212), drei Änderungen an javac selbst – schnellere Typprüfung von Argumenten (JEP 215), korrekte Verarbeitung von Imports unabhängig von der Reihenfolge (JEP 216) und eine neue Annotations-Pipeline (JEP 217) –, die Validierung aller JVM-Kommandozeilenargumente (JEP 245), automatisch generierte Compiler-Tests (JEP 233), Tests für die von javac erzeugten Class-File-Attribute (JEP 235) und für Humongous Objects im G1 (JEP 278), automatische Diagnosedaten bei Testfehlern (JEP 279), ein C++-Unit-Test-Framework auf Basis von Google Test für HotSpot (JEP 281), ein neues Build-System für HotSpot (JEP 284) und die Neuordnung der Dokumentation (JEP 299).

Vollständige Liste aller Änderungen in Java 9

Dieser Artikel hat alle 91 JEPs von Java 9 vorgestellt, dazu die wichtigsten Erweiterungen der Klassenbibliothek, die keinem JEP zugeordnet sind. Die Liste der JEPs findest du auf der Projektseite von JDK 9, eine vollständige Liste aller Änderungen in den offiziellen Java 9 Release Notes.

Fazit

Java 9 war ein Release, das viele Teams übersprungen haben – und das trotzdem die Grundlage für alles Weitere gelegt hat. Das Modulsystem hat das JDK von der monolithischen rt.jar in Dutzende Module zerlegt, aus denen jlink maßgeschneiderte Laufzeit-Images baut; die strikte Kapselung der JDK-Interna hat Bibliotheken und Frameworks in den Jahren danach zu sauberen Abhängigkeiten gezwungen – und damit den JDK-Entwickler:innen die Freiheit zurückgegeben, Interna zu ändern, ohne das halbe Java-Ökosystem zu brechen. Ohne diese Freiheit hätte sich Java nach 2017 kaum so schnell weiterentwickelt, wie es das getan hat.

Für den Alltag sind es die kleinen Dinge, die geblieben sind: List.of(), private Methoden in Interfaces, takeWhile(), Optional.or(), ProcessHandle, jshell. Zwei Verbesserungen bekam beim Wechsel von Java 8 auf Java 9 sogar jede Anwendung geschenkt, ohne eine einzige Zeile neu zu kompilieren: Compact Strings halbierten den Speicherbedarf der meisten Strings, und mit G1 als Standard wurden aus langen Stop-the-World-Pausen kurze. Die invokedynamic-basierte String-Verkettung gibt es dagegen erst nach einem Neuübersetzen – sie steckt im Bytecode, den javac ab Version 9 erzeugt.

Wenn du heute noch eine Java-8-Anwendung betreust, dann ist Java 9 nicht das Ziel, sondern die erste Hürde: Die Kapselung der Interna, die entfernten Java-EE-Module in Java 11 und die geänderten Locale-Daten sind die Punkte, an denen eine Migration typischerweise hängt. Ich empfehle, die Abhängigkeiten zuerst mit jdeps --jdk-internals zu prüfen und dann direkt auf das aktuelle LTS-Release zu gehen – die Hürden zwischen Java 9 und heute sind deutlich niedriger als die zwischen 8 und 9.

Konntest du etwas aus diesem Artikel mitnehmen? Mit einer Bewertung auf meinem ProvenExpert-Profil hilfst du anderen Entwickler:innen einzuschätzen, ob sich das Lesen lohnt – und mir zu verstehen, welche Inhalte dir weiterhelfen.

👉 Bewertung abgeben

Der HappyCoders-Newsletter hält dich über neue Java-Features und Artikel auf dem Laufenden – klicke hier, um dich anzumelden.

👉 Newsletter-Anmeldung

Lust auf noch mehr Wissen?

In meinem Blog findest du viele Artikel zu Java, Softwarearchitektur und Performance von grundlegenden Konzepten bis hin zu fortgeschrittenen Patterns.

Wenn du tiefer einsteigen willst, schau dir meine Trainings an: praxisnah, verständlich und direkt auf euren Projektalltag übertragbar. Statt Theorie vermittle ich Prinzipien, die euch helfen, Code langfristig besser, wartbarer und performanter zu schreiben.

Zu den Java-Schulungen

Werde ein:e bessere:r Java-Entwickler:in

Mit meinem kostenlosen Newsletter bleibst du vorn. Modernes Java: neue Versionen & Features, Performance und JVM-Insights – 1x im Monat.

Suche