Sweet 16

Java 16: Das JDK auf dem Weg zur nächsten LTS-Version

Falk Sippach

© Shutterstock / j.chizhe

14, 15 oder doch schon 16? Da kann man schon mal durcheinanderkommen. Durch die mittlerweile halbjährlichen Major-Releases von Java fällt es gar nicht so leicht, die aktuelle Version richtig zu benennen. Vor Kurzem hat man sich in einem Vortrag noch über die Neuerungen des JDK 14 informiert, und wenig später wurden in einem Artikel bereits die Features von Version 15 näher beleuchtet. Und da sich die Welt bekanntlich schnell weiterdreht, ist nun im März 2021 bereits das OpenJDK 16 herausgekommen.

Um die Verwirrung komplettzumachen, kann man sich natürlich auch nur auf die sogenannten Long-Term-Support-Versionen (LTS) konzentrieren, die alle drei Jahre erscheinen. Das ist im Moment Java 11, wobei in der freien Wildbahn die Version 8 ebenfalls noch sehr weit verbreitet ist. Im September 2021 wird nun mit dem JDK 17 das nächste LTS-Release erscheinen. Dort werden die Neuerungen der vergangenen drei Jahre (Java 12 bis 16) finalisiert, um bis zur darauffolgenden LTS-Version (JDK 23) gut dazustehen. In den vergangenen „Zwischen“-Releases wurden die diversen, teilweise auch größeren Änderungen häufig als Previews veröffentlicht. Dadurch konnte frühzeitig Feedback eingesammelt und bereits im nächsten Release eingearbeitet werden.

(Nicht ganz so) neue Features

Die Liste der für das OpenJDK 16 umgesetzten JEPs (Java Enhancement Proposals) sieht auf den ersten Blick wieder relativ lang aus [1]:

  • 338: Vector API (Incubator)
  • 347: Enable C++14 Language Features
  • 357: Migrate from Mercurial to Git
  • 369: Migrate to GitHub
  • 376: ZGC: Concurrent Thread-Stack Processing
  • 380: Unix-Domain Socket Channels
  • 386: Alpine Linux Port
  • 387: Elastic Metaspace
  • 388: Windows/AArch64 Port
  • 389: Foreign Linker API (Incubator)
  • 390: Warnings for Value-Based Classes
  • 392: Packaging Tool
  • 393: Foreign-Memory Access API (Third Incubator)
  • 394: Pattern Matching for instanceof
  • 395: Records
  • 396: Strongly Encapsulate JDK Internals by Default
  • 397: Sealed Classes (Second Preview)

Einige der Punkte sind für Java-Entwickler aber nicht direkt relevant. Dazu zählen beispielsweise die Migration zu Git/GitHub, die Aktivierung der C++-14-Sprachfeatures und auch das Foreign Linker API als zukünftiger Ersatz für das Java Native Interface (JNI). Wir schauen am Ende dieses Artikels trotzdem genauer darauf. Werfen wir aber zunächst einen Blick auf die Funktionen, die uns Entwickler betreffen. Dabei werden dem aufmerksamen Beobachter der vergangenen Java-Releases allerdings keine bahnbrechenden Neuerungen ins Auge springen. Das hängt vermutlich mit dem bevorstehenden LTS-Release zusammen, das im Herbst 2021 erscheinen wird. In Java 17 werden die neuen Features der vergangenen Monate stabilisiert, um für die folgenden Jahre eine gute Ausgangsbasis für die notwendigen Updates und Patches zu schaffen.

Java goes Pattern Matching

Bereits seit einiger Zeit schwebt das Thema Pattern Matching im Raum und hält nach und nach Einzug in Java. Dazu sind aber zur Vorbereitung diverse Änderungen in der Sprache selbst notwendig und deshalb erfolgt die Einführung nur schrittweise. Los ging es mit den Switch Expressions bereits im JDK 12. Seit Version 14 gab es zudem bereits zwei Previews zu „Pattern Matching for instanceof“. Das wird nun mit Java 16 abgeschlossen.

Ein Pattern ist übrigens eine Kombination aus einem Prädikat (das auf eine Zielstruktur passt) und einer Menge von Variablen innerhalb dieses Musters. Diesen Variablen werden bei passenden Treffern die entsprechenden Inhalte zugewiesen und damit extrahiert. Die Intention des Pattern Matching ist letztlich die Destrukturierung von Objekten, also das Aufspalten in die Bestandteile und Zuweisen in einzelne Variablen zur weiteren Bearbeitung. Die Spezialform des Pattern Matching beim instanceof-Operator spart unnötige Casts auf die zu prüfenden Zieldatentypen. Wenn o ein String oder eine Collection ist, dann kann direkt mit den neuen Variablen (s und c) mit den entsprechenden Datentypen weitergearbeitet werden. Das Ziel ist es, Redundanzen zu vermeiden und dadurch die Lesbarkeit zu erhöhen (Listing 1).

boolean isNullOrEmpty( Object o ) {
  return o == null ||
    o instanceof String s && s.isBlank() ||
    o instanceof Collection c && c.isEmpty();
}

Der Unterschied zum zusätzlichen Cast mag marginal erscheinen. Für die Puristen unter den Java-Entwicklern spart das allerdings eine kleine, aber dennoch lästige Redundanz ein. Laut Brian Goetz soll die Sprache dadurch prägnanter und die Verwendung sicherer gemacht werden. Erzwungene Typumwandlungen werden vermieden und stattdessen implizit durchgeführt. Bereits die zweite Preview, die im JDK 15 erschienen war, hatte keine nennenswerten Änderungen mehr mit sich gebracht. Deswegen wird das Feature jetzt als JEP 394 finalisiert. In zukünftigen Java-Versionen wird es aber noch weitere Funktionen rund um das Pattern Matching geben, zum Beispiel in Zusammenarbeit mit den Switch Expressions.

Versiegelte Klassen

Erst das zweite Mal dabei sind die Sealed Classes. Sie wurden in Java 15 als Previewfeature eingeführt und verbleiben als JEP 397 auch im JDK 16 im Vorschaumodus. Es gibt ein paar kleine Ergänzungen gegenüber der letzten Version und vermutlich werden sie dann im LTS-Release des OpenJDK 17 finalisiert. Bis dahin möchten die Macher aber noch Rückmeldungen einsammeln.

Dieses Feature wurde übrigens im Rahmen von Projekt Amber entwickelt und gehört ebenfalls zu einer Reihe von vorbereitenden Maßnahmen für die Umsetzung von Pattern-Matching-Mechanismen in Java. Ganz konkret soll es bei der Analyse von Mustern unterstützen. Aber auch für Framework-Entwickler bieten die Sealed Classes einen interessanten Mehrwert. Die Idee ist, dass versiegelte Klassen und Interfaces entscheiden können, welche Subklassen oder -interfaces von ihnen abgeleitet werden dürfen. Bisher konnte man als Entwickler Ableitungen von Klassen nur durch Zugriffsmodifikatoren (private, protected, …) einschränken oder durch die Deklaration der Klasse als final komplett durch den Compiler untersagen. Sealed Classes bieten nun einen deklarativen Weg, um gezielt bestimmten Subklassen die Ableitung zu erlauben:

public sealed class Vehicle
  permits Car, Bike, Bus, Train {
} 

Vehicle darf nur von den vier genannten Klassen überschrieben werden. Damit wird auch dem Aufrufer deutlich gemacht, welche Subklassen erlaubt sind und überhaupt existieren. In Zukunft sollen Sealed Classes auch bei Switch Expressions eingesetzt werden können (im Rahmen des Pattern Matching). Wenn man dann je case-Zweig alle erlaubten Subklassen verwendet, kann der Einsatz des default-Blocks entfallen. Durch die Information aus der permit-Anweisung kann der Compiler sicherstellen, dass mindestens einer der Zweige aufgerufen wird (Listing 2).

// noch kein gültiger Code, kommt erst in späteren Java-Versionen
public BigDecimal calculateExpense(Vehicle vehicle) {
  return switch(vehicle) {
    case Car c -> calculateCarExpense(c);
    case Bike b -> calculateBikeExpense(b);
    case Bus b -> calculateBusExpense(b);
    case Train t -> calculateTrainExpense(t);
  } 
} 

Subklassen bergen immer die Gefahr, dass beim Überschreiben der Vertrag der Superklasse und damit das Liskovsche Substitutionsprinzip verletzt wird. Zum Beispiel ist es unmöglich, die Bedingungen der equals-Methode aus der Klasse Object zu erfüllen, wenn man Instanzen von einer Super- und einer Subklasse miteinander vergleichen will. Weitere Details dazu kann man in der API-Dokumentation [2] unter dem Stichwort Äquivalenzrelationen (konkret Symmetrie) nachlesen.

Sealed Classes funktionieren auch mit abstrakten Klassen. Es gibt aber ein paar Einschränkungen. Eine Sealed Class und alle erlaubten Subklassen müssen im selben Modul existieren. Im Falle von Unnamed Modules müssen sie sogar im gleichen Package liegen. Außerdem muss jede erlaubte Subklasse direkt von der Sealed Class ableiten. Die abgeleiteten Klassen dürfen übrigens wieder selbst entscheiden, ob sie weiterhin versiegelt, final oder komplett offen sein wollen. Die zentrale Versiegelung einer ganzen Klassenhierarchie von oben bis zur untersten Hierarchiestufe ist nicht möglich.

Records

Bereits zum dritten Mal dabei sind wieder die in Java 14 eingeführten record-Datentypen. Mit dem JEP 395 sollen sie nun finalisiert werden. Es gab seit der zweiten Preview (JDK 15) noch einige kleine Änderungen, die sich aus den Rückmeldungen der letzten Monate ergeben haben. Bei den Records handelt es sich um eine eingeschränkte Form der Klassendeklaration, ähnlich den Enums. Entwickelt wurden Records im Rahmen des Projektes Valhalla. Es gibt gewisse Ähnlichkeiten zu Data Classes in Kotlin und Case Classes in Scala. Auch sie sind im Umfeld der Einführung von Pattern Matching entstanden und werden in folgenden JDK-Releases noch relevanter werden. Zudem könnte die kompakte Syntax Bibliotheken wie Lombok in Zukunft zumindest zum Teil obsolet machen. Die einfache Definition einer Person mit zwei Feldern kann man nachfolgend betrachten:

public record Person(String name, Person partner ) {} 

Eine erweiterte Variante mit einem zusätzlichen Konstruktor ist erlaubt. Dadurch lassen sich neben Pflichtfeldern auch optionale Felder abbilden (Listing 3).

public record Person(String name, Person partner ) {
public Person(String name ) { 
  this( name, null ); 
}
  public String getNameInUppercase() { 
    return name.toUpperCase(); 
  }
} 

Erzeugt wird vom Compiler eine unveränderbare (immutable) Klasse, die neben den beiden Attributen und den eigenen Methoden natürlich auch noch die Implementierungen für die Accessoren, den Konstruktor sowie equals/hashCode und toString enthält. Im Listing 4 sieht man den Pseudocode, den man dafür hätte schreiben müssen.

public final class Person extends Record {
  private final String name;
  private final Person partner;
  
  public Person(String name) { this(name, null); }
  public Person(String name, Person partner) { 
    this.name = name; this.partner = partner; 
  }

  public String getNameInUppercase() { 
    return name.toUpperCase(); 
  }
  public String toString() { /* ... */ }
  public final int hashCode() { /* ... */ }
  public final boolean equals(Object o) { /* ... */ }
  public String name() { return name; }
  public Person partner() { return partner; }
}

Verwendet werden Records dann wie normale Java-Klassen. Der Aufrufer merkt also gar nicht, dass ein spezieller Typ instanziiert wird (Listing 5).

var man = new Person("Adam");
var woman = new Person("Eve", man);
woman.toString(); // ==> "Person[name=Eve, partner=Person[name=Adam, partner=null]]"

woman.partner().name(); // ==> "Adam"
woman.getNameInUppercase(); // ==> "EVE"

// Deep equals
new Person("Eve", new Person("Adam")).equals( woman ); // ==> true

Records sind übrigens keine klassischen Java Beans, da sie keine echten Getter enthalten. Man kann auf die Membervariablen aber über die gleichnamigen Methoden zugreifen (name() statt getName()). Sie können im Übrigen auch Annotationen oder JavaDocs enthalten. Im Body dürfen zudem statische Felder sowie Methoden, Konstruktoren oder Instanzmethoden deklariert werden. Nicht erlaubt ist die Definition von weiteren Instanzfeldern außerhalb des record-Headers.

Zwischen Sealed Classes und den record-Typen gibt es eine Integration, wie das Beispiel in Listing 6 zeigt.

public sealed interface Expr
  permits ConstantExpr, PlusExpr, TimesExpr, NegExpr {
...
}
public record ConstantExpr(int i) implements Expr {...}
public record PlusExpr(Expr a, Expr b) implements Expr {...}
public record TimesExpr(Expr a, Expr b) implements Expr {...}
public record NegExpr(Expr e) implements Expr {...}

Eine Familie von Records kann vom gleichen Sealed Interface ableiten. Die Kombination aus Records und versiegelten Datentypen führt uns zu algebraischen Datentypen, die vor allem in funktionalen Sprachen wie Haskell zum Einsatz kommen. Konkret können wir jetzt mit Records Produkttypen und mit versiegelten Klassen Summentypen abbilden. Und auch hier schließt sich wieder der Kreis zum Pattern Matching, das ebenfalls vor allem in funktionalen Sprachen zum Einsatz kommt. Die Modernisierung von Java entwickelt sich also weiterhin in die vielversprechende Richtung der funktionalen Programmierung. Aber bitte nicht erwarten, dass Java in ein paar Jahren eine rein funktionale Sprache sein wird. Da wird es auch in Zukunft besser geeignete Kandidaten (Haskell, Clojure, …) geben. Nichtsdestotrotz wird man auch bei der Entwicklung mit Java von einigen dieser Möglichkeiten profitieren.

Weitere interessante Neuerungen

Bisher wurde die Quellen des OpenJDK in Mercurial verwaltet, einem nicht so verbreiteten Versionsverwaltungssystem. Dadurch war die Hürde für neue Entwickler relativ hoch, sich an der Entwicklung des JDK zu beteiligen. Im Rahmen des JEP 357 wurde der Sourcecode nun in ein Git-Repository migriert und sogar noch nach GitHub [3] umgezogen (JEP 369). Dabei gab es drei Hauptgründe für die Migration:

  1. Größe der Metadaten des Versionsveraltungssystems
  2. verfügbare Werkzeuge für die Versionsverwaltung
  3. Angebote an Hostingoptionen

Bei den Metadaten kam es immerhin zu einer Reduktion von 1,2 GByte auf 300 MByte im .git-Ordner. Zudem wird bei vielen Tools wie IDEs oder Texteditoren Git bereits standardmäßig unterstützt oder lässt sich leicht über Plug-ins nachrüsten. Git hat den Markt für verteilte Versionsverwaltungssysteme in den letzten Jahren nahezu überrollt. Der Schritt, die Quellen des OpenJDK nach GitHub umzuziehen, ist also nachvollziehbar. Um alle relevanten Informationen wie die Historie und Tags mit zu übertragen, wurde eigens ein kleines Tool geschrieben. Dieses hat die Mercurial Commit Messsages in das Git-Format überführt.

Wer schon sehr lange in der Java-Welt unterwegs ist, wird sich vielleicht noch an das Java Native Interface (JNI) erinnern. Damit kann man nativen C-Code aus Java heraus aufrufen. Der Ansatz ist aber relativ aufwendig und fragil. Das Foreign Linker API (JEP 389) bietet nun einen statisch typisierten, rein Java-basierten Zugriff auf nativen Code. Zusammen mit dem Foreign-Memory Access API (JEP 393) kann diese Schnittstelle den bisher fehleranfälligen Prozess der Anbindung einer nativen Bibliothek beträchtlich vereinfachen. Mit Letzterer bekommen Java-Anwendungen die Möglichkeit, außerhalb des Heap zusätzlichen Speicher zu allokieren.

Wer häufig mit primitiven Wrapper-Klassen (Integer, Boolean, …) arbeitet, wird ab dem JDK 16 womöglich über neue Deprecation-for-Removal-Warnungen stolpern. Das betrifft sowohl die Konstruktoren mit dem Stringparameter als auch mit dem jeweiligen primitiven Datentyp als Argument (int bei Integer). Hinter dieser Maßnahme steckt auch das Projekt Valhalla. Dort strebt man die Erweiterung des Java-Programmiermodells in Form von primitiven Klassen an. Diese primitiven Klassen sollen keine Identität besitzen und dadurch auch leicht vom Compiler bzw. dem Laufzeit-Interpreter „ge-inlined“ werden können. Dadurch lassen sie sich frei zwischen Speicherorten kopieren und als Werte von Instanzfeldern kodieren.

Ebenfalls dem Vorschaufeature entwachsen ist mit dem OpenJDK 16 das jpackage-Werkzeug. Es unterstützt native Paketformate, um den Nutzern eine einfache Installation zu ermöglichen, inklusive der Angabe von Startparametern zum Zeitpunkt der Paketierung. Zu den Formaten gehören msi und exe unter Windows, pkg und dmg unter macOS, sowie deb und rpm unter Linux. Das Tool kann direkt über die Befehlszeile oder auch programmatisch aufgerufen werden.

Durch das in Java 9 eingeführte Java Platform Modul System (JPMS) können nun JDK-interne Klassen vor dem Zugriff von außen geschützt werden. Bisher gab es zum Übergang aber die eingeschränkte Kapselung als Default, d. h., dass interne APIs weiterhin verwendet werden konnten. Dieses Schlupfloch wird mit dem OpenJDK 16 geschlossen. Die neue Standardeinstellung ist die strikte Kapselung der JDK-Internas, außer für sehr kritische interne APIs wie misc.Unsafe. Das Ziel dieser Maßnahme ist die Erhöhung der Sicherheit und Wartbarkeit des JDK. Man möchte die Entwickler ermutigen, alte, auf Internas basierende Lösungen zukünftig für den Zugriff auf Standard-APIs umzubauen. Somit sollen sowohl Java-Entwickler als auch Endbenutzer viel problemloser auf zukünftige Versionen updaten können.

Neben den prominenten JEPs gibt es in jeder neuen JDK-Version noch viele kleine Änderungen, zum Beispiel an der Java-Klassenbibliothek. Mit dem Java Version Almanac [4] kann man sehr einfach und kompakt die Differenzen zwischen den Releases, aber auch bei Versionssprüngen von z. B. JDK 8 auf 16 einsehen. Aus Entwicklersicht sind besonders zwei Neuerungen an der Klasse Stream interessant. Stream.toList() bietet eine prägnantere und im Einsatz mit parallel() meist auch effizientere Alternative zu Stream.collect(Collectors.toList()). Als Ergebnis wird eine nicht veränderbare (unmodifiable) ArrayList zurückgegeben. Weitere Informationen kann man der API-Dokumentation [5] oder dem Artikel von Donald Raab [6] entnehmen. Die zweite Neuerung in der Klasse Stream ist die in diversen Ausprägungen hinzugekommene Methode mapMulti(BiConsumer). Sie stellt eine imperative und schnellere Alternative zu flatMap dar. Nicolai Parlog hat die neue Funktion in einem Blogpost näher unter die Lupe genommen und zum Vergleich Performancemessungen durchgeführt [7].

Ausblick

Schon kurz vor der Fertigstellung des OpenJDK 16 wurden die ersten Features für das JDK 17 angekündigt [8]. Unter anderem soll es eine neue Rendering Pipeline für macOS und die Erweiterung des Pseudozufallszahlengenerators geben. Höchstwahrscheinlich werden wir allerdings keine weiteren großen, prominenten Änderungen sehen. Schließlich werden mit Java 17 die Entwicklungen der vergangenen 36 Monate abgeschlossen und es wird eine Version bereitgestellt, die wieder für die nächsten Jahre mit Updates und Sicherheitpatches versorgt werden muss. Die wirklich spannenden Neuerungen und Syntaxerweiterungen werden wir dann voraussichtlich erst wieder in der Version 18 sehen. Ab da hat Oracle dann wieder zweieinhalb Jahre Zeit, Feedback zu Previewfeatures einzuarbeiten und diese abzurunden.

Der 2018 eingeschlagene Weg – der anfangs nicht unumstritten war – wird weiterhin konsequent verfolgt. Die halbjährlichen Updates der Programmiersprache und der Plattform Java geben uns Entwicklern die einfache Möglichkeit, regelmäßig neue Funktionen ausprobieren zu können. Potenziell kann man sogar sein Produktivsystem alle sechs Monate aktualisieren und vermeidet langwierige Migrationsaufwände, die sonst irgendwann anfallen würden. Konservative Kunden können aber trotzdem LTS-Versionen einsetzen, die wie frühere Releases (vor Java 8) über mehrere Jahre mit Updates versorgt werden. Dabei hat man mittlerweile die freie Wahl zwischen verschiedenen Anbietern. Neben kommerziellen Versionen (Oracle JDK und andere) gibt es auch genügend JDKs mit freien Updates (allen voran AdoptOpenJDK). Das Java-Ökosystem ist also lebendiger denn je und weiterhin sehr innovativ. Konkurrenz wie beispielsweise Python (hauptsächlich wegen Machine/Deep Learning), Go und die C-basierten Sprachen beleben das Geschäft. Java, das besonders im Unternehmensanwendungsumfeld vertreten ist, wird aber weiterhin ein gewichtiges Wort mitreden.

Geschrieben von
Falk Sippach
Falk Sippach
Falk Sippach ist bei der embarc Software Consulting GmbH als Softwarearchitekt, Berater und Trainer stets auf der Suche nach dem Funken Leidenschaft, den er bei seinen Teilnehmern, Kunden und Kollegen entfachen kann. Bereits seit über 15 Jahren unterstützt er in meist agilen Softwareentwicklungsprojekten im Java-Umfeld. Als aktiver Bestandteil der Community (Mitorganisator der JUG Darmstadt) teilt er zudem sein Wissen gern in Artikeln, Blog-Beiträgen, sowie bei Vorträgen auf Konferenzen oder User Group Treffen und unterstützt bei der Organisation diverser Fachveranstaltungen.
Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: