Der lise Blog:
Einsichten, Ansichten, Aussichten

FAQ zu Java 17

Java 17 ist erschienen und kann aktuell bereits von der Oracle-Website heruntergeladen werden. Downloadmöglichkeiten für andere Distributionen, wie beispielsweise von Adoptium sollten in Kürze folgen.

Ich beantworte dazu die wichtigsten Fragen aus der Community.
 

Was, schon Java 17? Bei uns im Projekt verwenden wir gerade einmal Java 11.

Ja, das hat schon seine Richtigkeit. Nach Java 9 hat man auf einen halbjährlichen Release-Zyklus umgestellt. Seit Java 11 sind also "erst" drei Jahre vergangen. Vor Java 9 war das oft eine normale Dauer zwischen zwei Releases.
 

OK, aber warum sollte mich Java 17 dann plötzlich interessieren?

Vor Java 9 wurden quasi alle Versionen über einen längeren Zeitraum supportet. Aber seit Java 9 gibt es zwei verschiedene Arten von Versionen - LTS und non-LTS. Non-LTS Versionen werden immer nur so lange unterstützt, bis die nächste Version erscheint (konkret also ein halbes Jahr). Entsprechend werden diese Versionen nicht produktiv eingesetzt.

LTS Versionen hingegen werden (genau wie die Versionen vor Java 9) über längere Zeiträume supportet. Wie lange genau, hängt von der jeweiligen Java-Distribution ab. Im Allgemeinen aber lange genug. Wir reden hier von sechs, sieben, teilweise acht Jahren und mehr. Gerade bei immer noch sehr verbreiteten Versionen können dies auch mal 12 Jahre sein. Und Java 17 ist wieder so eine LTS Version.
 

Puh, müssen wir denn jetzt schnell auf Java 17 umsteigen?

Nein, keine Panik. Java 11 wird noch mindestens bis 2024 supportet. Amazon hat aber z.B. schon angekündigt, dass sie ihre Java-Distribution ("Corretto") bis 2027 unterstützen. Selbst Java 8 noch bis 2026 (das ist die Version, die oben mit "sehr verbreitet" gemeint war).
 

Und was bietet mir Java 17 jetzt Neues?

Ich hoffe du hast etwas Zeit mitgebracht. Java 17 selbst enthält gegenüber Java 16 gar nicht so viel Neues. Da man aber, wie oben erläutert, normalerweise in einem Projekt von Java 11 auf Java 17 wechseln wird, ist praktisch alles neu, was in den Versionen 12 bis 17 enthalten war. Und das sind dann doch ein paar Dinge. Alles in allem enthält Java 17 nichts Revolutionäres, aber es gibt ein paar größere und kleinere neue Features. Einige davon bereiten auch größere, zukünftig geplante Features vor und sind vor diesem Hintergrund betrachtet noch einmal etwas spannender. Ich versuche im Folgenden einmal einen Überblick zu geben.
 

Text Blocks

 

String usage = """
        -s, --sort=PROPERTY
                sort the output by the given PROPERTY;
                can be "name", "date" or "type"

        -v, --version
                prints out the version of this program and exits

        -h, --help
                prints out this help text and exits
    """;

 

Aufbau

Wie in dem Beispiel (fast) zu sehen ist, wird ein Textblock mit drei Anführungszeichen und einer Newline eingeleitet. Die Newline ist wichtig und nicht optional. Abgeschlossen wird der Textblock ebenfalls mit drei Anführungszeichen. Hier ist die vorhergehende Newline allerdings optional. Die drei Anführungszeichen könnten im Beispiel also auch direkt hinter dem "exits" stehen. 
 

Sonderzeichen

Innerhalb des Textblocks können Escapesequenzen weiterhin normal verwendet werden. Für Newlines, Tabs und Anführungszeichen (wie man im Beispiel bei "name", "date" und "type" sieht) braucht man sie allerdings nicht mehr. 
Die Newlines, die oben im Code enthalten sind, stehen also auch genauso in der Variablen. Dies schließt im Beispiel auch die abschließende Newline ein. Wollte man diese nicht haben, müsste man die drei abschließenden Anführungszeichen direkt hinter das "exists" schreiben. Newlines sind in Textblöcken übrigens immer LF, auch unter Windows.
 

Einrückungen

Einrückungen bleiben prinzipiell im Text enthalten, allerdings auf intelligente Art und Weise. Es wird geschaut, welche Zeile des Textblocks am wenigsten weit eingerückt ist (dazu zählen auch die drei abschließenden Anführungszeichen, sofern diese in einer eigenen Zeile stehen).

Diese Einrückung wird dann für alle Zeilen entfernt. Leere Zeilen, sowie Zeilen die nur Leerzeichen oder Tabs enthalten, werden dabei ignoriert. Die leeren Zeilen im Beispiel sorgen also nicht dafür, dass gar keine Einrückung entfernt wird. Hier wäre stattdessen die abschließende Zeile am wenigsten weit eingerückt und es würden überall vier Leerzeichen entfernt. 

Die Zeilen "-s…", "-v…" und "-h…" wären in der Variablen also mit vier Leerzeichen eingerückt. Die Zeilen darunter insgesamt mit zwölf Leerzeichen.

 

Switch Expressions

 

String output = switch (parameter) {
    case "-s", "--sort" -> {
        PropertyComparator propertyComparator = new PropertyComparator(sortBy);
        yield files.stream().sorted(propertyComparator)
                .map(File::toString)
                .collect(Collectors.joining("\n"));
    }
    case "-v", "--version" -> "version 1.2.3";
    case "-h", "--help" -> usage;
    default -> throw new IllegalArgumentException("Unknown parameter."
            + " Use -h to show help.");
};

 

Pfeil-Schreibweise

Switch-Expressions ähneln den Switch-Blöcken, haben allerdings einen Rückgabewert, der auch verwendet werden muss. Es gibt eine kürzere Schreibweise die einen Pfeil anstelle des Doppelpunkts verwendet und an eine Lambda-Expression erinnert (diese wird auch im Beispiel verwendet). Bei dieser Schreibweise genügt es im einfachsten Fall, nur einen Wert hinter den Pfeil zu schreiben.

Dieser ist dann der Rückgabewert für diesen Fall. Blockklammern sind nicht nötig. Falls man doch mehrere Statements schreiben möchte, sind Blockklammern nötig. In diesem Fall muss der Rückgabewert auch mittels "yield" zurückgegeben werden (siehe case "-s" im Beispiel).
 

Doppelpunk-Schreibweise

Die Schreibweise mit Doppelpunk statt Pfeil ist aber auch bei Switch-Expressions weiterhin möglich. Hier muss dann jeder Wert allerdings mittels "yield" zurückgegeben werden. Eine Mischung der beiden Schreibweisen innerhalb einer Switch-Expression ist nicht erlaubt.
 

Und sonst?

Wie im Beispiel zu erahnen ist, kennen Switch-Expressions kein Fall-Through (egal ob mit oder ohne "yield" und egal in welcher Schreibweise). Es ist aber trotzdem möglich, mehrere Fälle hintereinander zu schreiben, bei denen das Gleiche passieren soll. Das Schlüsselwort "case" muss dafür nicht wiederholt werden, sondern die einzelnen Fälle werden einfach durch ein Komma getrennt.

Wie im Beispiel ebenfalls zu sehen, ist es, nach wie vor, möglich Exceptions in Switch-Expressions zu werfen. Eine letzte Besonderheit ergibt sich noch bei der Verwendung einer Switch-Expression mit Enums. Sofern alle Werte des Enums in der Switch-Expression vorkommen, kann der default Branch weggelassen werden.

 

Record classes

 

record File(String name, LocalDate date, String type, long size) {}

record PropertyComparator(String property) implements Comparator<File> {
    PropertyComparator {
        if (property == null || property.isEmpty()) {
            throw new IllegalArgumentException("Property must not be null" 
                    + " or empty.");
        }
    }

    @Override
    public int compare(File file1, File file2) {
        return switch (property) {
            case "name" -> file1.name().compareTo(file2.name());
            case "date" -> file1.date().compareTo(file2.date());
            case "type" -> file1.type().compareTo(file2.type());
            default -> throw new IllegalArgumentException("Property to sort"
                    + " does not exist.");
        };
    }
}

 

Was sind Records?

Records sind ein neues "Top-Level" Konstrukt in Java (neben Klassen, Interfaces, Enums, Annotations). Mit ihnen können einfache, immutable Datenklassen geschrieben werden. Ein Vorteil dabei ist, dass die Schreibweise sehr kompakt ist und viele Dinge vom Java-Compiler automatisch generiert werden. Im ersten Record aus dem Beispiel, würde der Compiler folgendes erzeugen:

  • die vier Felder name, date, type und size (alle sind private und final)
  • einen Konstruktor der Werte für die vier Felder entgegennimmt und diese den Feldern zuweist
  • "Getter" für die vier Felder, die den Namen der Felder als Methodennamen haben; also konkret name(), date(), type() und size()
  • die Methoden equals, hashCode und toString, die jeweils alle Felder des Records einbeziehen

Unterschiede und Gemeinsamkeiten zu Klassen

Records verhalten sich äußerlich wie Klassen sind aber z.B. via Reflection von Klassen zu unterscheiden. Auch sind Records nicht direkt von Object abgeleitet sondern zunächst von Record, welches dann wiederum von Object abgeleitet ist. Records können Interfaces implementieren (siehe zweiten Record im Beispiel) aber nicht von anderen Klassen oder Records ableiten. Andere Klassen können auch nicht von Record abgeleitet werden, da Records final sind.
 

Compact Constructor

Es ist möglich, Records einen "compact constructor" hinzuzufügen. Dieser ist dafür gedacht, um beispielsweise Validierungen durchzuführen. Es wird damit also kein weiterer Konstruktor hinzugefügt, sondern Logik implementiert, die dann später vor dem eigentlichen Konstruktor ausgeführt wird.
 

Annotationen

Annotationen die zu Klassen hinzugefügt werden können, sind auch an Records gültig. Des Weiteren gibt es ein neues Annotations-Ziel und zwar die "record components". Dies wären im ersten Beispiel name, date, type und size. Künftig kann man also gezielt Annotationen für diese Bestandteile eines Records schreiben.

 

Sealed Classes

 

public sealed interface TemplatingEngine
        permits HandlebarsTemplatingEngine,
        ThymeleafTemplatingEngine, GenericTemplatingEngine {
    String generateDocument(String filepath, String templateParams);
}

public final class HandlebarsTemplatingEngine implements TemplatingEngine {
    @Override
    public String generateDocument(String filepath, String templateParams) {
        //Handlebars implementation
    }
}

public final class ThymeleafTemplatingEngine implements TemplatingEngine {
    @Override
    public String generateDocument(String filepath, String templateParams) {
        //Thymeleaf implementation
    }
}

public non-sealed interface GenericTemplatingEngine extends TemplatingEngine {}

 

Für Classes und Interfaces

Auch wenn das Feature sealed classes heißt, gilt es genauso für Interfaces. Sealed classes erlauben es einem, genau zu definieren, wer von einer Klasse ableiten oder eben ein Interface implementieren darf. Dieses "wer" muss dabei exakt definiert werden. Dafür gibt es insgesamt drei neue Schlüsselwörter: sealed, permits und non-sealed. Aber der Reihe nach. 
 

Sealed und Permits

Die Schlüsselwörter sealed und permits gehören immer zusammen. Wenn man sealed bei einer Klasse oder einem Interface verwendet (Records sind hier ausgenommen, da diese immer final sind), muss man auch sagen, wer ableiten bzw. implementieren darf. Hierfür listet man hinter dem permits kommagetrennt Klassen, Records und Interfaces auf (man darf hier auch mischen). Wichtig dabei ist, dass diese auch zur Compile-Zeit bereits existieren und ihrerseits die "Verbindung" zur sealed class/interface durch ein extends bzw. implements herstellen. Zur Erinnerung: Records können nur implementieren, nicht ableiten.
 

Non-sealed

Wie oben erwähnt, gibt es noch ein weiteres Schlüsselwort im Zusammenhang mit sealed classes. Dieses lautet non-sealed und wird ausschließlich als Modifier für Klassen oder Interfaces verwendet, die in einem permits aufgelistet werden. Klassen/Interfaces die dort aufgelistet werden, müssen solch einen Modifier wählen. Dabei haben sie die Wahl zwischen final, sealed oder non-sealed. Hier ebenfalls zur Erinnerung: Records sind automatisch immer final und haben diese Wahl somit nicht.
 

Modifier-Bedeutung

Die Bedeutung von final ist dabei die gleiche wie vorher auch – Ableitungen sind nicht erlaubt. Bei sealed, gilt das gleiche wie hier beschrieben, man muss ebenfalls ein permits definieren und damit festlegen wer von ihnen ableiten bzw. sie implementieren darf. Durch ein non-sealed klinkt sich eine Klasse / ein Interface quasi aus dem ganzen sealed Mechanismus wieder aus. Ein Ableiten oder Implementieren ist bei dieser Klasse also generell für alle wieder erlaubt. Damit lassen sich explizite extension points schaffen, die beliebige Erweiterungen erlauben. Gleichzeitig hat man aber einen anderen Teil der Hierarchie, den man ganz genau kennt und kontrollieren kann. 
 

Nutzen

Sealed classes werden vermutlich durch zukünftige Erweiterungen von Java erst richtig mächtig und nützlich. Aktuell sind sie wohl höchstens für Entwickler von Libraries und Frameworks interessant.

 

Pattern Matching for instanceof

 

if (file instanceof TextFile textFile) {
    return "charCount=" + textFile.charCount();
}
if (file instanceof ImageFile imageFile) {
    return  "width=" + imageFile.width() + ", "
          + "height=" + imageFile.height();
}
if (file instanceof AudioFile audioFile) {
    return "duration=" + audioFile.duration();
}

public boolean equals(Object o) {
    return o instanceof TextFile t
            && name.equals(t.name)
            && date.equals(t.date)
            && size == t.size
            && charCount == t.charCount;
}

 

Verwendung

Oft hat man den Fall, dass man den Typ einer Variablen mit instanceof prüft, nur um diese direkt danach genau zu diesem Typ zu casten. Bisher musste man diesen cast immer explizit schreiben. Durch Pattern matching for instanceof kann man sich dies nun sparen. Man schreibt stattdessen einen neuen Variablennamen hinter den Typ, auf den man prüft. Diese Variable lässt sich innerhalb des folgenden Blocks aber auch bereits innerhalb der if-Bedingung verwenden. Dies gilt natürlich nur, wenn man z.B. weitere Bedingungen mit UND anfügt, sodass das instanceof auch wirklich true gewesen sein muss. Damit lassen sich künftig beispielsweise equals Methoden wie im zweiten Beispiel schreiben.
 

Kontext

Pattern matching wird voraussichtlich auch in zukünftigen Java-Versionen noch eine Rolle spielen. Insofern ist pattern matching for instanceof nur als ein erster (kleiner) Schritt zu sehen. Bereits in Java 17 als Vorschau enthalten ist pattern matching for switch, was aber hier nicht weiter betrachtet werden soll. 

 

Gibt's abseits der großen Themen noch weitere Neuerungen, von denen man gehört haben sollte?

Also neben vielen eher technischen Dingen, wie zwei neuen Garbage-Collectoren (Shenandoah und ZGC) und Verbesserungen an G1 (dem Standard Garbage-Collector), sowie den üblichen Performanceverbesserungen, gibt es vier Dinge auf die ich kurz näher eingehen würde. Wobei die letzten beiden lediglich zwei neue Methoden sind.
 

Hilfreichere NullPointerExceptions

 

if (customer.name().equals(bankAccount.owner())) {
    //do stuff
}

 

Wird im Beispiel eine NullPointerException in der Zeile der if-Bedingung geworfen, kommt man als Entwickler schnell ins Grübeln. Ist jetzt customer null oder name oder doch bankAccount? Kommt man durch Ausschlussverfahren nicht zu einem eindeutigen Ergebnis, hilft oft nur debuggen. Hat man dann nur den Stacktrace und keine weiteren konkreten Daten, ist man meist aufgeschmissen. Hier sind hilfreichere ("helpful") NPEs dann sehr nützlich. Konkret könnte die Meldung einer NPE für das obere Beispiel also wie folgt lauten: "Cannot invoke "de.example.BankAccount.owner()" because "bankAccount" is null". Hier ist dann also sofort klar, was null war.
 

jpackage

Bei jpackage handelt es sich um ein Tool, mit dem man Java-Anwendungen verpacken und dann auf einer Ziel-Plattform installieren kann. Dabei wird Java mit installiert und eingerichtet, sodass ein Nutzer die Anwendung einfach ausführen kann. Vermutlich in Zeiten von Browser-Apps weniger relevant, allerdings hatte Java lange Zeit unterschiedliche Lösungen für genau diesen Fall, die aber im Laufe der letzten Jahre alle deprecated wurden. Jpackage schließt diese Lücke jetzt, sodass es wieder eine Lösung für diesen Anwendungsfall gibt. Viel weiter will ich auch gar nicht darauf eingehen. Es ist vermutlich ausreichend davon einmal gehört zu haben.
 

Zwei neue nützliche Methoden


Stream.toList

 

//statt
List<File> textFiles = files.stream()
        .filter(file -> file.type().equals("text"))
        .collect(Collectors.toList());
//kann man nun folgendes schreiben
List<File> textFiles = files.stream()
        .filter(file -> file.type().equals("text"))
        .toList();

 

String.formatted

 

//statt
String fileInfo = String.format("Filename: %s, size: %d bytes",
        textfile.name(), textfile.size());
//kann man nun folgendes schreiben
String fileInfo = "Filename: %s, size: %d bytes"
        .formatted(textfile.name(), textfile.size());

 

Es gibt noch viele zusätzliche kleine Erweiterungen und Ergänzungen. Was davon letztlich relevant ist, ist sehr subjektiv. Wer sich eine vollständige Liste anschauen möchte, kann dies unter https://javaalmanac.io/jdk/17/apidiff/11/ tun.


Gibt’s auch Dinge, die entfernt wurden?

Ja, in der Tat gibt es die auch. Hier sind vor allem zwei wichtige Dinge zu nennen. 
 

Nashorn

Vielen ist vermutlich gar nicht bekannt, das Java seit langem eine JavaScript Laufzeitumgebung von Haus aus mitbringt. Zuerst war dies Rhino, später Nashorn. Nashorn gilt seit Java 11 bereits als deprecated und wurde dann mit Java 15 entfernt. Praktischerweise hat das OpenJDK-Team Nashorn als Standalone-Version aus Java herausgelöst und auf Github veröffentlicht (https://github.com/openjdk/nashorn). Im besten Fall, genügt es also, in einem Projekt, welches Nashorn verwendet, diese Dependency hinzuzufügen, um Java 17 verwenden zu können. 
 

Strong encapsulation by default

Für eine weitere Veränderung mit ein paar Konsequenzen muss man etwas ausholen. In Java ist es via Reflection seit Anfang an möglich, sehr stark auch in die Standard-Library einzugreifen. So kann man beispielsweise private final Felder zugreifbar machen und verändern. Gleiches gilt für private Methoden, die man auf diese Weise benutzen kann. Dies ist aus mehreren Gesichtspunkten nicht wünschenswert. Zum einen bringt es gewisse Sicherheits- und Zuverlässigkeitsprobleme mit sich (man betrachte z.B. die Beispiele hier https://riptutorial.com/java/example/17965).

Zum anderen erschwert es die Weiterentwicklung von Java bzw. schränkt die Kompatibilität ein. 2017 wurde mit Java 9 das Modulsystem eingeführt. Ein Ziel dabei war unter anderem, dass einzelne Module sehr genau definieren können, was sie nach außen freigeben und was nicht. Dies wurde für Java selbst ebenfalls sehr umfassend genutzt. Um kompatibel zu bleiben, waren Zugriffe auf Java Interna aber nach wie vor erlaubt. Es wurde lediglich eine Warnung geloggt, die auf solch einen Zugriff hinwies und dies auch nur beim allerersten Zugriff eines Programms. Vielleicht hat man diese Warnung sogar schon einmal gesehen:

 

WARNING: An illegal reflective access operation has occurred
[...]
WARNING: All illegal access operations will be denied in a future release

 

Mit Java 16 wurde dieses geändert und der Zugriff wurde standardmäßig blockiert. Man konnte aber beim Programmstart ein Argument mitgeben, das den Zugriff weiterhin generell erlaubte (--illegal-access=permit). Mit 17 wurde dieses Argument dann ebenfalls entfernt. Es ist trotzdem noch möglich den Zugriff gezielt weiterhin zu ermöglichen. Dafür muss man dann aber sehr konkret bei Programmstart angeben, welche Zugriffe benötigt werden (--add-opens …).

Die "richtige" Lösung sollte also sein, nach neueren Versionen einer Dependency zu schauen bzw. den eigenen Code entsprechend anzupassen. Da diese Warnungen seit 2017 ausgegeben werden, sollte niemand mehr davon überrascht werden und die meisten Libraries und Frameworks ihren Code inzwischen angepasst haben.

 

Insbesondere der letzte Punkt klingt nach einer potenziell größeren Hürde. Sollte man dann auf Java 17 umsteigen?

Absolut richtig. Tja, wie so oft ist die Antwort hier wohl: Es kommt drauf an. Ich würde vermutlich generell zumindest ein paar Wochen warten und beobachten, ob sich größere Probleme herauskristallisieren. Libraries und Frameworks, die immer noch auf interne Java-Funktionalität zugreifen, dürften wohl in den nächsten Wochen einen etwas größeren Druck zu verspüren bekommen, dies zu ändern. 

Ansonsten kommt es darauf an, ob man sich unmittelbar größere Vorteile von den Features, die durch Java 17 dazukommen, verspricht. Wenn dies der Fall ist und man grundsätzlich seine Dependencies auf einem recht neuen Versionsstand hat, sollte nicht viel dagegensprechen.

Übrigens: Wir sind ständig auf der Suche nach neuen Kolleg:innen. Wer also Lust bekommen hat, die Neuerungen gemeinsam mit uns in der Praxis anzuwenden, dem empfehle ich unsere Jobbörse! 

Zur Jobbörse

 

 

Diesen Artikel weiterempfehlen

 Teilen  Teilen  Teilen  Teilen