Falk Sippach
10.12.2020
Türchen #10 im arcvent(s)kalender 2020
Oracle hat ab Java 10 vor etwa 3 Jahren damit begonnen, die Major-Releases halbjährlich auszuliefern. Das hörte sich zunächst verwegen an, hat sich aber als ein sehr sinnvoller Schachzug herausgestellt. Mittlerweile kommen alle sechs Monate immer genau im Zeitplan die neuen JDK-Versionen mit genau der Anzahl Features heraus, die bis dahin fertiggestellt werden konnten. Wobei auch gezielt Previews von neuen Funktionen herausgegeben werden, damit man sehr früh Feedback dazu einsammeln kann. Vorbei sind also die Zeiten, wo man drei, vier oder sogar fünf Jahre auf das nächste Java-Release warten musste und dann von der großen Menge an Neuerungen geradezu erschlagen wurde.
Mitte März 2021, also in etwa 3 Monaten wird das JDK 16 final erscheinen. Damit der Termin klappt, wird heute am 10. Dezember bereits die Rampdown Phase 1 gestartet, eine Art Feature Freeze. Von nun an werden nur noch Fehler behoben und keine Neuerungen mehr zugelassen. Das ist ein guter Zeitpunkt, einen Ausblick auf die geplanten Änderungen zu werfen. Tatsächlich ist Java 16 die letzte Major-Version vor dem nächsten Long Term Support (LTS) Release, welches als OpenJDK 17 im September 2021 erscheinen wird.
Die Liste der vorgeschlagenen JEPs (Java Enhancement Proposals) ist wieder relativ lang geworden:
Für Java-Entwickler ist natürlich nicht alles relevant, einige Features sind sehr speziell und betreffen eher Themen unter der Haube der Plattform. Und da im Herbst nächsten Jahres das JDK 17 als nächstes LTS-Release erscheinen wird, sind diesmal auch keine großen Überraschungen dabei. Vielmehr wird jetzt bereits begonnen, die ab dem JDK 12 erschienen Neuerungen und Preview-Funktionen zu stabilisieren, damit das JDK 17 dann für die nächsten drei Jahre gut dasteht. Und so taucht tatsächlich das ein oder andere Thema hier erneut wieder auf, welches wir bereits beim JDK 14 oder 15 kennengelernt haben.
Bereits mit Java 14 wurde das Pattern Matching for instanceof eingeführt. Es soll nun in Java 16 als JEP 394 finalisiert werden. Ein Pattern ist eine Kombination aus einem Prädikat, welches 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 Ziel-Datentypen. 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.
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 Java dadurch prägnanter und die Verwendung sicherer gemacht werden. Erzwungene Typumwandlungen werden vermieden und dafür implizit durchgeführt. Bereits der zweite Preview, welcher im JDK 15 erschienen ist, hat keine nennenswerten Änderungen mehr mit sich gebracht. Deswegen wird das Feature jetzt als JEP 394 finalisiert. In zukünftigen Java Versionen wird es das Pattern Matching dann auch für weitere Sprachkonstrukte geben, wie z. B. innerhalb der Switch Expressions.
Ebenfalls zum dritten Mal dabei sind die in Java 14 eingeführten Record-Datentypen. Mit dem JEP 395 sollen sie nun finalisiert werden. Es gab seit dem zweiten Preview (JDK 15) noch einige kleine Änderungen, die sich aus dem Feedback der letzten Monate ergeben haben. Bei den Records handelt es sich um eine eingeschränkte Form der Klassendeklaration, ähnlich zu den Enums. Entwickelt wurden Records im Rahmen des Projektes Valhalla. Es gibt gewisse Ähnlichkeiten zu Data Classes in Kotlin und Case Classes in Scala. Die kompakte Syntax könnte Bibliotheken wie Lombok in Zukunft obsolet machen. Die einfache Definition einer Person mit zwei Feldern kann man in betrachten.
public record Person(String name, Person partner ) {}
Eine erweiterte Variante mit einem zusätzlichen Konstruktor ist erlaubt. Dadurch lassen sich neben Pflicht- auch optionale Felder abbilden.
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.
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 Record-Typ instanziiert wird.
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()). Records 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.
Erst das zweite Mal dabei sind die Sealed Classes. Sie wurden in Java 15 als Preview-Feature eingeführt und verbleiben als JEP 397 auch im JDK 16 im Preview Status. Es gibt ein paar kleine Änderungen gegenüber der letzten Version und vermutlich werden sie dann im LTS Release OpenJDK 17 finalisiert. Bis dahin möchten die Macher weiteres Feedback einsammeln.
Dieses Feature wurde übrigens im Rahmen von Projekt Amber entwickelt und gehört zu einer Reihe von vorbereitenden Maßnahmen für die Umsetzung von Pattern Matching 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 Sub-Klassen oder -Interfaces von ihnen abgeleitet werden dürfen. Bisher konnte man als Entwickler Ableitung 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 nur 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 damit überhaupt existieren. In Zukunft werden Sealed Classes auch im Rahmen des Pattern Matchings bei Switch-Expressions eingesetzt werden können. Wenn man Subklassen einer Sealed Class in einem switch verwendet, kann bei Angabe aller abgeleiteten Typen in jeweils einem eigenen case-Zweig der Einsatz des default-Blocks entfallen. Durch die Information über alle erlaubten Subklassen kann der Compiler sicherstellen, dass mindestens einer der Zweig aufgerufen wird.
// 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 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 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 Sub-Klassen müssen im selben Modul existieren. Im Falle von Unnamed Modules müssen sie sogar im gleichen Package liegen. Außerdem muss jede erlaubte Sub-Klasse direkt von der Sealed Class ableiten. Die Sub-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 leider nicht möglich.
Außerdem gibt es eine Integration der Sealed Classes mit Records, wie das folgende, der Dokumentation entnonmmene Beispiel 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 von dem gleichen Sealed Interface ableiten. Die Kombination aus Records und versiegelten Datentypen führt uns zu algebraischen Datentypen, welche vor allem in funktionalen Sprachen wie Haskell zum Einsatz kommen. Konkret können wir jetzt mit Records Produkttypen und mit versiegelten Klassen Summentypen abbilden.
Bisher wurde die Quellen des OpenJDK in Mercurial verwaltet, einem nicht so gebräuchen Versionsverwaltungssystem. Dadurch war die Hürde für neue Entwickler größer, sich an der Entwicklung des JDK zu beteiligen. Im Rahmen des JEP 357 wurde der Sourcecode nun in ein Git Repo migriert und sogar noch nach Github umgezogen (JEP 369). Es gab dabei drei Hauptgründe für die Migration:
Bei den Metadaten kam es 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ässig unterstützt oder lässt sich leicht über ein Plugin erweitern. Git hat einfach den Markt für verteilte Versionsverwaltungssysteme in den letzten Jahren im Sturm erobert. Der Schritt, die Quellen des OpenJDK nach Git zu migrieren, ist also nachvollziehbar. Um alle relevanten Informationen wie die Historie und Tags mit zu übertragen, wurde eigens ein kleines Tool geschrieben. Dieses überführt die Mercurial Commit Messsages in das Git-Format.
Wer schon sehr lange in der Java-Welt unterwegs ist, wird noch das Java Native Interface (JNI) kennen. Damit kann man nativen C-Code aus Java heraus aufrufen. Der Ansatz ist aber relativ aufwändig und fragil. Die Foreign Linker API (JEP 389) bietet einen statisch typisierten reinen Java-basierten Zugriff auf nativen Code. Zusammen mit dem Foreign-Memory API (JEP 393) kann diese Schnittstelle den bisher fehleranfälligen Prozess der Anbindung einer nativen Bibliothek beträchtlich vereinfachen.
In Vorbereitung auf neue Features im Valhalla-Projekt werden im JEP 390 (Warnings for Value-Based Classes) nun bereits seit Java 9 bestehende Warnungen verschärft. So sind die Konstruktoren der primitiven Wrapper-Klassen (Integer, Boolean, …) nun deprecated for removal und könnten damit in einer der nächsten Versionen verschwinden. Der Hintergrund ist, dass diese wertbasierten Klassen (final, immutable, Vergleiche über equals und nicht per Identität) in Zukunft zu primitiven Klassen werden sollen. Und da soll man dann mit statischen Fabrikmethoden (z. B. Integer.valueOf()) arbeiten, anstatt selbst Instanzen über die Konstruktoren zu erzeugen. Diese im Moment unscheinbare Änderungen (Deprecation for removal) könnte in einem späteren JDK-Release bei der Migration spannend werden, wenn diese Konstruktoren tatsächlich wegfallen.
Neben vielen anderen, aber für Entwickler weniger relevanten Änderungen, wollen wir zum Abschluß noch einen Blick auf den JEP 392 werfen. Denn dort wird das in Java 14 als Inkubator-Projekt eingeführte Packaging Tool im JDK 16 nun zu einem produktionsreifen Feature. Man kann damit Java-Anwendungen inklusive Laufzeitumgebung in einem Installer verpacken, wobei die Paketformate für die jeweilige Zielplattform (z. B. *.msi oder *.exe unter Windows) angepasst sind. Eine mit dem Packaging Tool verpackter Installer enthält zwar keine GUI, kann aber über verschiedene Wege (Kommandozeile, programmatisch oder über die ToolProvider API) aufgerufen und über Aufrufparameter von außen konfiguriert werden.
Die Java Welt ist lebendiger denn je, in einem knappen Jahr kommt bereits das zweite LTS-Release nach der Umstellung auf das neue Lizenmodell und die halbjährlichen Releases. Alle, die im Moment noch auf das JDK 8 oder sogar noch ältere Versionen setzen, sollten unbedingt über eine Migration auf das aktuelle LTS Release 11 nachdenken. Weitere Updates, z. B. auf das aktuelle Java 15 oder Java 17 im Herbst 2021 sind dann keine allzu große Herausforderung mehr. Zudem bekommt man mit einem Update auf Java 11 oder höher einige Performance-Verbesserungen geschenkt, die aufgrund von Optimierungen bei der JVM und dem Garbage Collector automatisch mitkommen.
Für alle, die noch ein paar weitere Details zum aktuellen Stand von Java erfahren möchten, sei mein auf Jaxenter erschienener Artikel zu den Neuerungen in Java 14 und 15 empfohlen.