Kleinvieh macht auch Mist

Java 23 im Überblick: Kein großer Wurf, aber interessante Neuerungen

Java 23 im Überblick: Kein großer Wurf, aber interessante Neuerungen

Kleinvieh macht auch Mist

Java 23 im Überblick: Kein großer Wurf, aber interessante Neuerungen


Und wieder ist ein halbes Jahr vergangen und die nächste Version des OpenJDK steht bereit. Die großen Neuerungen sind diesmal ausgeblieben. Hauptsächlich werden die bestehenden Preview-Features auf Wiedervorlage gelegt, z. B. die Stream Gatherers, Implicitly Declared Classes and Instance Main Methods sowie Flexible Constructor Bodies. Und ganz überraschend wurde mit den String Templates ein bereits bestehendes Preview-Feature auf Eis gelegt [1].

Das vorläufige Entfernen der String Templates deprimiert viele, war es doch eines der interessantesten neuen Features der vergangenen Jahre. Es zeigt aber auch, dass der bestehende Prozess mit den Inkubator- und Preview-Phasen funktioniert. Die Macher des JDK haben sich aufgrund des Feedbacks zu den JEPs 430 und 459 entschieden, die Umsetzung der String-Interpolation in Java noch mal komplett zu überdenken. Um bei uns Entwicklern keine falschen Erwartungen zu schüren und sich aber auch nicht zu sehr stressen zu lassen, wurde das Feature kurzerhand ganz entfernt. Aber mit dem Ziel, es nach einer wohlüberlegten Überarbeitung bald wieder zurückkehren zu lassen. Wann das sein wird, ist momentan noch unklar. Javas Language Architect Brian Goetz sagt dazu immer süffisant: „Es ist fertig, wenn es fertig ist.“ Aber vielleicht bekommen wir die String Templates in abgewandelter Form bereits in sechs oder zwölf Monaten mit Java 24 oder 25 zurück. Bis dahin spricht übrigens nichts dagegen, sie weiter zu testen, solange ihr noch auf Java 21 oder 22 unterwegs seid. Und gerade, wenn man immer nur vom letzten zum nächsten LTS-Release wechselt, steht dem Einsatz der String Templates nichts im Wege, da sie hoffentlich in Java 25 wieder an Bord sein werden. Und vermutlich wird es dann durch Tools (IntelliJ IDEA Refactoring oder eine OpenRewrite Rule) sogar automatisierte Codemigrationen geben.

Aber weinen wir nicht dem vorerst verschwundenen Feature nach. Werfen wir lieber einen Blick darauf, was sich im OpenJDK 23 alles getan hat. Mit den Primitive Types in Patterns, Module Import Declarations und den Markdown Documentation Comments gibt es neben den vielen wiedervorgelegten JEPs doch auch drei interessante Neuerungen. Insgesamt wurden wieder zwölf JDK Enhancement Proposals (JEPs) umgesetzt [2]:

  • 455: Primitive Types in Patterns, instanceof, and switch (Preview)

  • 466: Class-File API (Second Preview)

  • 467: Markdown Documentation Comments

  • 469: Vector API (Eighth Incubator)

  • 473: Stream Gatherers (Second Preview)

  • 471: Deprecate the Memory-Access Methods in sun.misc.Unsafe for Removal

  • 474: ZGC: Generational Mode by Default

  • 476: Module Import Declarations (Preview)

  • 477: Implicitly Declared Classes and Instance Main Methods (Third Preview)

  • 480: Structured Concurrency (Third Preview)

  • 481: Scoped Values (Third Preview)

  • 482: Flexible Constructor Bodies (Second Preview)

Starten wir zunächst mit dem Langläufer der letzten Jahre, dem Vector API. Es ist nun schon das achte Mal als Inkubator enthalten und taucht seit Java 16 regelmäßig in den Releases auf. Es geht dabei um die Unterstützung der modernen Möglichkeiten von SIMD-Rechnerarchitekturen mit Vektorprozessoren. Single Instruction Multiple Data (SIMD) lässt viele Prozessoren gleichzeitig unterschiedliche Daten verarbeiten. Durch die Parallelisierung auf Hardwareebene verringert sich beim SIMD-Prinzip der Aufwand für rechenintensive Schleifen.

Der Grund für die lange Inkubationsphase des Vector API wird in den Zielen des JEP 469 [3] erklärt: „Alignment with Project Valhalla – the long-term goal of the Vector API is to leverage Project Valhalla’s enhancements to the Java object model. Primarily this will mean changing the Vector API’s current value-based classes to be value classes so that programs can work with value objects, i. e., class instances that lack object identity.“

Man wartet also auf die Reformen am Typsystem. Aktuell ist es zweigeteilt in primitive und Referenztypen (Klassen). Die primitiven Datentypen wurden ursprünglich aus Gründen der Performanceoptimierung eingeführt, haben aber im Handling entscheidende Nachteile. Referenztypen sind auch nicht immer die beste Wahl, insbesondere was die Effizienz und den Speicherverbrauch angeht. Es braucht etwas dazwischen, das sich so schlank und performant wie primitive Datentypen verhält, aber auch die Vorzüge von selbst zu erstellenden Referenztypen in Form von Klassen kennt.

Schon bald könnten daher aus dem Inkubatorprojekt Valhalla die Value Types (haben keine Identität) und Universal Generics (List<int>) ins JDK übernommen werden. Für Java 23 hat es der JEP 401 (Value Classes and Objects) leider wieder nicht geschafft (aber am Ende dieses Artikels gibt es noch gute Neuigkeiten). Dementsprechend werden wir das Vector API wohl auch noch einige Releases lang als Inkubator- bzw. dann hoffentlich bald als Preview-Feature wiedersehen. Diesmal gab es keine Änderungen am API und im Vergleich zum JDK 22 nur minimale Änderungen an der Implementierung.

Neuer Baustein beim Pattern Matching

Ebenfalls schon lange in der Entwicklung ist das Pattern Matching. Hier wurden immer wieder Teile abgeschlossen, zuletzt in Java 22 die Unnamed Variables & Patterns (JEP 456). Neu hinzugekommen sind die Primitive Patterns. Damit können wir nun primitive Datentypen in Type Patterns in switch- oder instanceof-Vergleichen nutzen.

Beim Pattern Matching geht es darum, bestehende Strukturen mit Mustern abzugleichen, um komplizierte Fallunterscheidungen effizient und wartbar implementieren zu können. Ein Pattern ist dabei eine Kombination aus einem Prädikat (das auf die 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 die Destrukturierung von Datenobjekten, also das Aufspalten in die Bestandteile und das Zuweisen in einzelne Variablen zur weiteren Bearbeitung. Mit instanceof und switch können wir also überprüfen, ob ein Objekt von einem bestimmten Typ ist, und wenn ja, dieses Objekt einer Variable dieses Typs zuweisen und diese im folgenden Programmpfad benutzen. Das funktionierte bisher aber nur mit Objekten und ließ sich nicht mit primitiven Datentypen kombinieren. Einzig in switch ließen sich bereits Variablen der primitiven Typen byte, short, char sowie int gegen Konstanten matchen und konnten sogar mit den neueren Type Patterns kombiniert werden (Listing 1).

Listing 1: Variablen mit primitiven Datentypen

int grade = 7;
String result = switch (grade) {
  case 1, 2 -> "very good or good";
  case 3, 4 -> "satisfactory or sufficient";
  case 5, 6 -> "poor or deficient";
  case Integer i -> "Undefined grade: " + i;
};
System.out.println(result);

Mit dem JEP 455 kommen nun zwei Neuerungen. Einerseits dürfen beim Pattern Matching jetzt alle primitiven Datentypen verwendet werden, also auch boolean, long, float und double. Und die Prüfung auf primitive Datentypen ist sowohl bei instanceof als auch bei switch erlaubt. Bei der moderneren Switch Expression gilt es zu beachten, dass sie exhaustive sein, also alle möglichen Fälle abdecken muss. In Listing 1 wird das über den Fall Integer i abgefangen, alternativ hätte es auch ein Defaultzweig getan.

Primitive Datentypen verhalten sich anders beim Pattern Matching, denn im Gegensatz zu Referenztypen unterstützen sie beispielsweise keine Vererbung. Wenn eine Variable x eines primitiven Typs (boolean, byte, short, int, long, float, double oder char) auf einen bestimmten primitiven Typ y geprüft wird, dann ergibt x instanceof y immer dann true, wenn der präzise Wert von x auch in einer Variablen vom Typ y gespeichert werden kann.

In Listing 2 wird untersucht, ob der Wert des Parameters value vom Typ byte ist und der byte-Variablen b zugewiesen werden kann. Alternativ wird ein Fehler angezeigt. Da der Wertebereich von byte von -128 bis +127 geht, ist der Parameter beim ersten Aufruf noch vom Typ byte, beim zweiten knapp nicht mehr.

Listing 2: Prüfung auf primitiven Datentyp

private static String checkByte(int value) {
  if (value instanceof byte b) {
    return "byte b = " + b;
  } else {
    return "kein byte: " + value;
  }
}

System.out.println(checkByte(127)); // b = 127
System.out.println(checkByte(128)); // kein byte: 128

Wie bei Objekttypen dürfen sich auch bei primitiven Typen direkt beim instanceof-Check weitere Prüfungen mit && anschließen. Der Code in Listing 3 filtert beispielsweise die positiven byte-Werte (1 bis 127) heraus.

Listing 3: Guarded Patterns

int value = -128;
if (value instanceof byte b && b > 0) {
  System.out.println("positive byte value: " + b);
} else {
  System.out.println("negative or not of type byte";
}

Nach der Prüfung auf einen primitiven Typ und der Zuweisung findet möglicherweise eine Typkonvertierung statt. Dadurch lassen sich Ganzzahlen auch in Gleitkommazahlen (mit Nachkommastellen) oder Characters (basierend auf dem Ascii-Code) umwandeln (Listing 4). Die Konvertierung in boolean ist allerdings nicht erlaubt, der Check wird vom Compiler abgelehnt. Ein boolean darf also nur mit einem boolean verglichen werden. Allerdings ergibt der Abgleich einer boolean-Variable mit dem Typ boolean sowieso immer true.

Listing 4: Konvertierung primitiver Datentypen

int value = 97;

if (value instanceof double d) {
  println(value + " instanceof double: " + d); // 97.0
}
if (value instanceof char c) {
  println(value + " instanceof char:   " + c); // "a"
}
// if (value instanceof boolean b) { .. } => Compile error

Aufgrund der unterschiedlichen Genauigkeit der primitiven Gleitkommatypen kann das zu interessanten Konstellationen führen, wenn ein bestimmter int-Wert als double, aber nicht als float darstellbar ist. Gleitkommazahlen ohne richtige Nachkommastelle (z. B. 5,0) sind übrigens auch in Ganzzahlen konvertierbar, mit Nachkommastellen (z. B. 5,5) matchen sie hingegen nur auf Gleitkommatypen.

Die primitiven Type Patterns können natürlich auch in einem switch verwendet werden. Auch hier sind Guarded Patterns (mit when) möglich. Zudem muss ggf. die Vollständigkeit der case-Zweige beachtet werden. Sind nicht alle möglichen Fälle abgedeckt, erwartet der Compiler einen Defaultzweig. Durch eine möglicherweise falsche Reihenfolge könnten case-Zweige zudem ignoriert werden, weil dominierende darauffolgende dominierte Typen überdecken. Eine Zeile mit case int dürfte nicht vor einer Zeile mit case byte stehen, weil die zweite Zeile dann nie aufgerufen werden würde. Netterweise gibt der Compiler für diesen Fall einen Fehler aus: „This case label is dominated by a preceding case label.“

Ganze Module importieren

Um in unseren Anwendungen andere Klassen verwenden zu können, müssen wir sie zuerst importieren. Das kann sowohl für einzelne Klassen (import java.util.Date) als auch ganze Pakete (import java.util.*) geschehen. Und schon seit Java 1.0 werden automatisch alle Klassen des Pakets java.lang importiert. Deshalb können wir z. B. die Klassen String, Integer, Exception oder Thread ohne separate import-Anweisung verwenden. Der JEP ermöglicht nun auch den kompletten Import von Modulen. Genaugenommen werden alle Klassen importiert, die in dem von dem Modul exportierten Paketen liegen. Das basiert auf dem in Java 9 eingeführten Plattform-Modul-System (JPMS). Übrigens muss die Klasse, die Module importiert, nicht selbst Teil eines Moduls sein. Der folgende Code zeigt den Import des Moduls java.base:

import module java.base;

Interessant wird es bei Namenskonflikten wie der Klasse Date (java.util.Date und java.sql.Date). Der Compiler stört sich zunächst nicht daran, wenn wir zwei Module importieren, die gleichnamige Klassen in unterschiedlichen Paketen enthalten. Bei der Verwendung der Klasse Date wird es dann aber zu einem Fehler kommen. Am einfachsten lässt sich das umgehen, indem die entsprechende Klasse zusätzlich importiert wird (Listing 5). Andernfalls wird der Compiler melden: „Reference to Date is ambiguous.“

Listing 5: Namenskonflike auflösen

import module java.base;
import module java.sql;
import java.util.Date;

public class ModuleImports {
  public static void main(String[] args) {
    System.out.println(new Date());
  }
}

Bei transitiven Importen von Modulen können auch alle Klassen der exportierten Pakete des transitiv importierten Moduls ohne expliziten Import verwendet werden. Beispielsweise benötigt das Modul java.sql transitiv java.xml. Dann können beim Import des Moduls java.sql auch Klassen wie SAXParser oder DocumentBuilder aus java.xml einfach direkt verwendet werden.

In zwei Fällen werden, ähnlich dem automatischen Import des Pakets java.lang, zukünftig auch einfach alle exportierten Klassen des ganzen Moduls java.base importiert. Denn sowohl bei der JShell als auch bei den implizit deklarierten Klassen soll den Entwicklern die Arbeit vereinfacht werden. Bei der JShell kann man sich das sogar anzeigen lassen. Da es sich im Moment noch um ein Preview-Feature handelt, muss die JShell aber mit dem Parameter --enable-preview gestartet werden (Listing 6). Andernfalls werden alle Pakete angezeigt, die JShell bis Java 22 automatisch importiert hat.

Listing 6: Automatische Modul-Importe in JShell anzeigen

> jshell --enable-preview
|  Welcome to JShell -- Version 23-ea
|  For an introduction type: /help intro

jshell> /imports
|  import java.base

Markdown in JavaDoc

Zwar kein Sprachfeature, aber trotzdem ganz nützlich sind die Markdown Documentation Comments (JEP 467). Bisher konnte man JavaDoc-Kommentare nur mit HTML formatieren. Das ist nicht mehr zeitgemäß, heute sind leichtgewichtige Auszeichnungssprachen wesentlich beliebter. Listing 7 zeigt einen klassischen JavaDoc-Kommentar für eine fiktive Methode. Er enthält Verweise auf Code und Datentypen sowie Absätze, Aufzählungen und die typischen spezifischen Angaben wie @param und @throws.

Listing 7: Klassischer JavaDoc-Kommentar mit HTML-Formatierung

/**
  * Returns the ... of {@code int} and {@code java.lang.String} arguments.
  * <p>
  * Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam
  * nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam
  * erat, sed diam voluptua.
  *
  * <p>
  * At vero eos et accusam et justo duo dolores et ea rebum.
  * <ul>
  *   <li>foo</li>
  *   <li>bar</li>
  * </ul>
  * <p>
  * For examples, see {@link #higherThanB(int, java.lang.String)}.
 *
  * @param x the first number as {@code int}
  * @param y the second number as {@code java.lang.String}
  * @return the higher value between {@code x} and {@code y} (converted to {@code int}
  * @throws NumberFormatException if {@code y} contains no number
  * @see #higherThanB(int, java.lang.String)
  * @since 7.5
  */
public static int higherThanA(int x, String y) {
  ..
}

Um Markdown verwenden zu können, benötigen wir am Zeilenanfang einen anderen Delimiter (///) und können dann auf die typische Markdown-Syntax zurückgreifen (Listing 8). Die Formatierungsregeln reichen von fett über kursiv, Absätze, Listen, einfache Tabellen, Inline-Code, Codeblöcke bis zu allen Arten von Verweisen (inklusive Referenzen auf Module, Packages, Klassen, Datentypen, Felder und Methoden). Und es werden natürlich weiterhin die JavaDoc-spezifischen Tags wie @param und @throws unterstützt, solange sie sich nicht innerhalb eines Markdown-Code-Blocks befinden.

Listing 8: JavaDoc mit Markdown-Formatierungen

/// Returns the ... of `int` and `java.lang.String` arguments.
///
/// Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam
///  nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam
///  erat, sed diam voluptua.
///
/// At vero eos et accusam et justo duo dolores et ea rebum.
/// - foo
/// - bar
///
/// For examples, see [{@link ]#higherThanB(int, java.lang.String)].
///
/// @param x the first number as `int`
/// @param y the second number as `java.lang.String`
/// @return the higher value between `x` and `y` (converted to `int`)
/// @throws NumberFormatException if `y` contains no number
/// @see #higherThanA(int, java.lang.String)
/// @since 7.5
public static int higherThanB(int x, String y) {
  ...
}

Java-Bytecode analysieren und manipulieren

Das Class-File API (JEP 466) ermöglicht das Lesen und Schreiben von class-Dateien, also von kompiliertem Bytecode. Sowohl das JDK selbst als auch viele Bibliotheken und Frameworks haben für diese Aufgabe bisher auf ASM gesetzt, ein universelles Java-Bytecode-Manipulations- und Analyse-Framework [4]. Es kann sowohl zum Modifizieren existierender als auch zum dynamischen Generieren von Klassen im Binärformat verwendet werden. Neben dem OpenJDK kommt ASM unter anderem auch beim Groovy- und Kotlin-Compiler, einigen Test-Coverage-Tools (Cobertura, Jacoco) und Build-Management-Werkzeugen (Gradle) zum Einsatz. Mockito verwendet es indirekt, um per Byte Buddy Mock-Klassen zu generieren. Aufgrund der kürzeren Releasezyklen des OpenJDK kann ASM aber nicht gut mit den Änderungen des Bytecodes mithalten. Das führt dann wiederum zu Abhängigkeiten und somit können die oben genannten Tools und Frameworks nicht schnell genug mit neuen OpenJDK-Releases umgehen. Mit der Entwicklung des JDK-internen Class-File API sollen solche Abhängigkeiten minimiert werden.

ASM hat bestehenden Bytecode auf Basis des Visitor Pattern analysiert und modifiziert. Dieses Pattern ist aber sperrig und unflexibel. Es ist ein Workaround für die fehlende Unterstützung von Pattern Matching. Da Java das mittlerweile unterstützt, kann der notwendige Code im neuen Class-File API direkter und konsistenter ausgedrückt werden. Listing 9 zeigt ein Beispiel, das auch im JEP 466 beschrieben ist.

Listing 9: Klassen parsen mit Pattern Matching

CodeModel code = ...
Set<ClassDesc> deps = new HashSet<>();
for (CodeElement e : code) {
  switch (e) {
    case FieldInstruction f  -> deps.add(f.owner());
    case InvokeInstruction i -> deps.add(i.owner());
    ... and so on for instanceof, cast, etc ...
  }
}

Klassen werden im Gegensatz zum Visitor-Ansatz mit Buildern erzeugt. Um beispielsweise eine Methode foobar (Listing 10) zu erzeugen, kann das ebenfalls aus dem JEP 457 entnommene Codebeispiel verwendet werden (Listing 11).

Listing 10: Zu erzeugende Methode

void fooBar(boolean z, int x) {
  if (z)
    foo(x);
  else
    bar(x);
}

Listing 11: Methode aus Listing 10 erzeugen

CodeBuilder classBuilder = ...;
classBuilder.withMethod("fooBar",  
  MethodTypeDesc.of(CD_void, CD_boolean, CD_int), 
  flags,
  methodBuilder -> methodBuilder
    .withCode(codeBuilder -> {
      codeBuilder
        .iload(codeBuilder.parameterSlot(0))
        .ifThenElse(
          b1 -> b1.aload(codeBuilder.receiverSlot())
                  .iload(codeBuilder.parameterSlot(1))
                  .invokevirtual(
                    ClassDesc.of("Foo"), 
                    "foo",
                    MethodTypeDesc.of(CD_void, CD_int)),
          b2 -> b2.aload(codeBuilder.receiverSlot())
                  .iload(codeBuilder.parameterSlot(1))
                  .invokevirtual(
                    ClassDesc.of("Foo"), 
                      "bar",
                       MethodTypeDesc.of(
                         CD_void, CD_int))
        .return_();
});

Auch existierender Code kann verändert werden. Listing 12 zeigt beispielhaft, wie bei einer bestehenden Klasse alle Methoden gelöscht werden, die mit debug beginnen.

Listing 12: Bestehende Klasse transformieren

ClassFile cf = ClassFile.of();
ClassModel classModel = cf.parse(bytes);
byte[] newBytes = 
  cf.build(
    classModel.thisClass().asSymbol(),
    classBuilder -> {
      for (ClassElement ce : classModel) {
        if (!(ce instanceof MethodModel mm
          && mm.methodName().stringValue()
            .startsWith("debug"))) {
              classBuilder.with(ce);
            }
      }
  });

Im Gegensatz zum OpenJDK 22 wurden in dieser zweiten Preview einige Verbesserungen vorgenommen. So wurden unter anderem

  • das API der Klasse CodeBuilder verschlankt,

  • Performanceoptimierungen in der Klasse Attributes vorgenommen (Zugriff auf AttributeMapper nun per statischen Methoden statt Feldern, um für schnellere Startzeiten eine verzögerte Initialisierung zu ermöglichen),

  • Datentypen konsistenter modelliert (Signature.TypeArg ist nun ein algebraischer Datentyp),

  • die Fehlerbehandlung beim Lesen von Einträgen in ClassReader bzw. ConstantPool optimiert,

  • die Klasse ClassSignature verbessert (akkuratere Behandlung der generischen Signaturen von Superklassen und -Interfaces),

  • unnötige Implementierungsmethoden von ClassReader entfernt und

  • Namensinkonsistenzen in der Klasse TypeKind behoben.

Vermutlich werden nur wenige Java-Entwicklerinnen und -Entwickler direkt mit dem Class-File API arbeiten. Trotzdem schadet es nicht, sich mit der Funktionsweise unter der Haube zu beschäftigen. Und es wird vor allem im JDK die Arbeiten an neuen Features beschleunigen und auch die Weiterentwicklung des Tool-Supports vereinfachen. Wer mehr wissen möchte, findet alle Details im JEP 466 [5].

Erneute Preview der Stream Gatherers

Das Stream API wurde in Java 8 eingeführt. Ein Stream wird erst bei Bedarf ausgewertet und kann potenziell eine unbeschränkte Anzahl von Werten enthalten. Sie können sequenziell oder parallel verarbeitet werden. Eine Stream Pipeline besteht typischerweise aus drei Teilen, der Quelle, ein oder mehreren Intermediate-Operationen und einer abschließenden Operation. Listing 13 zeigt ein Beispiel.

Listing 13: Verschiedene Stufen der Stream Pipeline

var number = Arrays.asList("abc1", "abc2", "abc3").stream() // Quelle
  .skip(1) // 1. Intermediate Operation
  .map(element -> element.substring(0, 3)) // 2. Intermediate Operation
  .sorted() // 3. Intermediate Operation
  .count(); // Terminal Operation
System.out.println(number);

Im JDK gibt es eine begrenzte Anzahl vordefinierter Intermediate-Operationen wie filter, map, flatMap, mapMulti, distinct, sorted, peak, limit, skip, takeWhile und dropWhile. Immer wieder wird der Wunsch nach weiteren Methoden wie window oder fold laut. Aber anstatt nur genau die geforderten Operationen bereitzustellen, wurde ein API (Stream Gatherers) entwickelt und im JDK 23 erneut und ohne Änderungen als zweiter Preview bereitgestellt. Das API ermöglicht es sowohl JDK-Entwicklern als auch normalen Anwendern, beliebige Intermediate Operations selbst zu implementieren.

Es werden die folgenden Gatherer mitgeliefert, erreichbar über die Klasse java.util.stream.Gatherers (Listing 14 zeigt einige Beispiele):

  • fold: zustandsbehafteter n:1-Gatherer, baut ein Aggregat inkrementell auf und gibt dieses Aggregat am Ende zurück

  • mapConcurrent: zustandsbehafteter 1:1-Gatherer, der die übergebene Funktion nebenläufig für jedes Inputelement aufruft

  • scan: zustandsbehafteter 1:1-Gatherer, wendet eine Funktion auf dem aktuellen Zustand und dem aktuellen Element an, um das nächste Element zu erzeugen

  • windowFixed: zustandsbehafteter n:n-Gatherer, der Eingabeelemente in Listen vorgegebener Größe gruppiert

  • windowSliding: ähnlich wie windowFixed, nach dem ersten Rahmen wird der nächste Rahmen erzeugt, in dem das erste Element gelöscht wird und alle weiteren Werte nachrutschen

Listing 14: Mitgelieferte Gatherers

// will contain: Optional["12345"]
Optional<String> numberString = 
  Stream.of(1, 2, 3, 4, 5)
  .gather(
   Gatherers.fold(() -> "", (string, number) -> string + number)
  )
  .findFirst();
System.out.println(numberString);

// will contain: ["1", "12", "123"]
List<String> numberStrings = Stream.of(1, 2, 3).gather(
  Gatherers.scan(() -> "", (string, number) -> string + number)
  ).toList();
System.out.println(numberStrings);

// will contain: [[1, 2, 3], [4, 5, 6], [7, 8]]
List<List<Integer>> windows =
  Stream.of(1, 2, 3, 4, 5, 6, 7, 8).gather(Gatherers.windowFixed(3)).toList();
System.out.println(windows);

// will contain: [[1, 2], [2, 3], [3, 4], [4, 5]]
List<List<Integer>> windows2 =
  Stream.of(1, 2, 3, 4, 5).gather(Gatherers.windowSliding(2)).toList();
System.out.println(windows2);

// will contain: [[1, 2, 3], [2, 3, 4], [3, 4, 5]]
List<List<Integer>> windows3 =
  Stream.of(1, 2, 3, 4, 5).gather(Gatherers.windowSliding(3)).toList();
System.out.println(windows3); 

Werfen wir nun einen Blick auf den Aufbau eines Stream Gatherer. Er kann einen Status besitzen, sodass Elemente abhängig von den vorherigen Aktionen unterschiedlich transformiert werden können. Und er kann einen Stream auch vorzeitig terminieren, wie beispielsweise limit() oder takeWhile(). Der Gatherer startet mit einem optionalen Initializer, der den Status bereitstellen soll. Dann folgt der Integrator, der jedes Element des Streams verarbeitet und ggf. den Status aktualisiert. Dann folgen der optionale Finisher, der nach der Verarbeitung des letzten Elements aufgerufen wird und ggf. je nach Status weitere Elemente an die nächste Stufe der Stream-Pipeline sendet. Und zu guter Letzt kombiniert ein optionaler Combiner den Status parallel ausgeführter Transformationen.

Um eine eigene Implementierung zu schreiben, muss man vom Interface Gatherer ableiten und mindestens die Methode integrator() implementieren. Die anderen bringen eine Defaultimplementierung mit und sind daher optional (Listing 15).

Listing 15: Interface Gatherer

interface Gatherer<T, A, R> {
  default Supplier<A> initializer() {
    return defaultInitializer();
  };

  Integrator<A, T, R> integrator();

  default BinaryOperator<A> combiner() {
    return defaultCombiner();
  }

  default BiConsumer<A, Downstream<? super R>> finisher() {
    return defaultFinisher();
  };
  [...]
}

Wenn man den Gatherer windowFixed noch einmal implementieren möchte, würde es wie in Listing 16 aussehen. Das Beispiel ist aus der Beschreibung des JEP 473 [6] übernommen.

Listing 16: Verschiedene Stufen der Stream-Pipeline

record WindowFixed<TR>(int windowSize)
  implements Gatherer<TR, ArrayList<TR>, List<TR>> {

  public WindowFixed {
    // Validate input
    if (windowSize < 1)
      throw new IllegalArgumentException("window size must be positive" );
  }

  @Override
  public Supplier<ArrayList<TR>> initializer() {
    // Create an ArrayList to hold the current open window
    return () -> new ArrayList<>(windowSize);
  }

  @Override
  public Integrator<ArrayList<TR>, TR, List<TR>> integrator() {
    // The integrator is invoked for each element consumed
    return Gatherer.Integrator.ofGreedy((window, element, downstream) -> {

      // Add the element to the current open window
      window.add(element);

      // Until we reach our desired window size,
      // return true to signal that more elements are desired
      if (window.size() < windowSize)
        return true;

        // When the window is full, close it by creating a copy
        var result = new ArrayList<TR>(window);

        // Clear the window so the next can be started
        window.clear();

        // Send the closed window downstream
        return downstream.push(result);

      });
    }

    // The combiner is omitted since this operation is intrinsically sequential,
    // and thus cannot be parallelized

    @Override
    public BiConsumer<ArrayList<TR>, Downstream<? super List<TR>>> finisher() {
      // The finisher runs when there are no more elements to pass from
      // the upstream
      return (window, downstream) -> {
        // If the downstream still accepts more elements and the current
        // open window is non-empty, then send a copy of it downstream
        if (!downstream.isRejecting() && !window.isEmpty()) {
          downstream.push(new ArrayList<TR>(window));
          window.clear();
        }
      };
    }
  }

// [[1, 2], [3, 4], [5]]
System.out.println(Stream.of(1,2,3,4,5).gather(new WindowFixed(2)).toList());

Vereinfachungen beim Starten von Java-Anwendungen

Programmierneulinge tun sich mit dem Einstieg in Java häufig schwer. Seit einiger Zeit gibt es aber immer wieder kleine Verbesserungen, die die ersten Schritte erleichtern. Bereits seit Java 9 wird die JShell mitgeliefert. In dieser REPL (Read Eval Print Loop) lassen sich sehr schnell kleine Programmierbeispiele ausprobieren, ohne sich mit dem Thema Kompilieren und Build-Management befassen zu müssen.

In Java 11 wurde „Launch Single-File Source-Code Programs“ eingeführt, wodurch einzelne Java-Dateien (mit Klassendeklaration und main-Methode) ohne separaten Kompilierungsschritt einfach von der Konsole aus gestartet werden können. Mit dem JEP 458 (Launch Multi-File Source-Code Programs) darf der Code seit dem OpenJDK 22 nun auch in beliebig vielen Java-Dateien strukturiert sein. Zusätzlich zu den Multi-Files kann auch Code aus JARs (die im Unterverzeichnis libs liegen) mittels --class-path 'libs/*' eingebunden werden.

In Java 21 kam mit dem JEP 445 (Unnamed Classes and Instance main Methods) ein Preview hinzu, um Java-main-Anwendungen viel schlanker und ohne überflüssigen Boilerplate-Code zu definieren. Somit lassen sich main-Methoden viel kompakter schreiben (ohne public, static, String[] args, ...). Außerdem müssen sie nicht mal mehr in eine Klassendefinition eingebettet sein. Gerade für Programmieranfänger, die in Java mit einem einfachen „Hello World“-Programm starten, stellte die bisherige Vorgehensweise eine übermäßige Hürde dar. Sie müssen bereits zu Beginn gleich mehrere in dem Moment nicht relevante Konzepte (Klassen, statische Methoden, Stringarrays usw.) verstehen. Im Zusammenhang mit dem oben erwähnten Launch Single- sowie Multi-File Source-Code Programs sinkt der Aufwand weiter.

Für Java 22 wurde das Konzept noch einmal überarbeitet, in Implicitly Declared Classes and Instance main Methods umbenannt und als zweite Preview (JEP 463) vorgelegt. Dabei gibt es jetzt keine Unnamed Classes mehr, vielmehr wird von der Laufzeitumgebung ein Name gewählt. Bei ersten Versuchen wurde der Name der Java-Datei (ImplicitMain.java => Klasse ImplicitMain) verwendet, das scheint für viele Distributoren der Default zu sein. Diese implizit deklarierten Klassen verhalten sich wie normale Top-Level-Klassen und benötigen nun auch keine zusätzliche Unterstützung mehr beim Tooling, bei der Verwendung von Bibliotheken oder in der Laufzeitumgebung. Die einzige Bedingung ist, dass die Datei im Root-Package liegen muss.

Im OpenJDK 23 ist jetzt die dritte Preview erschienen (JEP 477). Es gibt jetzt zusätzlich eine Klasse java.io.IO, deren drei öffentliche statische Methoden (print() , println() und readln()) automatisch in implizit deklarierte Klassen importiert werden. Und wie beschrieben werden auch alle anderen exportierten Klassen des Moduls java.base importiert.

Ein einfaches Beispiel inklusive des Kommandozeilenbefehls zeigt der folgende Code. Da es sich noch um ein Preview-Feature handelt, muss die Angabe zur aktuellen Java-Version erfolgen und die Preview-Funktion aktiviert werden.

// > java --source 23 --enable-preview  Main.java
void main() {
  println("Hello, World!");
}

Die implizit deklarierte Klasse darf sogar noch weitere Attribute/Felder und Methoden enthalten (Listing 17). Dadurch fühlt sich Java jetzt fast schon wie eine Skriptsprache an und es werden ganz neue Anwendungsfälle möglich. Wir können nun Shellskripte schreiben und profitieren dabei von dem mächtigen Funktionsumfang und der Typsicherheit Javas.

Listing 17: Zusätzliche Felder und Methoden mit der Instance-Main-Methode

// > java --source 23 --enable-preview Main.java

final String greeting = "Hello";

void main() {
  String name = readln("Your name: ");
  println(greet(name));
}

String greet(String name) {
  return greeting + ", " + name + "!";
}

Da es theoretisch mehrere main-Methoden in einer implizit deklarierten Klasse geben kann, braucht es eine Priorisierung beim Aufruf der Startprozedur. Die Logik dahinter hat sich seit der zweiten Preview deutlich vereinfacht. Es wird nur noch unterschieden, ob es einen Parameter vom Typ Stringarray gibt oder nicht. Wenn ja, dann hat diese Methode Vorrang. In Listing 18 wird die oberste main-Methode aufgerufen.

Der Compiler verbietet, dass es eine statische und eine Instanzmethode mit der gleichen Methodensignatur gibt. Die main-Methode mit dem Stringarray dürfte also auch static sein, aber es kann entweder nur eine statische oder eine Instanzmethode geben. Sichtbarkeitsattribute wie public, protected usw. werden an dieser Stelle ignoriert und der Compiler würde auch hier verhindern, dass es eine private und eine öffentliche Methode mit der gleichen Methodensignatur gibt.

Listing 18: Aufrufreihenfolge von main-Methoden in unbenannten Klassen

// Gewinner, da mit String[] args
void main(String[] args) {
  System.out.println("instance main with String[] args");
}

// nicht erlaubt, da es schon eine main-Methode mit der gleichen Signatur gibt
/* 
  static void main(String[] args) {
    System.out.println("static main with String[] args");
}
*/

void main() {
  System.out.println("instance main without String[] args");
}

Neuerungen im Umfeld von Virtual Threads

Virtual Threads waren mit ihrer Finalisierung die größte Änderung im OpenJDK 21. Sie erlauben es, die konkurrierende Verarbeitung parallel ausgeführter Aufgaben auch bei einer sehr großen Anzahl an Threads zu implementieren und dabei sogar gut verständlichen Code zu schreiben. Dieser lässt sich zudem wie sequenzieller Code mit herkömmlichen Mitteln debuggen. Die Virtual Threads verhalten sich dabei wie normale Threads, werden aber nicht 1:1 auf Betriebssystem-Threads abgebildet. Stattdessen gibt es einen Pool von Träger-Threads (Carrier Threads), denen virtuelle Threads vorübergehend zugewiesen werden. Sobald der virtuelle Thread auf eine blockierende Operation stößt, wird er vom Träger-Thread genommen, der dann einen anderen virtuellen Thread (einen neuen oder einen zuvor blockierten) übernehmen kann. Da Virtual Threads so leichtgewichtig sind, können sie jederzeit schnell erzeugt werden. Man muss also keine Threads wiederverwenden, sondern kann einfach immer neue instanziieren. An den Virtual Threads selbst hat sich nichts geändert.

Im Umfeld von Virtual Threads wurde mit dem OpenJDK 19 die Structured Concurrency eingeführt. Jetzt ist sie als dritte Preview (JEP 480) ohne Änderungen wieder mit dabei. Man möchte weiteres Feedback einsammeln. Bei der Bearbeitung mehrerer paralleler Teilaufgaben erlaubt Structured Concurrency die Implementierung auf eine besonders les- und wartbare Art und Weise. Bisher wurden für die Aufteilung in parallel zu verarbeitenden Aufgaben die Parallel Streams oder der ExecutorService eingesetzt. Letzterer ist sehr mächtig, macht aber auch einfache Umsetzungen ziemlich kompliziert und fehleranfällig. Es ist zum Beispiel schwer zu erkennen, wenn eine der Teilaufgaben einen Fehler produziert, um dann sofort alle anderen sauber abzubrechen. Wenn z. B. ein Task sehr lange läuft, erhält man erst spät Feedback, wenn andere Aufgaben auf Probleme gestoßen sind. Auch das Debuggen ist nicht einfach, da in den Thread Dumps die Tasks nicht den jeweiligen Threads aus dem Pool zugeordnet werden können.

Bei der Structured Concurrency ersetzen wir den ExecutorService durch einen StructuredTaskScope, bei dem man verschiedene Strategien (Listing 19 verwendet ShutdownOnFailure) auswählen kann. Dieses neue API macht nebenläufigen Code besser lesbar und kann zudem leichter mit Fehlersituationen umgehen.

Listing 19: Structured Concurrency

try (var scope = 
  new StructuredTaskScope.ShutdownOnFailure()) {

  Future<String> task1 = scope.fork(() -> { … }
  Future<String> task2 = scope.fork(() -> { … }
  Future<String> task3 = scope.fork(() -> { … }

  scope.join(); 
  scope.throwIfFailed();

  System.out.println(task1.get());     
  System.out.println(task2.get()); 
  System.out.println(task3.get());
}

Mit scope.join() wird gewartet, bis alle Tasks erfolgreich erledigt sind. Schlägt einer fehl oder wird abgebrochen, werden auch die anderen beiden beendet. Mit throwIfFailed() kann der Fehler außerdem weitergegeben werden und das Ausgeben der Ergebnisse wird übersprungen.

Dieser neue Ansatz bringt einige Vorteile. Zum einen bilden Task und Subtasks im Code eine abgeschlossene, zusammengehörige Einheit. Die Threads kommen nicht aus einem Thread-Pool mit schwergewichtigen Plattform-Threads, stattdessen wird jede Unteraufgabe in einem neuen virtuellen Thread ausgeführt. Bei Fehlern werden noch laufende Subtasks abgebrochen. Zudem sind bei Fehlersituationen die Informationen besser, weil die Aufrufhierarchie sowohl in der Codestruktur als auch im Stacktrace der Exception sichtbar ist.

Aufgrund des Preview-Status müssen für die Structured Concurrency beim Kompilieren und Ausführen aber weiterhin bestimmte Schalter aktiviert werden:

$ javac --enable-preview --source 23 StructuredConcurrencyMain.java 
$ java --enable-preview StructuredConcurrencyMain

Scoped Values

Ebenfalls im Umfeld der virtuellen Threads wurde im JDK 20 auch eine Alternative zu den ThreadLocal-Variablen vorgestellt. Die Scoped Values befinden sich weiterhin im Preview-Status (JEP 481). Es gab nur eine Änderung zur letzten Version: Die Methode getWhere() wurde entfernt und dafür callWhere() erweitert. Der Operation-Parameter verwendet nun ein neues Functional Interface, wodurch der Compiler schlussfolgern kann, ob eine CheckedException geworfen wird. Ansonsten will man auch hier weitere Erfahrungen und Feedback sammeln.

Die Scoped Values erlauben das Speichern eines temporären Werts für eine begrenzte Zeit, wobei nur der Thread, der den Wert geschrieben hat, ihn auch wieder lesen kann. Sie werden in der Regel als öffentliche statische (globale) Felder angelegt. Sie sind dann von beliebigen, tiefer im Aufrufstack befindlichen Methoden aus erreichbar. Verwenden mehrere Threads dasselbe ScopedValue-Feld, wird dieses je nach dem ausführenden Thread unterschiedliche Werte enthalten. Das Konzept funktioniert von der Idee her wie bei ThreadLocal, bringt aber einige Vorteile mit.

Im Beispiel in Listing 20 werden aus einem Web-Request Informationen zum angemeldeten Benutzer extrahiert. Auf diese Informationen muss in der weiteren Aufrufkette (im Service, im Repository …) zugegriffen werden. Eine Variante wäre das Durchschleifen des User-Objektes als zusätzlichen Parameter in die aufzurufenden Methoden. Scoped Values verhindern diese redundante und unübersichtliche Parameterauflistung. Konkret wird mit ScopedValue.where() das User-Objekt gesetzt und dann der run-Methode eine Instanz von Runnable übergeben, für dessen Aufrufdauer der Scoped Value gültig sein soll. Alternativ kann mit der call()-Methode ein Callable-Objekt übergeben werden, um auch Rückgabewerte auszuwerten. Der Versuch, den Inhalt außerhalb des Scoped-Value-Kontextes auszuführen, führt zu einer Exception, weil bei diesem Aufruf dann kein User-Objekt hinterlegt ist.

Das Auslesen des Scoped Value erfolgt über den Aufruf von get(). Bei Abwesenheit eines Wertes kann man mit einem Fallback (orElse()) oder dem Werfen einer Exception (orElseThrow()) reagieren.

Listing 20: Scoped Values

public final static ScopedValue<SomeUser> CURRENT_USER = 
  ScopedValue.newInstance();

[..]

SomeController someController = 
  new SomeController(
    new SomeService(new SomeRepository()));

someController.someControllerAction(
  HttpRequest.newBuilder()
    .uri(new URL( "http://example.com")
    .toURI()).build());

static class SomeController { 

  final SomeService someService;

  SomeController(SomeService someService) {
    this.someService = someService; 
  }

  public void someControllerAction(
    HttpRequest request) { 
    
    SomeUser user = authenticate(request);   
    ScopedValue.where(CURRENT_USER, user)
      .run(() -> someService.processService());
  }
}

static class SomeService { 
  final SomeRepository someRepository;

  SomeService(SomeRepository someRepository) {    
    this.someRepository = someRepository; 
  }

  void processService() {   
    System.out.println(CURRENT_USER
      .orElseThrow(() -> 
        new RuntimeException("no valid user")));
    }
  }
}

Auch hier müssen beim Kompilieren und Ausführen bestimmte Schalter aktiviert werden. Die Klasse ScopedValue ist immutable und bietet dementsprechend keine set-Methode an. Dadurch wird der Code besser wartbar, weil es keine Zustandsänderungen am bestehenden Objekt geben kann. Muss für einen bestimmten Codeabschnitt (Aufruf einer weiteren Methode in der Kette) ein anderer Wert sichtbar sein, kann ein Rebinding des Wertes erfolgen (z. B. auf null setzen wie in folgendem Code). Sobald der begrenzte Codeabschnitt beendet ist, wird der ursprüngliche Wert wieder sichtbar.

ScopedValue.where(CURRENT_USER, null)
  .run(() -> someRepository.getSomeData());

Scoped Values arbeiten auch mit der Structured Concurrency zusammen. Die Sichtbarkeit wird an die über einen StructuredTaskScope erzeugten Kindprozesse vererbt. Somit können alle per fork() abgezweigten Kind-Threads ebenfalls auf die im Scoped Value befindlichen User-Informationen zugreifen.

Die Scoped Values stehen genau wie die ThreadLocals sowohl für Plattform- als auch für virtuelle Threads zur Verfügung. Die Vorteile der Scoped Values sind vielfältig. Es erfolgt ein automatisches Aufräumen der Inhalte, sobald der Runnable-/Callable-Prozess beendet ist, so werden Memory Leaks verhindert. Die Immutability der Scoped Values erhöht zudem die Verständlichkeit und Lesbarkeit. Bei der Structured Concurrency erzeugte Kindprozesse haben auch Zugriff auf den einen unveränderbaren Wert. Die Informationen werden nicht wie bei InheritedThreadLocals kopiert (was zu höherem Speicherverbrauch führen kann).

Flexible Konstruktorinhalte

In Java 22 wurde diese Idee zunächst als „Statements before super()“ eingeführt. Im OpenJDK 23 wurden sie umbenannt zu Flexible Constructor Bodies und als zweiter Preview veröffentlicht (JEP 482). Dies ermöglicht in Konstruktoren das Einfügen von Codezeilen vor dem eigentlich in der ersten Zeile obligatorischen super()- oder this()-Aufruf. Der Grund für die bisherige Einschränkung ist, dass der Compiler sicherstellt, dass zunächst potenzielle Oberklassen vollständig initialisiert sein müssen, bevor der Zustand der Subklasse hergestellt oder abgefragt werden kann. Bei this() geht es zwar um eine Delegation an einen überladenen Konstruktor in der gleichen Klasse, aber der wird letztlich auch explizit oder implizit den super-Konstruktor der Oberklasse aufrufen.

Genau genommen gelten die strengen Anforderungen auch weiterhin. Die Statements, die man jetzt vor dem super()-Aufruf verwenden darf, dürfen nämlich erst mal nicht schreibend oder lesend auf die aktuelle Instanz der Superklasse zugreifen. Aber die Validierung oder die Transformation von Eingabeparametern sowie das Aufspalten eines Parameters in Einzelteile kann nun vor dem Aufruf von super() erfolgen (Listing 21).

Listing 21: Flexible Constructor Bodies

class Person {
  private final String firstname;
  private final String lastname;
  private final LocalDate birthdate;

  public Person(String firstname, String lastname, LocalDate birthdate) {
    this.firstname = firstname;
    this.lastname = lastname;
    this.birthdate = birthdate;
  }
}

class Employee extends Person {
  private final String company;

  public Employee(String name, LocalDate birthdate, String company) {
    if (company == null || company.isEmpty()) {
      throw new IllegalArgumentException("company is null or empty");
    }
    String[] names = name.split("\\s" );
    super(names[0], names[1], birthdate);
    this.company = company;
  }
}

System.out.println(new Employee("Dieter Develop", LocalDate.now(), "embarc"));

Bisher wurden dafür typischerweise ein überladener Hilfskonstruktor oder eine private Methode als Workaround benötigt. Das hat den Lesefluss gestört und die Implementierung unnötig komplex gemacht. Im Fall von Berechnungen auf den Inputparametern werden Werte zudem unnötigerweise mehrfach ermittelt. Beim Beispiel in Listing 21 müsste das Aufspalten in Vor- und Nachnamen zweimal erfolgen, um einmal das erste Element des split()-Aufrufs als ersten Parameter und dann das zweite Element aus der zweiten Berechnung als zweiten Parameter zu übergeben.

Alle Zeilen vor dem super()- oder this()-Aufruf werden Prolog genannt. Codezeilen nach bzw. ohne super() oder this() werden Epilog genannt. Im Prolog darf nicht lesend auf Felder der Klasse oder der Oberklasse zugegriffen werden, da diese noch nicht initialisiert sein könnten. Außerdem dürfen keine Instanzmethoden der Klasse aufgerufen und keine Instanzen von nicht statischen inneren Klassen erzeugt werden, da diese potenziell eine Referenz auf das noch nicht fertig initialisierte Elternobjekt halten können. Im Gegensatz dazu darf im Prolog des Konstruktors einer nicht statischen inneren Klasse auf Felder und Methoden der äußeren Klassen zugegriffen werden.

Auch bei Records und Enums sind Codezeilen vor this() nach den oben genannten Regeln erlaubt. Da sie nicht von anderen Klassen ableiten können, verwenden wir in dem Umfeld keine expliziten Aufrufe von super().

JEP 482 hat eine Änderung gebracht. Es dürfen jetzt im Prolog vor dem Aufruf von super() Instanzvariablen der Subklasse initialisiert werden. Das ist praktisch, wenn in der Subklasse eine Methode überschrieben wird, die in der Oberklasse deklariert und in deren Konstruktor aufgerufen wird und sowohl die Felder der Ober- als auch der Subklasse ausgeben will. Ohne die Möglichkeit, die Felder der Subklasse zu initialisieren, würden diese noch den Defaultwert (0, false oder null) enthalten und somit ein falsches Ergebnis liefern.

Was sonst noch geschah

Aufgrund der Verfügbarkeit von stabilen, sicheren und performanten Alternativen werden mit JEP 471 alle Methoden der Klasse Unsafe für den Zugriff auf On-Heap- und Off-Heap-Speicher in Java 23 als deprecated for removal gekennzeichnet und werden somit in einer zukünftigen Java-Version entfernt. Die Klasse sun.misc.Unsafe wurde bereits 2002 (Java 1.4) eingeführt. Sie bietet seit damals den direkten Zugriff auf Speicher, sowohl auf den Java Heap als auch auf nativen Speicher (nicht vom Heap kontrollierten). Aber wie der Name suggeriert, können die meisten dieser Methoden zu einem undefinierten Verhalten, zu Leistungseinbußen oder Systemabstürzen führen, wenn sie nicht korrekt eingesetzt werden. Eigentlich war diese Klasse auch nur für JDK-interne Zwecke gedacht. Aber durch das damals noch fehlende Modulsystem ließ sie sich nicht verstecken, zudem gab es keine Alternativen für die möglichst performante Implementierung bestimmter Operationen oder für den Zugriff auf größere Off-Heap-Speicherbereiche ab 2 GByte.

Heute existieren Alternativen, z. B. seit Java 9 die VarHandles. Sie ermöglichen den direkten und optimierten Zugriff auf On-Heap-Speicher, können sogenannte Memory Barriers setzen und stellen auch atomare Operationen wie Compare-and-Swap bereit. Und mit dem OpenJDK 22 wurde das Foreign Function & Memory API finalisiert, eine Schnittstelle zum Aufruf von Funktionen in nativen Libraries und zur Verwaltung von nativem, also Off-Heap-Speicher.

Die Entfernung läuft in vier Phasen ab:

  • Phase 1: Mit Java 23 kommt es bei der Verwendung zu Compilerwarnungen (da die Methoden als deprecated for removal markiert sind).

  • Phase 2: Mit Java 25 wird die Verwendung dieser Methoden voraussichtlich auch zu Laufzeitwarnungen führen.

  • Phase 3: Mit Java 26 werden die Methoden dann eine UnsupportedOperationException werfen.

  • Phase 4: Dann werden die Methoden entfernt, eine genaue Version wurde dafür aber noch nicht festgelegt.

Das Defaultverhalten kann für die jeweiligen Phasen durch die VM-Option --sun-misc-unsafe-memory-access überschrieben werden. JEP 471 [7] verrät im Abschnitt sun.misc.Unsafe memory-access methods and their replacements dazu weitere Details.

Neben diesen Deprecations wurde diesmal auch direkt Inhalte entfernt, nämlich die Methoden Thread.suspend()/resume(), ThreadGroup.suspend()/resume() sowie ThreadGroup.stop(). Bereits in Java 1.2 wurden diese für Deadlocks anfälligen Funktionen als deprecated markiert und in Java 14 bzw. 16 dann als deprecated for removal deklariert. Seit Java 19 bzw. 20 werfen sie mittlerweile eine Exception zur Laufzeit und nun wurden sie im OpenJDK endgültig entfernt.

In Java 21 wurde bei den alternativen Z Garbage Collectors (ZGC) der Generational Mode eingeführt. Dabei wird zwischen neuen und alten Objekten unterschieden. Die junge Generation enthält eher kurzlebige Objekte und wird häufiger bereinigt. Die alte Generation muss dagegen nur selten aufgeräumt werden. Bisher musste dieser Modus aktiviert werden, jetzt ist er default. Natürlich lässt er sich aber auch deaktivieren. Weitere Details zu den VM-Optionen lassen sich in JEP 474 (ZGC: Generational Mode by Default) nachschlagen [8].

Alle weiteren kleinere Neuerungen, für die es keine JEPs gibt, können in den Release Notes [9] nachgelesen werden. Änderungen am JDK (Java-Klassenbibliothek) kann man sich zudem sehr schön über den Java Almanac [10] anzeigen lassen. In dieser Übersicht lassen sich z. B. auch alle aktuellen Änderungen am Class-File API nachvollziehen und die neue Klasse java.io.IO mit den Methoden print(), println() und readln() entdecken.

Fazit

Auch wenn das Release zum OpenJDK 23 auf den ersten Blick eher unscheinbar wirkt, gibt es doch wieder einiges zu entdecken. Die halbjährlichen Releases halten für uns Java-Entwickler:innen also weiterhin viele Überraschungen bereit. In Java 23 kamen drei neue Themen und eine Menge an aktualisierten Preview-Features hinzu. Und die Ideen für die nächsten Funktionen gehen den JDK-Entwickler:innen nicht aus. Wer sich vorab über mögliche zukünftige Themen informieren möchte, kann sich schon mal im JEP-Index unter „Draft and submitted JEPs“ [11] umschauen.

Besonders interessant sind die kürzlich vorgestellten Null-Restricted and Nullable Types [12]. Dadurch können wir ähnlich wie bei Kotlin-Markierungen an Java-Typen festlegen, ob null-Werte erlaubt sind oder vom Compiler abgewiesen werden sollen. Und auf dem diesjährigen JVM Language Summit hat Brian Goetz angekündigt, dass sie beim Projekt Valhalla kurz vor dem finalen Durchbruch stünden [13]. Dann könnten die Value Types also doch bald Wirklichkeit werden.

Die Welt rund um die JVM bleibt weiter in Bewegung und im nächsten Jahr wird Java schon 30 Jahre alt. Oracle plant zu diesem Jubiläum im März 2025 ein Revival der JavaOne in der San Francisco Bay Area [14]. Vielleicht treffen wir uns dort und stoßen auf unser gutes, altes Java an.

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.