Schlagwort: Java/Fortgeschritten

  • Initialization-on-Demand Holder Idiom in Java

    Initialization-on-Demand Holder Idiom in Java

    Das Initialization-on-Demand Holder Idiom ist ein sicherer und effizienter Weg, um in Multithreading-Anwendungen statische Felder erst bei Bedarf zu initialisieren. Es verhindert dabei subtile Race Conditions, ohne die Performance durch vollständige Synchronisierung aller Zugriffe zu beeinträchtigen.

    In diesem Artikel erfährst du:

    • Was ist die Motivation für das Initialization-on-Demand Holder Idiom?
    • Warum ist die vollständige Synchronisierung mit synchronized nicht optimal?
    • Wie wird das Initialization-on-Demand Holder Idiom in Java implementiert?
    • Welche Alternativen gibt es?

    Motivation

    Bei zeitaufwändigen Initialisierungsprozessen ist es oft ratsam, diese erst bei tatsächlichem Bedarf durchzuführen. Beispielsweise könnten es Sinn machen, einen Logger, der eine Verbindung zu einer Datenbank aufbaut, erst dann zu initialisieren, wenn das erste Mal etwas geloggt wird. Diese „Lazy Initialization“ verhindert unnötige Verzögerungen beim Programmstart.

    In einer Single-Thread-Umgebung ist die Implementierung unkompliziert:

    public class UserService {
      private static Logger logger;
    
      private static Logger getLogger() {
        if (logger == null) {
          logger = initializeLogger();
        }
        return logger;
      }
    
      public void createUser(User user) {
        // . . .
        getLogger().info("User created");
      }
    
      // . . .
    }Code-Sprache: Java (java)

    Der erste Aufruf von getLogger() initializiert den Logger und speichert ihn im statischen Feld logger. Nachfolgende Aufrufe liefern direkt das gespeicherte Objekt.

    Diese Implementierung ist allerdings nicht threadsicher.

    In einer Multithreading-Anwendung können hier verschiedene subtile Effekte auftreten, die zu kaum zu reproduzierenden und damit nur schwer zu behebenden Race Conditions führen können:

    • Bei nahezu gleichzeitigem ersten Aufruf der getLogger()-Methode aus zwei Threads könnten beide Threads das logger-Feld als null sehen, so dass der Logger mehrfach initialisiert wird.
    • Durch Thread-Caching-Effekte könnte ein Thread selbst dann, wenn ein anderer Thread das logger-Feld bereits zugewiesen hat, dieses noch als null sehen.
    • Durch Instruction Reordering könnte ein Thread das logger-Feld als nicht null sehen, dieses könnte aber auf ein noch nicht vollständig initialisiertes Logger-Objekt zeigen.

    Die drei Effekte habe ich im Artikel über das Double-Checked Locking Idiom ausführlich beschrieben.

    In Multithreading-Anwendungen müssen wir daher den Zugriff auf das logger-Feld durch geeignete Maßnahmen synchronisieren.

    Lösung 1: Vollständige Synchronisation

    Der einfachste Ansatz ist eine vollständige Synchronisation der getLogger()-Methode durch synchronized (oder alternativ ein explizites Lock):

    //           ↓     
    private synchronized static Logger getLogger() {
      if (logger == null) {
        logger = initializeLogger();
      }
      return logger;
    }Code-Sprache: Java (java)

    Diese Implementierung führt jedoch zu erheblichen Performance-Einbußen, da:

    • jeder Aufruf den Verwaltungsoverhead einer kompletten Synchronisation erfordert,
    • Threads bei parallelen Zugriffen warten müssen,
    • Ein- und Austritt aus dem synchronized-Block vollständige Cache-Hauptspeicher-Synchronisationen auslösen.

    Aufgrund dieses signifikanten Mehraufwands ist diese Lösung insbesondere für oft aufgerufene Methoden nicht optimal.

    Lösung 2: Double-Checked-Locking

    Eine weitere Lösungsmöglichkeit ist das oben bereits erwähnte Double-Checked-Locking. Dieses habe ich ausführlich in dem verlinkten Artikel erläutert.

    Ein korrekt implementiertes Double-Checked Locking löst die oben genannten Performance-Probleme, ist aber recht kompliziert und damit fehleranfällig in der Implementierung.

    Lösung 3: Initialization-on-Demand Holder

    Die dritte Lösung ist das Initialization-on-Demand Holder Idiom. Auch dieses löst die oben genannten Performance-Probleme. Es ist einfacher umzusetzen als Double-Checked Locking und damit weniger fehleranfällig – es funktionert aber nur mit statischen Feldern, nicht mit Instanzfeldern.

    Und so wird es implementiert:

    public class UserService {
      private static class LoggerHolder {
        private static final Logger LOGGER = initializeLogger();
      }
    
      public void registerUser(User user) {
        // . . .
        LoggerHolder.LOGGER.info("User created");
      }
    
      // . . .
    }Code-Sprache: Java (java)

    Hier wird das Logger-Objekt im statischen LOGGER-Feld der inneren Klasse LoggerHolder gespeichert.

    Aber wird damit der Logger nicht schon beim Programmstart initialisiert? Wollten wir nicht genau das vermeiden?

    Nein, denn die JVM lädt und initialisiert eine Klasse erst dann, wenn sie benötigt wird.

    Wie stellt das Initialization-on-Demand Holder Idiom die Lazy Initialization sicher?

    Wenn die JVM (die Java Virtual Machine) die UserService-Klasse lädt, lädt sie nicht automatisch die LoggerHolder-Klasse mit. Sie lädt die Klasse erst dann, wenn zur Laufzeit zum ersten Mal auf LoggerHolder.LOGGER zugegriffen wird.

    Hier ist ein kleines Demo-Programm, mit dem du das ausprobieren kannst:

    public class InitializationOnDemandHolderIdiomDemo {
      private static class LoggerHolder {
        private static final Logger LOGGER = initializeLogger();
    
        private static Logger initializeLogger() {
          System.out.println(">>>>>>>>>> Initializing logger <<<<<<<<<<");
          return Logger.getLogger(InitializationOnDemandHolderIdiomDemo.class.getName());
        }
      }
    
      public static void main(String[] args) {
        InitializationOnDemandHolderIdiomDemo demo =
            new InitializationOnDemandHolderIdiomDemo();
    
        demo.doSomethingWithoutLogging();
        demo.doSomethingWithoutLogging();
        demo.doSomethingWithoutLogging();
    
        demo.doSomethingWithLogging();
        demo.doSomethingWithLogging();
        demo.doSomethingWithLogging();
      }
    
      private void doSomethingWithoutLogging() {
        System.out.println("Not logging");
      }
    
      private void doSomethingWithLogging() {
        System.out.println("\nI'm going to log something...");
        LoggerHolder.LOGGER.info("Some log message");
        System.out.println("Logged something");
      }
    }Code-Sprache: Java (java)

    Du wirst folgende Ausgabe sehen:

    Not logging
    Not logging
    Not logging
    
    I'm going to log something...
    >>>>>>>>>> Initializing logger <<<<<<<<<<
    Logged something
    
    I'm going to log something...
    Logged something
    
    I'm going to log something...
    Logged somethingCode-Sprache: Klartext (plaintext)

    Du siehst: Der Logger wird erst dann – und nur dann – initialisiert, wenn er das erste Mal benötigt wird. Damit können wir an die Anforderung „Lazy Initialization“ einen Haken setzen.

    Und was ist mit der Threadsicherheit?

    Wie garantiert das Initialization-on-Demand Holder Idiom die Threadsicherheit?

    Die Threadsicherheit wird beim Laden und Initialisieren von Klassen automatisch durch die JVM (die Java Virtual Machine) garantiert.

    D. h. wenn der erste Zugriff auf LoggerHolder.LOGGER gleichzeitig durch zwei Threads erfolgen sollte, dann stellt die JVM zum einen sicher, dass das LoggerHolder.LOGGER nur einmal initialisiert wird – und zum anderen, dass beide Threads das vollständig initialisierte Logger-Objekt sehen.

    Das klingt ja fast zu schön um wahr zu sein…

    Nachteil des Initialization-on-Demand Holder Idioms

    Wie fast immer gibt es auch bei dieser Lösung einen Nachteil:

    Wenn beim ersten Zugriff auf LoggerHolder.LOGGER der Aufruf der initializeLogger()-Methode fehlschlagen sollte, dann wird bei folgenden Zugriffen nicht etwa erneut versucht, initializeLogger() aufzurufen. Nein – wenn das Initialisieren einer Klasse einmal fehlgeschlagen ist, wird die JVM nicht erneut versuchen, die Klasse zu initialisieren. Stattdessen wird jeder weitere Zugriff auf LoggerHolder.LOGGER unverzüglich zu einem NoClassDefFoundError führen.

    Hier ist ein kleines Programm, das das Verhalten demonstriert:

    public class InitializationOnDemandHolderIdiomErrorDemo {
      private static class LoggerHolder {
        private static final Logger LOGGER = initializeLogger();
    
        private static Logger initializeLogger() {
          System.out.println(">>>>>>>>>> Initializing logger <<<<<<<<<<");
          throw new RuntimeException("Initialization failed");
        }
      }
    
      public static void main(String[] args) {
        InitializationOnDemandHolderIdiomErrorDemo demo =
            new InitializationOnDemandHolderIdiomErrorDemo();
    
        demo.doSomethingWithLogging();
        demo.doSomethingWithLogging();
        demo.doSomethingWithLogging();
      }
    
      private void doSomethingWithLogging() {
        try {
          System.out.println("\nI'm going to log something...");
          LoggerHolder.LOGGER.info("I did something smart");
          System.out.println("Logged something");
        } catch (Throwable t) {
          System.out.println(">>>>>>>>>> " + t.getClass().getName() + " <<<<<<<<<<");
        }
      }
    }Code-Sprache: Java (java)

    Das Programm gibt folgendes aus:

    I'm going to log something...
    >>>>>>>>>> Initializing logger <<<<<<<<<<
    >>>>>>>>>> java.lang.ExceptionInInitializerError <<<<<<<<<<
    
    I'm going to log something...
    >>>>>>>>>> java.lang.NoClassDefFoundError <<<<<<<<<<
    
    I'm going to log something...
    >>>>>>>>>> java.lang.NoClassDefFoundError <<<<<<<<<<Code-Sprache: Klartext (plaintext)

    Du siehst: Nur beim ersten Zugriff auf LoggerHolder.LOGGER wird initializeLogger() aufgerufen. Alle folgenden Zugriffe führen direkt zu einem NoClassDefFoundError.

    Bei den anderen Lösungen – vollständiger Synchronisation und Double-Checked Locking – würde hingegen bei jedem weiteren Aufruf der getLogger()-Methode erneut versucht werden, das Logger-Objekt zu initialisieren.

    Hier ein entsprechendes Demo für die Variante mit vollständiger Synchronisation:

    public class LazyInitializationErrorDemo {
      private static Logger logger;
    
      private static Logger getLogger() {
        if (logger == null) {
          logger = initializeLogger();
        }
        return logger;
      }
    
      private static Logger initializeLogger() {
        System.out.println(">>>>>>>>>> Initializing logger <<<<<<<<<<");
        throw new RuntimeException("Initialization failed");
      }
    
      public static void main(String[] args) {
        LazyInitializationErrorDemo demo = new LazyInitializationErrorDemo();
    
        demo.doSomethingWithLogging();
        demo.doSomethingWithLogging();
        demo.doSomethingWithLogging();
      }
    
      private void doSomethingWithLogging() {
        try {
          System.out.println("\nI'm going to log something...");
          getLogger().info("I did something smart");
          System.out.println("Logged something");
        } catch (Throwable t) {
          System.out.println(">>>>>>>>>> " + t.getClass().getName() + " <<<<<<<<<<");
        }
      }
    }
    Code-Sprache: Java (java)

    Und hier die Ausgabe des Programms:

    I'm going to log something...
    >>>>>>>>>> Initializing logger <<<<<<<<<<
    >>>>>>>>>> java.lang.RuntimeException <<<<<<<<<<
    
    I'm going to log something...
    >>>>>>>>>> Initializing logger <<<<<<<<<<
    >>>>>>>>>> java.lang.RuntimeException <<<<<<<<<<
    
    I'm going to log something...
    >>>>>>>>>> Initializing logger <<<<<<<<<<
    >>>>>>>>>> java.lang.RuntimeException <<<<<<<<<<Code-Sprache: Klartext (plaintext)

    Hier wird nun bei jedem Aufruf von getLogger() erneut versucht, den Logger zu initialisieren.

    Ob das gewünscht ist oder nicht, hängt selbstverständlich von den Anforderungen ab.

    Kommende Alternative: Lazy Constants

    Auch den JDK-Entwickler:innen ist bewusst, dass die existierenden Lösungen allesamt nicht optimal sind. Daher wird aktuell an einem neuen Feature gearbeitet: Lazy Constants.

    Lazy Constants wurden in Java 25 unter dem Namen Stable Values als Preview-Version eingeführt und in Java 26 in Lazy Constants umbenannt und grundlegend überarbeitet. Eine Lazy Constant ist ein Container, der die threadsichere Initialisierung von Konstanten beim erstem Zugriff darauf hinter einer einfachen API kapselt.

    Fazit

    Um Felder in Multithreading-Anwendungen erst bei Bedarf zu initialisieren, können wir das Double-Checked Locking Idiom oder – für statische Felder – das in diesem Artikel beschriebene Initialization-on-Demand Holder Idiom einsetzen.

    Beide Varianten sind performanter als eine vollständige Synchronisation der Zugriffsmethode mit synchronized oder einem expliziten Lock.

    Beide Varianten sind allerding auch kompliziert in der Implementierung – und das kann leicht zu Fehlern führen – vor allem, da sich eine fehlerhafte Implementierung nur durch Race Conditions offenbart, und damit in der Regel nicht sofort, sondern unter Umständen erst nach Wochen oder Monaten.

    An einer performanten und gleichzeitig leicht zu implementierenden Variante wird derzeit gearbeitet: Lazy Constants (zum Stand von Java 26 im Preview-Stadium).

  • Double-Checked Locking in Java

    Double-Checked Locking in Java

    Double-Checked Locking ist ein Pattern, um in Multithreading-Umgebungen Objekte lazily (also beim ersten Zugriff darauf) zu initialisieren, ohne dass es dabei zu subtilen Race Conditions kommen kann – und das ohne den Zugriff auf dieses Objekt vollständig (und damit zeitaufwändig) zu synchronisieren.

    In diesem Artikel erfährst du:

    • Was ist die Motivation für das Double-Checked Locking?
    • Welche subtilen Fehler können bei der „Lazy Initialization“ gemacht werden?
    • Warum ist die vollständige Synchronisierung mit „synchronized“ nicht optimal?
    • Warum ist das originale Double-Checked Locking Idiom fehlerhaft?
    • Wie wird Double-Checked Locking in Java korrekt implementiert?
    • Welche Alternativen gibt es?

    Motivation

    Gelegentlich möchten wir ein Objekt erst dann initialisieren, wenn es benötigt wird, da die Initialisierung aufwändig ist und wir den Programmstart nicht unnötig verzögern wollen.

    In single-threaded Anwendungen ist das einfach. Beispielsweise könnten wir das Laden von Einstellungen aus der Datenbank beim ersten Zugriff darauf wie folgt implementieren:

    private Settings settings;
    
    private Settings getSettings() {
      if (settings == null) {
        settings = loadSettingsFromDatabase();
      }
      return settings;
    }Code-Sprache: Java (java)

    Beim ersten Aufruf der Methode werden die Settings geladen und im settings-Feld gespeichert. Bei jedem weiteren Aufruf wird das im settings-Feld gespeicherte Objekt zurückgegeben.

    Diese Methode ist jedoch nicht threadsicher.

    Es gibt drei problematische Effekte, die beim Aufruf dieser Methode aus mehreren gleichzeitig laufenden Threads auftreten kann – einen offensichtlichen und zwei, die selbst erfahrene Java-EntwicklerInnen oft übersehen.

    Effekt 1: Mehrfache Initialisierung durch Verzahnung der Thread-Ausführungen

    Ein Effekt, den die meisten Java Developer sofort sehen, ist der folgende:

    Würde die Methode aus zwei Threads gleichzeitig aufgerufen werden, kann sich die Ausführung der zwei Threads wie folgt verzahnen:

    Mehrfache Initialisierung eines Feldes durch Verzahnung der Thread-Ausführungen

    Vereinfacht gesagt:

    Wenn beide Threads die Methode nahezu gleichzeitig starten, sehen beide, dass settings null ist. Dementsprechend würden beide Threads die Settings aus der Datenbank laden, und die als zweites geladenen Settings würden die zuerst geladenen überschreiben.

    Effekt 2: Mehrfache Initialisierung durch Cache-Effekte

    Was selbst erfahrene EntwicklerInnen oft nicht sehen, ist folgendes:

    Tatsächlich muss die Thread-Ausführung nicht einmal verzahnt sein. Auch wenn ein Thread die Methode erst dann ausführt, nachdem der andere sie beendet hat, kann es zu mehrfacher Initialisierung kommen:

    Mehrfache Initialisierung eines Feldes durch Cache-Effekte

    Wie kann das sein? Wieso sollte Thread 2 settings als null sehen und die Initialisierung wiederholen?

    Die Antwort liegt in der CPU-Architektur:

    Jeder CPU-Kern hat einen Cache, in dem Daten aus dem Hauptspeicher zwischengespeichert werden. Genauer gesagt: Bei modernen CPUs hat jeder Kern sogar zwei Caches: einen Level-1-Cache und einen Level-2-Cache. Zudem hat die CPU noch einen von allen Kernen geteilten Level-3-Cache:

    CPU-Kern-Caches L1, L2 und gemeinsam genutzter CPU-Cache L3

    Die Aufteilung auf drei Cache-Level können wir für die Betrachtung der Auswirkungen der Caches allerdings vernachlässigen, und daher werde ich im folgenden nur vom CPU-Kern-Cache sprechen.

    Aus Performancegründen arbeitet jeder CPU-Kern vorrangig mit seinem Cache.

    Nehmen wir an, Thread 1 und Thread 2 laufen auf unterschiedlichen CPU-Kernen – der Einfachheit halber: CPU-Kern 1 und CPU-Kern 2. Dann könnte folgendes passieren:

    • Thread 1 hat das settings-Feld bisher nur in den Cache von CPU-Kern 1 geschrieben, aber noch nicht in den Hauptspeicher. Thread 2 lädt das settings-Feld aus dem Hauptspeicher, wo es noch null ist. Demzufolge sieht Thread 2 es als null und initialisiert es erneut.
    • Oder: Thread 1 schreibt das settings-Feld zwar in den Hauptspeicher, aber CPU-Kern 2 hat das Feld bereits zuvor, als es noch null war, in seinen Cache geladen. Thread 2 greift nun auf dieses gecachte Feld zu – sieht also auch in diesem Fall null und initialisiert die Settings erneut.

    Race Conditions

    Beide Effekte – sowohl das wiederholte Laden durch Verzahnung der Thread-Ausführungen als auch das wiederholte Laden durch Cache-Effekte – treten nicht deterministisch auf, da nicht vorhersehbar ist, wie Threads zeitlich ablaufen und wann die CPU den Cache mit dem Hauptspeicher synchronisiert. Es handelt sich daher um sogenannte „Race Conditions“.

    D. h. dass die Software unter Umständen monatelang korrekt läuft, bis es zu einem Fehler kommt. Es ist dann extrem schwer, den Fehler zu reproduzieren, ihn aufzuspüren und zu beheben.

    Und wenn uns die wiederholte Initialisierung nicht stört?

    Wenn diese Race Condition nur alle paar Monate auftritt und die einzige Folge ist, dass die Einstellungen wiederholt aus der Datenbank geladen werden – könnten wir das nicht einfach ignorieren?

    Zum einen: In diesem konkreten Use Case vermutlich schon. Es gibt aber auch Use Cases, in denen eine wiederholte Initialisierung ernste Konsequenzen haben könnte. Beispielsweise, wenn das so initialisierte Objekt einen globalen Status kapselt, auf den auch schreibend zugegriffen wird. In so einem Use Case müssen wir sicherstellen, dass zu jeder Zeit nur eine Instanz existiert.

    Zum anderen: Ich habe oben erwähnt, dass es neben dem offensichtlichen Effekt der unglücklichen Thread-Verzahnung zwei subtile Effekte gibt. Der eine war das CPU Core Caching.

    Bevor ich auf den zweiten subtilen Effekt näher eingehe, zeige ich dir zunächst die naheliegendste (aber auch unperformanteste) Lösung, um diese Methode threadsicher zu machen.

    Danach zeige ich dir das „originale“ Double-Checked Locking, das diese unperformante Lösung performanter machen soll. Leider enthält das „originale“ Double-Checked Locking immer noch den zweiten subtilen Effekt.

    Dann werde ich diesen Effekt erklären und dir zeigen, wie du Double-Checked Locking in Java korrekt und performant implementierst.

    Unperformanteste Lösung: Vollständige Synchronisation

    Die naheliegendste Variante, um die getSettings()-Methode threadsicher zu machen, ist sie mit dem synchronized-Keyword (oder alternativ einem expliziten Lock) zu synchronisieren:

    //           ↓
    private synchronized Settings getSettings() {
      if (settings == null) {
        settings = loadSettingsFromDatabase();
      }
      return settings;
    }Code-Sprache: Java (java)

    Das bedeutet allerdings, dass bei jedem Zugriff auf die Einstellungen eine vollständige Synchronisation durchgeführt wird, d. h. dass ggf. gewartet werden muss, falls gerade ein anderer Thread die Methode blockiert, dass dann der Zugriff für andere Threads blockiert wird und dass beim Betreten und Verlassen des synchronized-Blocks alle Daten zwischen CPU-Cache und Hauptspeicher synchronisisert werden.

    Dieser Vorgang ist recht aufwändig und daher inbesondere für häufig aufgerufene Methoden nicht zu empfehlen.

    Das „originale“ Double-Checked Locking

    Oft versuchen Entwickler, den Code zu optimieren, indem sie zunächst prüfen, ob das settings-Feld bereits gesetzt ist, und nur dann den synchronisierten Block betreten, wenn das Feld noch null ist und dementsprechend initialisiert werden muss.

    Für den Fall, dass nach der ersten Prüfung – aber vor dem Betreten des synchronized-Blocks – ein anderer Thread das settings-Feld gesetzt hat, wird innerhalb des synchronized-Blocks ein zweites Mal geprüft, ob dieses null ist. Daher die Bezeichnung „Double-Checked Locking“ (auf deutsch: „Doppelt überprüfte Sperrung“):

    // Don't do this!!!
    // This is the original, broken implementation of the "Double-Checked Locking" idiom.
    private Settings settings;
    
    private Settings getSettings() {
      if (settings == null) {
        synchronized (this) {
          if (settings == null) {
            settings = loadSettingsFromDatabase();
          }
        }
      }
      return settings;
    }Code-Sprache: Java (java)

    Auf den ersten Blick scheint dies eine sinnvolle Lösung zu sein, da so – außer beim ersten Aufruf – keine teure Synchronisation stattfinden muss. Doch wie der Kommentar in den ersten Zeilen des Codes bereits offenbart, ist diese Implementierung fehlerhaft.

    Und damit kommen wir zum dritten Effekt.

    Effekt 3: Instruction Reordering

    „Instruction Reordering“ bedeutet, dass sowohl Compiler als auch CPU innerhalb eines Threads zur Performance-Optimierung CPU-Instruktionen umsortieren dürfen, d. h. in einer anderen Reihenfolge ausführen dürfen – solange das die Semantik der Programmausführung innerhalb dieses Threads nicht verändert.

    Beispielsweise könnte der Code int a = 3; int b = 4; int c = a + b; auch so compiliert oder ausgeführt werden, dass zuerst Variable b auf 4 gesetzt wird und danach Variable a auf 3. Für die Berechnung von c macht das keinen Unterschied:

    Instruction-Reordering-Beispiel

    Was hat das mit der Initialisierung des settings-Felds zu tun?

    Der oben gezeigte Java-Programmcode wird sinngemäß in die folgenden Instruktionen übersetzt:

    1. Lade die Einstellungen aus der Datenbank.
    2. Erzeuge ein neues Settings-Objekt.
    3. Initialisiere das Settings-Objekt mit den aus der Datenbank geladenen Werten.
    4. Weise dieses Settings-Objekt dem settings-Feld zu.
    5. Lese Einstellungen aus dem settings-Feld
      (dieser Schritt ist im Beispielcode oben nicht zu sehen).

    Der Java-Compiler darf diese Instruktionen umordnen, insbesondere darf er die Schritte 3 und 4 vertauschen:

    1. Lade die Einstellungen aus der Datenbank.
    2. Erzeuge ein neues Settings-Objekt.
    3. Weise dieses Settings-Objekt dem settings-Feld zu.
    4. Initialisiere das Settings-Objekt mit den aus der Datenbank geladenen Werten.
    5. Lese Einstellungen aus dem settings-Feld.

    Das Settings-Objekt wird also zuerst (im uninitialisierten Zustand) dem Feld zugewiesen und erst danach initialisiert. Innerhalb eines Threads spielt das keine Rolle, da Schritt 5 (der lesende Zugriff) immer noch nach der Initialisierung ausgeführt wird.

    Aber: Wenn wir das noch einmal im Kontext der verzahten Thread-Ausführung betrachten, wäre jetzt folgender Ablauf möglich:

    Effekt von Instruction Reordering auf Double-Checked Locking

    Was passiert hier?

    Thread 1 sieht das uninitialisierte settings-Feld, lädt die Einstellungen aus der Datenbank, erzeugt ein Settings-Objekt und weist dieses dem settings-Feld zu – und zwar bevor es initialisiert ist. CPU-Kern 1 speichert zufällig in diesem Moment das settings-Feld im Hauptspeicher.

    Genau jetzt lädt CPU-Kern 2 das settings-Feld aus dem Hauptspeicher. Und da dieses nicht null ist, versucht Thread 2 nicht, den synchronized-Block zu betreten, merkt dementsprechend auch nicht, dass Thread 1 die Initialisierung noch nicht abgeschlossen hat.

    Und somit sieht Thread 2 zu diesem Zeitpunkt uninitialisierte Einstellungen (d. h. beispielsweise int-Felder, die noch auf 0 stehen, oder String-Felder, die noch null sind).

    Das passiert natürlich nicht immer, sondern nur dann, wenn die Ausführung der zwei Threads und die Synchronisation zwischen CPU-Kern-Cache und Hauptspeicher exakt so verzahnt abläuft, wie oben dargestellt. Auch mit fehlerhaftem Double-Checked Locking kann die Anwendung monatelang korrekt laufen, bis es zu einem Fehler kommt. Doch dann wird es quasi unmöglich sein, den Fehler zu reproduzieren – und dementsprechend schwer, ihn aufzuspüren und zu beheben.

    Auch bei der nicht synchronisierten Version vom Beginn des Artikels kann diese Race Condition auftreten. D. h. die Frage von oben („Und wenn uns die wiederholte Initialisierung nicht stört?“) müssen wir damit beantworten, dass wir selbst dann den Zugriff auf das gemeinsam genutzte Feld geeignet synchronisieren müssen.

    Warum überhaupt CPU-Kern-Cache und Instruction Reordering?

    Jetzt stellt sich berechtigterweise die Frage:

    Warum wird der CPU-Kern-Cache überhaupt verwendet, und warum erlaubt Java „Instruction Reordering“, wenn das doch zu so vielen Problemen führen kann?

    Die Antwort liegt in der Grundannahme, auf der CPU-Architekturen und Compiler basieren:

    Anwendungen sollen so performant wie möglich ablaufen, und bei Multithreading-Anwendungen wird davon ausgegangen, dass per default Threads unabhängig voneinander sind und deren Performance jeweils einzeln optimiert werden soll.

    Zugriff auf gemeinsam genutzte Datenstrukturen ist die Ausnahme und muss vom Programmierer durch geeignete Maßnahmen synchronisiert werden.

    Das „originale“ Double-Checked Locking ist – wie wir nun gesehen haben – keine geeignete Maßnahme. Wie macht man es nun richtig?

    Korrektes Double-Checked Locking in Java

    Vor Java 5 gab es keine Möglichkeit, das Double-Checked Locking korrekt in Java umzusetzen. Ab Java 5 ist dies mit einem einzigen zusätzlichen Keyword möglich: volatile.

    Hier ist eine korrekte (noch nicht optimierte) Version des Double-Checked Locking Idioms in Java:

    // Correct - but not yet optimized - version of the "Double-Checked Locking" idiom
    private volatile Settings settings; // ⟵ `settings` field must be volatile!
    
    private Settings getSettings() {
      if (settings == null) {
        synchronized (this) {
          if (settings == null) {
            settings = loadSettingsFromDatabase();
          }
        }
      }
      return settings;
    }Code-Sprache: Java (java)

    Was macht volatile, und warum ist das Double-Checked Locking damit korrekt?

    Mit dem Keyword volatile zeigen wir an, dass der Wert eines Feldes von verschiedenen Threads geändert werden kann. Damit erreichen wir zwei Dinge:

    1. Zwischen Schreiben und nachfolgendem Lesen eines Feldes werden die Caches der beteiligten CPU-Kerne mit dem Hauptspeicher synchronisiert. Somit sind Änderungen an einem Feld immer für andere Threads sichtbar – Thread-Caching-Probleme werden so verhindert.
    2. Ein neu erzeugtes Objekt wird erst dann einem Feld zugewiesen, wenn es vollständig initialisiert ist – damit kann ein Thread nie ein unvollständig initialisiertes Objekt aus einem anderen Thread sehen.

    Was bedeutet das konkret für das Double-Checked Locking?

    Durch volatile wird sichergestellt, dass im vorherigen Beispiel kein Instruction Reordering bzgl. der Objekt-Initialisierung durchgeführt wird, d. h. die Schritte 3 und 4 dürfen nicht vertauscht werden. Somit ist die oben dargestellte, zu einer Race Condition führende Thread-Verzahnung nicht mehr möglich.

    Optimiertes Double-Checked Locking in Java

    volatile führt allerdings auch dazu, dass, nachdem settings initialisiert wurde, bei jedem Aufruf der getSettings()-Methode zwei Mal der CPU-Cache mit dem Hauptspeicher synchronisiert wird – denn es wird zwei Mal auf das settings-Feld zugegriffen: einmal bei der Prüfung auf null und einmal bei der Rückgabe mit return.

    Das können wir optimieren, indem wir das Feld settings zunächst einer lokalen Variable zuweisen. Diese kann, da jeder Thread seine eigene lokale Version diese Variablen hat, auf dem Thread-Stack gehalten werden und muss nicht mit dem Hauptspeicher synchronisiert werden:

    // Correct and optimized version of the "Double-Checked Locking" idiom
    private volatile Settings settings;
    
    private Settings getSettings() {
      Settings localRef = settings; // ⟵ Store `settings` in a thread-local variable
      if (localRef == null) {
        synchronized (this) {
          localRef = settings;
          if (localRef == null) {
            settings = localRef = loadSettingsFromDatabase();
          }
        }
      }
      return localRef; // ⟵ Return thread-local variable
                       //   without accessing main memory a second time
    }Code-Sprache: Java (java)

    Im regulären Fall, also wenn settings bereits initialisiert ist, wird so nur noch einmal auf den Hauptspeicher zugegriffen: beim Zuweisen von localRef auf settings. Die Rückgabe mit return greift dann nur noch auf die Thread-lokale Variable localRef zu.

    Klingt kompliziert?

    Ist es auch! Und damit besteht auch immer das Risiko einer fehlerhaften Implementierung.

    Für die Initialisierung von zumindest statischen Feldern gibt es eine weitere Variante: das Initialization-on-Demand Holder Idiom – aber auch das ist eher ein Workaround als eine Lösung.

    Geht das nicht schöner?

    Bald! In Java 25 wurden die sogenannten Stable Values als Preview-Version eingeführt. In Java 26 wurden sie in Lazy Constants umbenannt. Lazy Constants sind ein Wrapper, der die threadsichere Initialisierung von Konstanten beim ersten Zugriff darauf hinter einer einfachen API kapselt.

    Bis Lazy Constants finalisiert werden, müssen wir uns allerdings zwischen einer vollständigen Synchronisation (einfach in der Implementierung, dafür langsam) und einem korrekt implementierten Double-Checked Locking (schnell, dafür kompliziert und fehleranfällig in der Implementierung) entscheiden.

    Fazit

    Die Initialisierung von gemeinsam genutzten Objekten beim ersten Zugriff darauf kann in Multithreading-Anwendungen (bisher) nur durch vollständige Synchronisation (mit synchronized oder einem expliziten Lock), durch ein korrekt implementiertes Double-Checked Locking oder durch das Initialization-on-Demand Holder Idiom implementiert werden.

    Beim Double-Checked Locking ist es essentiell, das gemeinsam genutzte Feld als volatile zu markieren, um Race Conditions auszuschließen, die durch Thread-Caching-Effekte oder Instruction Reordering verursacht werden.

    In Java 26 stehen Lazy Constants als Preview-Feature zur Verfügung: ein threadsicherer und Performance-optimierter Wrapper für Objekte, die beim ersten Zugriff initialisiert werden sollen.

  • Lazy Constants in Java – Endlich Werte sicher initialisieren!

    Lazy Constants in Java – Endlich Werte sicher initialisieren!

    Lazy Constants (in Java 25 hießen sie noch noch Stable Values) sind Werte, die zur Laufzeit einer Anwendung nur ein einziges Mal zugewiesen werden können – dies aber zu einem beliebigen Zeitpunkt – und danach konstant bleiben. Sie standardisieren die verzögerte Initialisierung („lazy initialization“) von Konstanten und erlauben der JVM, diese Konstanten so zu optimieren, wie sie es auch für finale („final“) Werte tun kann.

    In diesem Artikel erfährst du:

    • Was sind Lazy Constants und wie benutzt man sie?
    • Welche Vorteile bringt die Unveränderlichkeit einer Lazy Constant?
    • Wie haben wir Unveränderlichkeit bisher implementiert, und welche Nachteile hatte das?
    • Was sind Lazy Lists und Lazy Maps?
    • Wie funktionieren Lazy Constants intern?

    Lazy Constants sind ein Preview-Feature, das in Java 25 unter dem Namen Stable Values veröffentlicht wurde (JDK Enhancement Proposal 502) und in Java 26 deutlich vereinfacht und in Lazy Constants umbenannt wurde (JEP 526).

    Im den ersten Abschnitten erkläre ich, warum wir Lazy Constants überhaupt brauchen. Falls du dir das schon denken kannst, dann springe gerne direkt zum Abschnitt „Die Lösung: Lazy Constants“.

    Warum Immutability (Unveränderlichkeit)?

    Im der Einführung habe ich erklärt, dass es sich bei Lazy Constants um Werte handelt, die nur einmal zugewiesen werden, danach aber unveränderlich bleiben. Aber was bringt es uns, wenn Werte unveränderlich („immutable“) sind? Die Unveränderlichkeit bringt einige Vorteile mit sich:

    1. Ein unveränderliches Objekt kann problemlos von mehreren Threads genutzt werden.

    Es besteht keine Gefahr von Race Conditions, die wir bei veränderlichen Objekten nur durch Synchronisierung oder Memory Barriers verhindern können. Dabei schleichen sich selbst bei erfahrenen EntwicklerInnen leicht Fehler ein.

    2. Die JVM kann unveränderliche Objekte optimieren, z. B. durch Constant Folding.

    Wenn die JVM z. B. erkennt, dass an mehreren Stellen auf serviceRegistry.userService() zugegriffen wird, und sie weiß, dass serviceRegistry konstant ist und userService() eine Konstante zurückgibt, dann kann sie alle Aufrufe von serviceRegistry.userService() durch die userService-Konstante ersetzen.

    3. Unveränderliche Objekte machen den Code besser lesbar.

    Code ist vorhersehbarer, leichter verständlich und leichter zu debuggen, wenn man sich keine Gedanken über mögliche Änderungen von Objektzuständen machen muss. Bei veränderlichen Objekten sollten wir für Parameter und Rückgabewerte defensive Kopien erstellen, um sicherzustellen, dass diese nicht versehentlich modifiziert werden. Bei unveränderlichen Objekten ist das nicht notwendig.

    Immutability mit „final“

    Bisher war die einzige Möglichkeit, um Immutability zu erreichen, Felder eines Objekts mit final zu kennzeichnen. Statische finale Felder müssen bei der Deklaration oder in einem static-Block zugewiesen werden und werden beim Laden der Klasse initialisiert. Finale Instanzfelder müssen bei der Deklaration oder im Konstruktor zugewiesen werden und werden beim Erzeugen eines neuen Objekts der Klasse initialisiert.

    Im folgenden Beispiel wird ein statisches Logger-Feld initialisiert:

    public class UserService {
      private static final Logger LOGGER = LoggerFactory.getLogger(UserService.class);
    
      // . . .
    }Code-Sprache: Java (java)

    Alternativ mit einem static-Block:

    public class UserService {
      private static final Logger LOGGER;
    
      static {
        LOGGER = LoggerFactory.getLogger(UserService.class);
      }
    
      // . . .
    }Code-Sprache: Java (java)

    Im folgenden Beispiel wird für jedes neue Task-Objekt eine unveränderliche UUID generiert:

    public class Task {
      private final UUID taskId = UUID.randomUUID();
    
      // . . .
    }Code-Sprache: Java (java)

    Alternativ im Konstruktor:

    public class Task {
      private final UUID taskId;
    
      public Task() {
        taskId = UUID.randomUUID();
      }
    
      // . . .
    }Code-Sprache: Java (java)

    Nicht immer ist die Initialisierung von Konstanten ganz so einfach. Im Folgenden zeige ich dir einige weniger triviale Beispiele.

    Verzögerte Initialisierung („Lazy Initialization“)

    Finale Felder werden in jedem Fall initialisiert, selbst wenn sie gar nicht (oder erst sehr viel später) benutzt werden. Wenn aber die Initialisierung, also z. B. das Erzeugen des Loggers, eine Weile dauert (weil dieser z. B. eine Verbindung zu einem externen Logging-System aufbaut), der Logger dann aber nie (oder erst später) im Programmablauf verwendet wird, wurde der Start der Anwendung durch die frühzeitige Initialisierung u. U. unnötig verlangsamt.

    Felder, die teuer zu initialisieren sind, können wir verzögert, also erst bei Bedarf („lazy“) initialisieren. In einer single-threaded Anwendung ist das einfach:

    public class UserService {
      private static Logger logger;
    
      // Not thread-safe!!!
      private static Logger getLogger() {
        if (logger == null) {
          logger = LoggerFactory.getLogger(UserService.class);
        }
        return logger;
      }
    
      // . . .
    }Code-Sprache: Java (java)

    Ein zweiter Use Case:

    Nicht immer haben wir beim Erzeugen eines Objekts alle Informationen vorliegen, die wir für die Initialisierung eines unveränderlichen Feldes benötigen. Beispielsweise könnte ein Service erzeugt werden, bevor eine Verbindung zur Datenbank besteht – der Service muss aber zur Initialisierung eines Feldes auf die Datenbank zugreifen.

    Auch so ein Feld können wir lazy initialisieren:

    public class BusinessService {
      private Settings settings;
    
      // Not thread-safe!!!
      private Settings getSettings() { 
        if (settings == null) {
          settings = loadSettingsFromDatabase();
        }
        return settings;
      }
    
      // . . .
    }Code-Sprache: Java (java)

    In einer Spring- oder Jakarta-EE-Anwendung könnten wir die settings-Variable auch in einer mit @PostConstruct annotierten Methode initialiseren:

    @Service
    public class BusinessService {
      private Settings settings;
    
      @PostConstruct
      private void initializeSettings() {
        settings = loadSettingsFromDatabase();
      }
    
      // . . .
    }Code-Sprache: Java (java)

    Doch all dies sind Workarounds, und sie haben einige entscheidende Nachteile. Welche das sind, erfährst du im folgenden Abschnitt.

    Nachteile der „hausgemachten“ Lazy Initialization

    Wenn wir uns die Beispiele aus dem vorherigen Abschnitt noch einmal anschauen, dann fällt auf: Die Felder logger und settings sind nicht mehr als final gekennzeichnet. Denn das geht nur, wenn sie bei der Deklaration, in einem static-Block oder im Konstruktor initialisiert werden.

    Das wiederum bedeutet: Wir können nicht garantieren, dass die Felder nach der Initialisierung nicht doch noch verändert werden. Und ohne die Garantie, dass die Werte unveränderlich sind, kann die JVM kein Constant Folding durchführen.

    Außerdem müssen wir – zumindest bei den ersten zwei Beispielen – sicherstellen, dass wir auf die Felder nie direkt, sondern immer über die getLogger() bzw. getSettings()-Methode zugreifen.

    Und wenn wir uns eben diese Methoden noch einmal anschauen, dann stellen wir fest: Sie sind (bisher) nicht threadsicher! Sie dürfen also nicht aus mehreren Threads heraus aufgerufen werden.

    Um die getSettings()-Methode thread-safe zu machen, könnten wir sie mit synchronized markieren:

    private synchronized Settings getSettings() {
      if (settings == null) {
        settings = loadSettingsFromDatabase();
      }
      return settings;
    }Code-Sprache: Java (java)

    Das macht sie zwar threadsicher, gleichzeitig aber auch die Anwendung deutlich langsamer, da nun bei jedem Zugriff auf die Settings der synchronized-Block betreten werden muss.

    Schneller (aber auch fehleranfälliger) ist das sogenannte Double-checked Locking:

    private volatile Settings settings; // ⟵ `settings` must be volatile!
    
    private Settings getSettings() {
      Settings localRef = settings;
      if (localRef == null) {
        synchronized (this) {
          localRef = settings;
          if (localRef == null) {
            settings = localRef = loadSettingsFromDatabase();
          }
        }
      }
      return localRef;
    }Code-Sprache: Java (java)

    Warum du hierbei auf keinen Fall das volatile vergessen darfst und welchen Zweck die zusätzliche (auf den ersten Blick überflüssige) Variable localRef hat, kannst du im Artikel über das Double-checked Locking Idiom nachlesen.

    Eine Alternative ist das sogenannte Initialization-on-Demand Holder Idiom, bei dem die Tatsache ausgenutzt wird, dass die JVM Klassen zum einen lazy und zum anderen threadsicher lädt. Auch das ist ein Workaround. Nicht alle kennen ihn, und er funktioniert nur bei statischen Feldern, nicht bei Instanzfeldern.

    Zusammengefasst:

    1. Verzögert („lazy“) initialisierte Werte können nicht als final markiert werden; die Unveränderlichkeit ist also nicht garantiert.
    2. Dementsprechend kann die JVM den Code nicht durch Constant Folding optimieren.
    3. Der Aufruf eines verzögert initialisierten Wertes muss immer über eine Hilfsmethode erfolgen.
    4. In Multithreading-Anwendungen muss diese Hilfsmethode threadsicher sein. Hier können sich leicht Fehler einschleichen, was zu subtilen Race Conditions führt.

    Was uns in Java fehlt, ist ein Mittelweg zwischen final und veränderbar. Ein Wert, der dann initialisiert wird, wenn er benötigt wird. Ein Wert, der auf jeden Fall nur einmal initialisiert wird. Und ein Wert, der auch dann korrekt initialisiert wird, wenn aus mehreren Threads auf ihn zugegriffen wird.

    Und genau dieser Mittelweg sind Lazy Constants!

    Die Lösung: Lazy Constants

    Eine Lazy Constant ist ein Container, der ein Objekt enthält, den sogenannten „Inhalt“ (englisch: „content“). Eine Lazy Constants wird genau einmal initialisiert, bevor ihr Inhalt abgerufen wird; danach ist sie unveränderlich. Eine Lazy Constant ist thread-safe, d. h. wenn auf sie von mehreren Threads aus zugegriffen wird, wird sie maximal einmal initialisiert. Und die JVM kann eine Lazy Constant genauso gut durch Constant Folding optimieren wie ein finales Feld.

    Im Folgenden siehst du, wie du das Settings-Beispiel mit einer Lazy Constant implementieren kannst.

    public class BusinessService {
      private final LazyConstant<Settings> settings =
          LazyConstant.of(this::loadSettingsFromDatabase);
    
      public Locale getLocale() {
        return settings.get().getLocale(); // ⟵ Here we access the lazy constant
      }
    
      // . . .
    }
    Code-Sprache: Java (java)

    Die an LazyConstant.of() übergebene Methode – hier die Methode, die die Settings aus der Datenbank lädt – wird Berechnungsfunktion („Computing Function“) genannt. Beim ersten Aufruf der get()-Methode wird der Inhalt der Lazy Constant einmalig initialisiert, indem die Berechnungsfunktion aufgerufen wird.

    Das von of() zurückgelieferte LazyConstant-Objekt könntest du übrigens auch in einem Supplier speichern, da LazyConstant von Supplier erbt. LazyConstant bietet allerdings neben get() noch zwei weitere Methoden:

    • isInitialized() – gibt zurück, ob der Wert bereits initialisiert wurde.
    • orElse(T other) – gibt den berechneten Wert zurück, falls initialisiert, andernfalls other.

    Beide Methoden führen nicht zu einer Initialisierung der Lazy Constant.

    Lazy Lists

    Wir können nicht nur einzelne Lazy Constants definieren, sondern auch eine Liste von Lazy Constants, also eine Liste, bei der jedes einzelne Element erst beim Zugriff darauf – z. B. mit first(), get(int index) oder last() – initialisiert wird.

    Das folgende Beispiel erzeugt eine Lazy List, in der jedes Element bei dessen ersten Aufruf mit der Quadratwurzel des Listenindexes initialisiert wird:

    List<Double> squareRoots = List.ofLazy(100, Math::sqrt);Code-Sprache: Java (java)

    Die Größe der Liste und deren Elemente sind nicht änderbar. Die Methoden add(), set() und remove() führen zu einer UnsupportedOperationException. Abgeleitete Listen – z. B. mit subList() oder reversed() – sind ebenfalls Lazy Lists.

    Hier ein kleines Demo-Programm (ich verwende hier eine vereinfachte Main-Methode, die es ab Java 21 als Preview-Feature gibt, und die in Java 25 finalisiert wurde):

    void main() {
      List<Double> squareRoots = List.ofLazy(100, i -> {
        IO.println("Initializing list element at index " + i);
        return Math.sqrt(i);
      });
    
      IO.println("squareRoots[0]    = " + squareRoots.get(0));
      IO.println("squareRoots[1]    = " + squareRoots.get(1));
      IO.println("squareRoots[2]    = " + squareRoots.get(2));
      IO.println("squareRoots[0]    = " + squareRoots.get(0));
      IO.println("squareRoots.first = " + squareRoots.getFirst());
      IO.println("squareRoots.last  = " + squareRoots.getLast());
    }Code-Sprache: Java (java)

    Das Programm gibt folgendes aus:

    Initializing list element at index 0
    squareRoots[0]    = 0.0
    Initializing list element at index 1
    squareRoots[1]    = 1.0
    Initializing list element at index 2
    squareRoots[2]    = 1.4142135623730951
    squareRoots[0]    = 0.0
    squareRoots.first = 0.0
    Initializing list element at index 99
    squareRoots.last  = 9.9498743710662Code-Sprache: Klartext (plaintext)

    Du kannst hier gut erkennen, dass die Lazy List das Element an Position 0 nur einmal berechnet, obwohl es drei Mal abgerufen wird (zwei Mal mit get(0) und ein Mal mit getFirst()).

    Lazy Map

    Analog zu Lazy Lists können wir auch Lazy Maps erzeugen. Bei einer Lazy Map wird für jeden Key der zugehörige Value erst beim ersten Abruf initialisisert und dann gespeichert.

    Das folgende Beispiel zeigt eine Lazy Map, mit der wir Lokalisierungsresourcen pro Sprache dynamisch beim ersten Aufruf laden können:

    Set<Locale> supportedLocales = getSupportedLocales();
    Map<Locale, ResourceBundle> resourceBundles =
        Map.ofLazy(supportedLocales, this::loadResourceBundle);Code-Sprache: Java (java)

    Erst beim ersten Aufruf von resourceBundles.get(...) wird das entsprechende Resource Bundle über die als Methodenreferenz übergebene loadResourceBundle(...)-Methode geladen.

    Wie funktionieren Lazy Constants intern?

    Lazy Constants sind ausschließlich im Java-Code implementiert. Änderungen an Compiler, Bytecode oder JVM waren nicht erforderlich, wie aus dem Pull Request für JEP 502 hervorgeht.

    Der Inhalt einer Lazy Constants wird in einem nicht-finalen Feld gespeichert. Dieses ist mit der JDK-internen Annotation @Stable versehen, die auch an anderen Stellen des JDK-Codes zur Optimierung eingesetzt wird. Eben diese Annotation sagt der JVM, dass sich der Wert nach der Initialisierung nicht ein weiteres Mal ändern wird. Und so kann die JVM, nachdem der Wert gesetzt wurde, mit der Constant-Folding-Optimierung loslegen.

    Die Threadsicherheit wird durch Memory Barriers sichergestellt, die über die Unsafe-Klasse gesetzt werden.

    Heißt das, LazyConstant ist im Grunde genommen nur ein Wrapper, den wir auch selbst implementieren könnten?

    Ja, aber…

    Erstens können wir weder die JDK-interne @Stable-Annotation noch die interne Unsafe-Klasse verwenden, ohne diese explizit über --add-exports java.base/jdk.internal.vm.annotation bzw. --add-exports java.base/jdk.internal.misc unserem Modul zur Verfügung zu stellen.

    Zweitens sollten wir diese JDK-Internals nicht verwenden, da nicht garantiert ist, dass diese sich nicht in einem späteren Java-Release ändern werden.

    Und drittens schreiben wir ja auch z. B. eine ConcurrentHashMap nicht selbst. Dadurch, dass LazyConstant von JDK-Spezialisten implementiert wird, können wir sichergehen, dass alle nur bekannten Performance-Tricks angewendet wurden und dass auch in Zukunft weitere Performance-Optimierungen vorgenommen werden. Und wenn LazyConstant in Zukunft durch Millionen von Java-Entwickler:innen genutzt wird, können wir auch sicher sein, dass eventuelle Bugs – selbst subtile Concurrency-Bugs – schnell gefunden und behoben werden.

    Fazit

    Lazy Constants sind Konstanten, die zu jeder beliebigen Zeit „on demand“ initialisiert werden können. Danach sind sie immutable und werden von der JVM genau wie finale Felder behandelt, also z. B. durch Constant Folding optimiert.

    Lazy Constants sind threadsicher, können also auch in Multithreading-Programmen eingesetzt werden, ohne subtile Concurrency Bugs zu riskieren.

    Neben Lazy Constants gibt es Lazy Lists und Lazy Maps, die die Elemente in Listen und Maps einmalig initialisieren und dann unveränderlich speichern.

    Lazy Constants sind als Preview-Feature in Java 25 (dort noch unter dem Namen „Stable Values“) und im aktuellen Early-Access-Build von Java 26 enthalten.

    Was hälst du von Lazy Constants? Teile deine Meinung in den Kommentaren!

  • Java Compact Object Headers (JEP 519)

    Java Compact Object Headers (JEP 519)

    Jedes Java-Objekt hat im Speicher ein den eigentlichen Daten vorangestellten Objekt-Header. Dieser enthält vor allem den Hash-Code des Objekts und die Information, von welcher Klasse das Objekt eine Instanz ist.

    Der Objekt Header ist zum Stand von Java 25 standardmäßig 96 Bit (12 Byte) groß – oder 128 Bit (16 Byte), wenn Compressed Class Pointers ausgeschaltet werden (wozu es aber nahezu keinen Grund gibt – weshalb diese Option in Java 25 als deprecated markiert wurde).

    Im Rahmen von Project Lilliput tüfteln die JDK-Entwickler seit vielen Jahren an Möglichkeiten, um den Header auf insgesamt 64 Bit oder sogar auf 32 Bit zu komprimieren.

    Im Jahr 2025 war es dann so weit: In Java 24 wurden Compact Object Headers durch JDK Enhancement Proposal 450 als experimentelles Feature eingeführt – und in Java 25 durch JDK Enhancement Proposal 519 finalisiert. Compact Object Headers ermöglichen es, den Objekt-Header von 96 Bit auf 64 Bit zu komprimieren und damit die Heap-Größe bestehender Anwendungen deutlich zu reduzieren.

    In diesem Artikel erfährst du:

    • Wie funktioniert die Header-Komprimierung?
    • Warum wird dadurch nicht nur der Speicherbedarf reduziert, sondern auch die Anwendungsperformance erhöht?

    Status Quo vor Compact Object Headers

    Eine detaillierte Beschreibung des Aufbaus von Objekt-Headern findest du im Hauptartikel über Java Object Header. Hier das Wichtigste zusammengefasst:

    In der Regel besteht der Object Header aus einem 64-Bit „Mark Word“ und einem 32-Bit „Class Word“. Mark Word und Class Word sind wie folgt aufgebaut:

    JEP 450: Java Object Header with Mark Word and Class Word

    Das Mark Word enthält:

    • einen 31-Bit Identity Hash Code (der beim Aufruf von System.identityHashCode(Object) zurückgegeben wird),
    • 4 Bits, in denen der Garbage Collector das Alter eines Objekts speichert (anhand dessen er entscheidet, wann ein Object von der jungen in die alte Generation verschoben wird),
    • 2 „Tag Bits“, die anzeigen, ob das Objekt nicht, uncontended (ohne wartende Threads) oder contended (mit wartenden Threads) gelockt ist.

    Bei Verwendung des veralteten Legacy Stack Lockings wurden im gelockten Zustand die ersten 62 Bit des Mark Words durch einen Pointer auf eine Lock-Datenstruktur ersetzt. Seit Java 23 hat das sogenannte Lightweight Locking diesen veralteten Mechanismus abgelöst.

    Der alte Modus kann aktuell noch durch die VM-Option -XX:LockingMode=1 reaktiviert werden; er kann allerdings nicht mit Compact Object Headers kombiniert werden.

    Das Class Word enthält einen 32-Bit-Offset in den maximal 4 GB großen Compressed Class Space, auf die sogenannte Klass-Datenstruktur, die alle relevanten Daten über die Klasse des Objekts enthält.

    Wenn Compressed Class Pointers mit -XX:-UseCompressedClassPointers deaktiviert wurden, dann ist das Class Word 64 Bit groß und enthält einen unkomprimierten Pointer. Die Deaktivierung von Compressed Class Pointers wurde in Java 25 als deprecated markiert und kann nicht mit Compact Object Headers kombiniert werden.

    Vom Compressed Class Pointer zum Compact Object Header

    Wie können wir den Objekt-Header weiter komprimieren?

    Zunächst einmal enthält das Mark Word (wie man oben sieht) aktuell 27 ungenutzte Bits (25 am Anfang und jeweils eines vor und nach den „Age Bits“). Von den 96 Bits des gesamten Object-Headers werden also nur 96 – 27 = 69 Bits benötigt. Um auf 64 Bit zu kommen, müssen wir also irgendwie fünf Bits einsparen.

    Wo können wir die hernehmen?

    Die JDK-Entwickler haben lange experimentiert, bis sie zu folgender Lösung kamen (ich habe für eine bessere Darstellung den Maßstab geändert – die 64 Bit ziehen sich jetzt über die gesamte Breite):

    JEP 450: Compact Object Header

    Der neue 64 Bit-Header wird nicht mehr in Mark Word und Class Word aufgeteilt, sondern enthält direkt die folgenden Informationen:

    • einen von 32 Bit auf 22 Bit weiter komprimierten Class Pointer (wird unten erklärt),
    • den 31-Bit Identity Hash Code (unverändert),
    • 4 für Project Valhalla reservierte Bits (neu),
    • 4 Bits für das Alter des Objekts (unverändert),
    • 1 Bit für das sogenannte „Self Forwarded Tag“ (wird unten erklärt),
    • 2 Tag Bits (unverändert).

    Der Class Pointer wurde also um 10 Bits verkleinert. Da wir nur fünf Bits einsparen mussten, stehen nun fünf zusätzliche Bits zur Verfügung. Vier davon wurden für Projekt Valhalla reserviert, und in einem Bit wird das neue „Self Forwarded Tag“ gespeichert.

    Wie konnte der Class Pointer auf 22 Bit komprimiert werden?

    Mit den bisherigen 32 Bit konnte jede Position innerhalb des 4 GB großen Compressed Class Space einzeln adressiert werden.

    Der Einfachheit halber stelle ich das in der folgenden Grafik mit einem 256 Byte großen Speicherbereich dar:

    256 Byte großer Speicherbereich

    Wie du siehst, brauchen wir die Zahlen 0 bis 255, um jede einzelne Position des Speicherbereichs zu adressieren. Dafür brauchen wir einen 8-Bit-Pointer (28 = 256).

    Doch müssen wir wirklich jede einzelne Position adressieren können? Nein, das müssen wir nicht!

    Genau wie eine Festplatte (egal ob eine herkömmlichen oder eine SSD) in sogenannte (in der Regel 4 KB große) Blöcke aufgeteilt ist, können wir auch den Speicherbereich für die Klassendaten in Blöcke aufteilen. So muss nicht mehr jedes einzelne Byte adressiert werden, sondern nur noch jeder Block. Und so können wir mit deutlich weniger Bits den gleichen Speicherbereich adressieren.

    Hier wieder das vereinfachte Beispiel, in dem ich den 256 Byte großen Speicherbereich in 32 Blöcke zu je 8 Byte aufteile:

    256 Byte großer Speicherbereich unterteilt in 8 Byte Blöcke

    Jetzt brauchen wir nur noch die Zahlen 0 bis 31, um den gleichen Speicherbereich zu adressieren. Dafür brauchen wir nur noch 5 Bit große Pointer (25 = 32). Durch Aufteilung in Blöcke konnten wir den Speicherbedarf pro Pointer von 8 Bit auf 5 Bit reduzieren.

    Und das funktioniert auch mit dem Speicherbereich, in dem die Klassen-Informationen liegen.

    Bei der Verwendung von Compact Object Headers wird dieser Speicherbereich in 1.024 (= 210) Byte große Blöcke aufgeteilt. Dieser Wert wurde gewählt, da die meisten Klassen zwischen einem halben und einem Kilobyte belegen.

    Zur Erinnerung: der Bereich ist 4 GB groß. Entsprechend ergeben sich 4 × 1.024 × 1.024 × 1.024 / 1.024 Blöcke, also 4 × 1.024 × 1.024, das sind 4.194.304, oder 222 Blöcke. Und diese können wir mit 22 Bit adressieren!

    Um aus einer 22-Bit-Blocknummer einen Pointer zu machen, müssen wir die 32 Bit lediglich um 10 Bit nach links schieben und die hinteren 10 Bit mit Nullen auffüllen, und damit haben wir wieder einen 32-Bit-Pointer in den 4 GB großen Speicherbereich:

    22-Bit Blocknummer wird zu einem 32-Bit Class Pointer

    Die Aufteilung in Blöcke führt nun zu einer Fragmentierung bei den Klassendaten. Doch auch das haben die JDK-Developer bedacht: der zwischen den Klassen liegende Speicher kann auch von anderen Datenstrukturen des Metaspace genutzt werden.

    Was ist das „Self Forwarded Tag“?

    Wenn ein Garbage Collector ein Objekt an eine neue Speicheradresse kopiert, ersetzt er im ursprünglichen Objekt die oberen 62 Bit des Mark Words durch einen Pointer auf die neue Speicheradresse und setzt die Tag Bits auf 0x11. Das ursprüngliche Mark Word findet er dann an der neuen Adresse.

    Wenn der Kopiervorgang fehlschlägt, wird das Mark Word durch einen Pointer auf das Objekt selbst ersetzt. Dadurch gehen Identity Hash Code und Alter des Objekts verloren, das scheint aber verschmerzbar zu sein (ich konnte leider keine verlässliche Information darüber finden, warum das der Fall ist, werde diesen Absatz aber aktualisieren, sollte ich eine Aussage hierzu finden).

    Wenn wir allerdings einen Compact Object Header durch eine Selbst-Referenz ersetzen würden, dann würde auch der Class-Pointer verloren gehen. Da dieser essentiell ist, darf ein Compact Object Header eben nicht durch solch eine Selbst-Referenz ersetzt werden.

    Stattdessen wird das neue „Self Forwarded Tag“-Bit gesetzt.

    Fazit zu Compact Object Headers

    Compact Object Headers reduzieren den Speicherbedarf eines Java-Programms signifikant, indem die Objekt-Header von 96 Bits (12 Bytes) auf 64 Bit (8 Bytes) reduziert werden.

    Und nicht nur das – dadurch, dass die Objekte kleiner sind, passen auch mehr Objekte in den CPU-Cache. So kommt es zu weniger Cache-Misses – und das wirkt sich zusätzlich positiv auf die Performance aus.

    Ab Java 25 können Compact Object Headers mit folgender VM-Option aktiviert werden:

    -XX:+UseCompactObjectHeaders

    In Java 24 befanden sich Compact Object Headers noch im experimentellen Stadium und mussten wie folgt aktiviert werden:

    -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders

    Ausblick

    In einer kommenden Java-Version (welche, ist noch nicht bekannt), werden Compact Object Headers standardmäßig aktiviert sein (s. JEP-Entwurf „Compact Object Headers by Default“).

    Als nächstes arbeiten die Project-Lilliput-EntwicklerInnen an 4-Byte-Headern – also einer weiteren Halbierung der Header-Größe! Für diese weitere Reduzierung müssen wir wahrscheinlich Leistungseinbußen in Kauf nehmen. Der JEP-Entwurf „4-byte Object Headers“ strebt eine maximale Durchsatz- und Latenzreduzierung von 5 % an. Wie die Komprimierung auf vier Byte erreicht werden soll, ist im JEP-Entwurf noch nicht beschrieben.

    Hast du Compact Object Headers bereits getestet? Hat es die erwarteten Verbesserungen gebracht? Teile deine Erfahrung in den Kommentaren!

  • Ahead-of-Time Class Loading & Linking – Turbo für Java-Programme

    Ahead-of-Time Class Loading & Linking – Turbo für Java-Programme

    In diesem Artikel erfährst du:

    • Warum brauchen große Java-Anwendungen mehrere Sekunden, um zu starten?
    • Was ist Ahead-of-Time Class Loading & Linking, und wie kann es die Startzeit verbessern?
    • Schritt für Schritt: Wie kann man durch Ahead-of-Time Class Loading & Linking den Start einer Anwendung beschleunigen?
    • Wie unterscheidet sich AoT Class Loading & Linking von (App)CDS?

    Warum starten Java-Anwendungen so langsam?

    Java-Anwendungen sind zur Laufzeit extrem flexibel, so können Klassen dynamisch geladen und entladen werden, dynamische Compilierung, Optimierung und Deoptimierung macht Java-Programme so schnell wie C-Code (oder schneller), und durch Reflection sind Frameworks wie Jakarta EE, Spring Boot, Quarkus, Helidon, Micronaut, etc. überhaupt erst möglich.

    Doch diese Vorteile haben ihren Preis:

    Beim Start einer Anwendung müssen Hunderte .jar-Dateien entpackt und Tausende .class-Dateien in den Speicher geladen, analysiert und verknüpft werden. Der statische Initialisierungscode von Klassen muss ausgeführt werden, und Frameworks wie Jakarta EE und Spring müssen den Code nach Annotationen scannen, Beans instanziieren und Konfigurationscode ausführen.

    Große Backend-Anwendungen können so mehrere Sekunden oder sogar Minuten benötigen, bis sie vollständig gestartet sind.

    Wie kann der Start von Anwendungen beschleunigt werden?

    Viele der im vorherigen Abschnitt beschriebenen Initialisierungsarbeiten sind bei jedem Anwendungsstart dieselben.

    Im Rahmen von Project Leyden wird daher daran gearbeitet, möglichst viele dieser sich immer wiederholenden Aufgaben bereits vor dem Start einer Anwendung auszuführen.

    Durch JDK Enhancement Proposal 483 wurden in Java 24 die ersten Früchte dieser Arbeit veröffentlicht: Klassen können nun nach dem Lesen, Parsen, Laden, und Linken in einer Binärdatei – dem AOT-Cache – gespeichert werden und sind damit bei zukünftigen Starts derselben Anwendung deutlich schneller in geladenem und gelinkten Zustand verfügbar. Die JDK-EntwicklerInnen haben Startzeit-Reduzierungen um bis zu 42 % gemessen.

    Durch JDK Enhancement Proposal 514, Ahead-of-Time Command-Line Ergonomics, wurde in Java 25 die Generierung des AOT-Caches vereinfacht – statt zwei Schritten muss nur noch einer ausgeführt werden.

    Durch JDK Enhancement Proposal 515, Ahead-of-Time Method Profiling, wurde – ebenfalls in Java 25 – der AOT-Cache um Laufzeitstatistiken über Methodenaufrufe erweitert, so dass die JVM direkt nach dem Start mit der Compilierung von Hotspots beginnen kann. Dadurch wurde eine weitere Startzeit-Reduzierungen um 19 % gemessen.

    Wie funktioniert Ahead-of-Time Class Loading & Linking?

    Um den Programmstart durch AoT Class Loading & Linking zu beschleunigen, müssen wir drei Schritte (bzw. zwei, dazu unten mehr) durchführen:

    1. In einem ersten Schritt wird die Anwendung in einem sogenannten Trainingslauf gestartet. Dabei analysiert die JVM alle geladenen und gelinkten Klassen und erzeugt eine Konfigurationsdatei mit den relevanten Informationen über diese Klassen – und ab Java 25 zusätzlich über Methoden-Aufrufstatistiken.
    2. In einem zweiten Schritt wird mit Hilfe dieser Konfigurationsdatei die binäre Cache-Datei erzeugt.
    3. Bei jedem weiteren Start der Anwendung wird diese Cache-Datei mit angegeben, und die Anwendung lädt die Klassen in geladener und gelinkter Form direkt aus diesem Cache – und ab Java 25 beginnt sie direkt mit der Optimierung der am häufigsten aufgerufenen Methoden („Hotspots“).

    Das hört sich komplizierter an als es ist. Im Folgenden führe ich dich Schritt für Schritt anhand einer Beispielanwendung durch diese Schritte bis hin zum beschleunigten Start der Anwendung.

    Noch einmal Schritt für Schritt zum Mitmachen

    Ich zeige dir in diesem Abschnitt an einer kleinen Anwendung Schritt für Schritt wie Ahead-of-Time Class Loading & Linking funktioniert.

    Wir benutzen dazu ein ganz einfaches Demo-Programm, das lediglich die aktuelle Zeit anzeigt.

    Wir verwenden hier eine kompakte Klassendatei mit einer Instanz-Main-Methode. Dadurch müssen wir keine Klasse definieren und können statt public static void main(String args[]) einfach void main() schreiben.

    Lade dir Java 24 oder ein Early-Access-Build von Java 25 herunter (Ahead-of-Time Class Loading & Linking ist ab Java 24 verfügbar, die -XX:AOTCacheOutput-Option, mit der wir die ersten zwei Schritte zu einem kombinieren können, erst ab Java 25).

    Speichere den folgenden Quellcode in der Datei AotTest.java ab:

    void main() {
      var now = LocalDateTime.now();
      var nowString = now.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM));
      System.out.println("Hello, it's " + nowString);
    }Code-Sprache: Java (java)

    Kompiliere den Code (in Java 24 musst du die Optionen --enable-preview --source 24 mit angeben, da sich das o. g. Feature Compact Source Files and Instance Main Methods in Java 24 noch im Preview-Stadium befindet):

    javac --enable-preview --source 24 AotTest.javaCode-Sprache: Klartext (plaintext)

    Erzeuge eine JAR-Datei:

    jar cvf AotTest.jar AotTest.classCode-Sprache: Klartext (plaintext)

    Starte dann den Trainingslauf mit folgendem Aufruf:

    java -XX:AOTMode=record -XX:AOTConfiguration=AotTest.conf \
        --enable-preview -cp AotTest.jar AotTestCode-Sprache: Klartext (plaintext)

    Dadurch wird die Konfigurationsdatei AotTest.conf erzeugt. Diese kannst du mit einem Texteditor öffnen – sie enthält eine lange Liste von Klassen und Daten zu diesen Klassen.

    Danach erzeugst du mit folgendem Aufruf den Klassen-Cache in der Datei AotTest.aot (die Anwendung wird hierbei nicht noch einmal ausgeführt):

    java -XX:AOTMode=create -XX:AOTConfiguration=AotTest.conf -XX:AOTCache=AotTest.aot \
        --enable-preview -cp AotTest.jarCode-Sprache: Klartext (plaintext)

    In Java 25 kannst du Trainingslauf und Erzeugung des Caches (die letzten beiden Schritte vor diesem Info-Block) in einem kombinierten Schritt ausführen. Außerdem ist die Option --enable-preview nicht mehr erforderlich, da Compact Source Files and Instance Main Methods in Java 25 finalisiert wurde:

    java -XX:AOTCacheOutput=AotTest.aot -cp AotTest.jar AotTest

    Und zuletzt startest du die Anwendung und gibst dabei die zu benutztende Cache-Datei an:

    java -XX:AOTCache=AotTest.aot --enable-preview -cp AotTest.jar AotTestCode-Sprache: Klartext (plaintext)

    Lass uns nun einmal die Startzeit der Anwendung mit und ohne Cache vergleichen.

    Im folgenden verwende ich das Linux-Kommando time. Unter Windows kannst du das Tool ptime verwenden, das du über den Package Manager Chocolatey mit dem Kommando choco install ptime installieren kannt.

    Zunächst ein Aufruf ohne Cache:

    time java --enable-preview -cp AotTest.jar AotTestCode-Sprache: Klartext (plaintext)

    Bei fünf Aufrufen ergab sich bei mir eine mittlere Laufzeit von 0,137 Sekunden.

    Und jetzt ein Aufruf mit Cache:

    time java -XX:AOTCache=AotTest.aot --enable-preview -cp AotTest.jar AotTestCode-Sprache: Klartext (plaintext)

    Hier ergab sich aus fünf Aufrufen eine mittlere Laufzeit von 0,086 Sekunden. Das ist eine beeindruckende Leistungssteigerung von 37 %, die nah an die von den Entwicklern gemessenen 42 % herankommt.

    Und was ist mit AppCDS?

    Der aufmerksame Leser wird sich fragen: Was ist der Unterschied zwischen Ahead-of-Time Class Loading & Linking und (Application) Class Data Sharing?

    Class Data Sharing (CDS) existiert bereits seit Java 5 und ermöglicht es, die Klassen des JDK in einem Plattform-spezifischen Binärformat zu speichern, aus dem die Klassen dann deutlich schneller geladen werden können als aus .class-Dateien.

    In Java 10 kam dann Application Class Data Sharing (AppCDS) hinzu, wodurch nicht mehr nur JDK-Klassen, sondern auch Anwendungsklassen in diesem Binärformat gespeichert werden können.

    Tatsächlich baut Ahead-of-Time Class Loading & Linking auf (App)CDS auf. Falls du dir vorhin die Datei AotTest.conf angeschaut hast, ist dir vielleicht aufgefallen, dass der Header sagt, es handle sich um ein „CDS archive dump“.

    Während Class Data Sharing die Klassen lediglich liest, parst und dann in einem Binärformat speichert, werden die Klassen beim AoT Class Loading & Linking zusätzlich – wie der Name schon sagt – in Class-Objekte geladen und gelinkt.

    Die Leyden-Entwickler haben beide Mechanismen mit der Spring PetClinic getestet. Durch AppCDS wurde die Ladezeit der Anwendung um 33 % beschleunigt und durch AoT Class Loading & Linking um 42 %. Falls du also bereits AppCDS einsetzt, wird die Startzeitverbesserung durch AoT Class Loading & Linking nicht mehr ganz so signifikant ausfallen.

    Fazit

    Java ist eine sehr flexible und mächtige Sprache, doch diese Flexibilität kann bei größeren Anwendungen zu Startzeiten im Bereich von mehreren Sekunden bis Minuten führen. Beim Start werden u. a. Java-Klassen gelesen, geparst, geladen und gelinkt.

    Durch Ahead-of-Time Class Loading & Linking können diese Schritte vor dem Start der Anwendung einmalig ausgeführt werden und dadurch der eigentliche Start der Anwendung – nach Aussage der Entwickler dieses Features – um bis zu 42 % beschleunigt werden.

    Durch Ahead-of-Time Method Profiling werden zudem Statistiken über Methodenaufrufe im AOT-Cache gespeichert, so dass direkt nach Start der Anwendung häufig aufgerufene Methoden („Hotspots“) optimiert werden können.

    Falls du dieses Feature ausprobierst, schreib mir gerne deine Erfahrungen in die Kommentare – ich bin gespannt auf deine Messwerte und deine Meinung!

  • Compressed Oops in Java

    Compressed Oops in Java

    In diesem Artikel erfährst du wie durch „Compressed Oops“ auf einem 64-Bit-System Referenzen auf Java-Objekte mit nur 32 statt 64 Bits dargestellt werden können und wie dadurch der Speicherbedarf einer Java-Anwendung signifikant reduziert wird.

    Was ist ein 64-Bit-System?

    Ein 64-Bit-System zeichnet sich dadurch aus, dass Pointer auf Adressen im Arbeitsspeicher 64 Bit groß sind. Dadurch lassen sich 264 Bytes = 16 Exabyte = 18.446.744.073.709.551.616 Bytes adressieren.

    Heutzutage gibt es praktisch keine Programme mit diesem Speicherbedarf. In zehn Jahren werden wir über diese Aussage vielleicht schmunzeln ;-)

    Hier die grafische Darstellung eines solchen 64-Bit-Pointers:

    64-Bit-Pointer

    Nun könnte man überlegen, diese 64 Bit in der Mitte zu teilen und mit der gleichen Speichermenge nicht nur einen, sondern zwei Pointer zu speichern:

    Zwei 32-Bit-Pointer

    Mit 32 Bit lassen sich allerdings nur 232 Bytes = 4 GB adressieren. Das wiederum ist für viele Anwendungen zu wenig.

    Die Java-Entwickler haben sich daher einen cleveren Trick („Compressed Oops“) einfallen lassen, um mit 32 Bit nicht nur 4 GB sondern 32 GB zu adressieren. Und das wiederum ist für die meisten Anwendungen unserer Zeit ausreichend.

    Wie funktionieren Compressed Oops?

    Durch Compressed Oops (OOP steht für „ordinary object pointer“) kann mit 32-Bit-Pointern ein Adressraum von 235 = 32 Gigabyte adressiert werden.

    Wie ist das möglich? Um 32 Gigabyte zu adressieren, bräuchten wir doch eigentlich 35 Bits. 35 Bits können wir aber nicht in einem 32-Bit-int speichern, dafür bräuchten wir die nächstgrößere Datenstruktur, ein 64-Bit-long:

    35-Bit-Pointer

    Wir hätten also 29 Bit verschwendet. Dann hätten wir auch bei 64-Bit-Pointern bleiben können.

    Es wird Zeit einen Trick anzuwenden!

    Zunächst einmal positionieren wir alle Java-Objekte an durch acht teilbare Speicheradressen. Acht ist 23, d. h. die letzten 3 Bits eines Pointers sind immer 0. Somit enthalten also nur die oberen 32 Bit relevante Informationen:

    32 relevante Bits in einem 35-Bit-Pointer

    Da wir wissen, dass die letzten drei Bits immer 0 sind, brauchen wir diese nicht jedes Mal mit abzuspeichern. Die 35-Bit-Speicheradresse kann daher ohne Informationsverlust um 3 Bit nach rechts verschoben und somit in einem 32-Bit-Feld gespeichert werden:

    Compressed OOP in Java: auf 32 Bit komprimierter 35-Bit-Pointer

    Um später wieder auf die unkomprimierte Speicheradresse zugreifen zu können, müssen die 32 Bit lediglich wieder um 3 Bit nach links geschoben werden.

    Compressed Oops sind standardmäßig aktiviert

    Auf einem 64-Bit-System mit maximal 32 GB Heap sind Compressed OOPs standardmäßig aktiviert. Bei mehr als 32 GB können Compressed Oops nicht verwendet werden, da sie eben nur 32 GB adressieren können.

    Compressed Oops können mit folgender VM-Option deaktiviert werden:

    -XX:-UseCompressedOops

    Warum sollte man das tun?

    Das Komprimieren und Dekomprimieren der Pointer kostet Zeit. Nicht viel, da die dafür benötigte Shift-Operation in der Regel in einem einzigen CPU-Zyklus durchgeführt werden kann. Doch wer das letzte Quänt­chen Performance aus seiner Anwendung herauskitzeln will und den dafür erhöhten Speicherbedarf in Kauf nehmen will (alle Pointer belegen 64 statt 32 Bits), der kann diese Möglichkeit in Betracht ziehen.

    Bis Java 14 war die Aktivierung von Compressed Class Pointers an die Aktivierung von Compressed OOPs gekoppelt. Wenn man Compressed OOPs deaktivierte, wurden automatisch auch Compressed Class Pointers deaktiviert. Da es keinen Grund für diese Kopplung gab, wurde sie in Java 15 aufgehoben.

    Fazit

    Mit Compressed Oops lassen sich auf einem 64-Bit-System mit einer Heapgröße von maximal 32 GB Speicheradressen mit nur 32 statt 64 Bits kodieren. Das es auf jedes aktive Java-Objekt in einer Anwendung mindestens einen Pointer gibt, kann somit der Speicherbedarf einer Anwendung signifikant reduziert werden.

  • Java Object Headers und Compressed Class Pointers​

    Java Object Headers und Compressed Class Pointers​

    Jedes Java-Objekt enthält im Arbeitsspeicher nicht nur die eigentlichen Daten, sondern dazu auch einen sogenannten „Object Header“, der vor den Daten steht. Dieser Header enthält z. B. den Identity Hash Code eines Objekts, Informationen über das Alter des Objekts und einen Verweis auf die Klasse, die durch dieses Objekt instanziiert wurde.

    In diesem Artikel erfährst du:

    • Wie ist der Object Header aufgebaut?
    • Was sind Mark Word und Class Word?
    • Was ist der Compressed Class Space?
    • Wie können Compressed Class Pointers auf einem 64-Bit-System mit nur 32 Bit dargestellt werden?

    Dieser Artikel beschreibt den Aufbau des Objekt-Headers zum Stand von Java 24, d. h. bevor dieser durch sogenannte „Compact Object Headers“ weiter komprimiert wird. Compact Object Headers werden im Rahmen von Project Lilliput erstmals in Java 24 aktiviert werden können.

    Aufbau des Java Objekt-Headers

    Der Java Object Header besteht aus einem sogenannten „Mark Word“ und einem „Class Word“. Auf einem 64-Bit-System mit nicht komprimierten Klassen-Pointern belegt der Header 128 Bit – also 16 Byte – und hat folgenden Aufbau:

    Java Object Header: 64-Bit Mark Word und 64-Bit Class Word, gesamt: 128 Bit

    Mit komprimierten Klassen-Pointern (was das genau bedeutet, erkläre ich dir im Abschnitt Compressed Class Pointers) ist das Class Word nur 32 Bit lang – und damit der gesamte Objekt-Header nicht mehr 128 Bit, sondern nur noch 96 Bit – also 12 Byte:

    Java Object Header: 64-Bit Mark Word und 32-Bit komprimiertes Class Word, gesamt: 96 Bit

    Welche Daten enthalten Mark Word und Class Word, und wie sind sie aufgebaut?

    Mark Word

    Schauen wir uns zunächst das Mark Word im Detail an (beachte, dass ich den Maßstab gegenüber den vorherigen Grafiken geändert habe, um die Details besser darzustellen):

    Java Mark Word Layout

    Das Mark Word enthält in der Regel die folgenden Informationen:

    • 25 ungenutzte Bits.
    • 31 Bit für den Identity Hash Code des Objekts – das ist der Wert, der durch System.identityHashCode(Object) zurückgegeben wird.
    • 1 ungenutztes Bit – dieses wurde durch den bereits in Java 14 entfernten Concurrent Mark Sweep (CMS) Garbage Collector bei Komprimierung des Class Words (s. u.) genutzt.
    • 4 Bits, in denen der Garbage Collector das Alter des Objekts speichert, anhand dessen er entscheidet, wann ein Objekt von der jungen in die alte Generation verschoben wird.
    • 1 ungenutztes Bit – dieses wurde für das in Java 15 deaktivierte Biased Locking verwendet.
    • 2 Bits für den Lock-Zustand des Objekts („Tag Bits“).

    Beim veralteten „Legacy Stack Locking“ konnte sich das Mark Word auch ändern – wie, das erkläre ich im nächtsen Abschnitt.

    Legacy Stack Locking

    Beim „Legacy Stack Locking“ (dem Standard-Locking-Mechanismus bis Java 22) werden im gelockten Zustand (d. h. wenn sich ein Thread innerhalb eines synchronized-Blocks befindet, der auf diesem Objekt definiert wurde), die ersten 62 Bit des Mark Words durch einen Pointer auf eine zusätzliche Datenstruktur auf dem Stack ersetzt:

    Java Mark Word in locked state

    Diese Datenstruktur enthält dann wiederum das eigentliche Mark Word sowie weitere Informationen über das Lock, wie z. B. eine Liste der Threads, die blockiert wurden und darauf warten, den synchronized-Block betreten zu dürfen.

    Der Pointer zu dieser separaten Datenstruktur ist übrigens einer von zwei Gründen für das Pinning bei virtuellen Threads: Ein virtueller Thread, der innerhalb eines synchronized-Blocks blockierenden Code aufruft, darf nicht vom Carrier Thread gelöst werden.

    Denn wenn der virtuelle Thread danach auf einem anderen Carrier Thread weiterlaufen würde (dessen Stack an einer anderen Adresse im Speicher liegt), wäre dieser Pointer nicht mehr gültig.

    Da das „Legacy Stack Locking“ den Zugriff auf die eigentlichen Daten des Mark Words erschwerte und mit ein Grund für das o. g. Pinning war, wurde es durch das modernere „Lightweight Locking“ ersetzt.

    Lightweight Locking

    Seit Java 21 gibt es das sogenannte „Lightweight Locking“, das ohne Änderung des Mark Words auskommt. Seit Java 23 ist dies der Standard-Modus.

    Beim Lightweight Locking werden – sofern kein anderer Thread den kritischen Bereich betreten will – lediglich die Tag Bits (die letzten zwei Bits des Mark Words) von 0x01 (unlocked) auf 0x00 (lightweight-locked) geändert. Es wird keine zusätzliche Datenstruktur angelegt.

    Erst wenn ein weiterer Thread versucht den kritischen Bereich zu betreten, wird das Lock „inflated“ (auf deutsch könnte man sagen: „aufgebläht“):

    • Eine zusätzliche Datenstruktur, die u. a. eine Liste der wartenden Threads enthält, wird erstellt.
    • Der Pointer auf diese Datenstruktur wird in einer separaten Hashtable abgelegt und nicht mehr im Mark Word – die Inflation des Locks wird dort lediglich durch die Änderung der Tag Bits auf 0x10 angezeigt.

    Lightweight Locking ist also eine effizientere Möglichkeit zur Synchronisierung von Threads, indem es die Änderung des Mark Words überflüssig macht und den Overhead unnötiger Monitor-Objekte in Szenarien ohne Thread Contention (d. h. dass keine Threads auf andere warten) reduziert.

    Class Word

    Das Class Word (manchmal auch „Klass Word“) ist schnell erklärt:

    Es enthält einen Pointer auf die sogenannte Klass-Datenstruktur im Metaspace – einem Speicherbereich außerhalb des Java-Heaps. Diese Datenstruktur enthält alle relevanten Informationen über die Klasse des Objekts. Alle Objekte derselben Klasse, z. B. alle ArrayList-Objekte, zeigen auf dieselbe Klass-Datenstruktur.

    Auf einem 64-Bit-System ist auch dieser Pointer (sofern er nicht komprimiert wird – dazu kommen wir gleich) 64 Bit groß:

    Java Class Word, nicht komprimiert, 64 Bits

    Mit diesen 64 Bit lassen sich 16 EB (16 Exabyte = 18.446.744.073.709.551.616 Bytes) adressieren. Eine Klass-Datenstruktur ist in der Regel zwischen einem halben und einem Kilobyte groß. Mit 64 Bit könnten wir also 264 / 768 = 24 Billiarden Klassen referenzieren. Das ist eine Zahl, die vermutlich auch in 30 Jahren noch sehr groß erscheint.

    Daher wurden der sogenannte „Compressed Class Space“ und „Compressed Class Pointers“ eingeführt, die ich in den nächsten zwei Abschnitten beschreiben werde.

    Compressed Class Space

    Der „Compressed Class Space“ ist ein zusammenhängender Speicherblock innerhalb des Metaspaces (einem Speicherbereich außerhalb des Heaps), in dem alle Klass-Datenstrukturen abgelegt sind. Dieser Bereich wird beim Start eines Java-Programms allokiert, und seine Größe kann sich während der Laufzeit nicht ändern.

    Java Speicherlayout: Heap, Metaspace, Compressed Class Space, C Heap, Thread Stack

    Standardmäßig ist der Compressed Class Space 1 GB groß. Seine Größe kann mit der folgenden VM-Option geändert werden:

    -XX:CompressedClassSpaceSize=<size>

    Erlaubt sind Werte zwischen 1 MB und 4 GB.

    Der Name „Compressed Class Space“ ist irreführend, da nicht die Klass-Datenstrukturen selbst komprimiert sind, sondern die Pointer vom Class Word des Object Headers auf diese Klass-Datenstrukturen. Dazu mehr im nächsten Abschnitt.

    Compressed Class Pointers

    Wie im vorherigen Abschnitt erwähnt, kann der Compressed Class Space maximal 4 GB groß sein. Um 4 GB zu adressieren, genügen 32 Bit (232 = 4.294.967.296).

    Ein Compressed Class Pointer ist somit ein 32-Bit-Wert, der die Adresse der Klass-Datenstruktur als Offset innerhalb des Compressed Class Spaces beschreibt:

    Java Class Word, 32 Bits, mit Compressed Class Pointer

    Die tatsächliche Adresse der Klass-Datenstruktur ergibt sich durch Addition der Startadresse des Compressed Class Spaces und dieses Offsets.

    Compressed Class Pointers sind standardmäßig aktiviert

    Auf einem 64-Bit-System sind komprimierte Klassen-Pointer standardmäßig aktiviert. Du kannst sie mit folgender Option deaktivieren:

    -XX:-UseCompressedClassPointers

    Es gibt allerdings keinen Grund das zu tun, denn mit komprimierten Pointern lassen sich ca. 6 Millionen Klassen adressieren – und selbst große Java-Anwendungen benutzen selten mehr als 100.000 Klassen. Aus diesem Grund wird diese Option in Java 25 als deprecated markiert.

    Bis Java 14 war die Aktivierung von Compressed Class Pointers an die Aktivierung von Compressed OOPs (komprimierte Objekt-Pointer) gekoppelt. Wenn man Compressed OOPs deaktivierte, wurden automatisch auch Compressed Class Pointers deaktiviert. Da es keinen Grund für diese Kopplung gab, wurde sie in Java 15 aufgehoben.

    Ausblick: Compact Object Headers

    Seit mehreren Jahren wird im Rahmen von Project Lilliput daran gearbeitet, die Objekt-Header in Java weiter zu verkleinern – zunächst auf 64 Bit, später eventuell sogar auf 32 Bit.

    Der erste Meilenstein ist erreicht. In Java 25 werden Compact Object Headers veröffentlicht, und damit kann die Header-Größe auf 64 Bit reduziert werden.

  • Java Foreign Function & Memory API (FFM API)

    Java Foreign Function & Memory API (FFM API)

    Nach vielen Jahren der Entwicklung im Rahmen von Project Panama wurde die finale Version der „Foreign Function & Memory API” im März 2024 mit Java 22 veröffentlicht.

    In diesem Artikel erfährst du:

    • Was ist die Foreign Function & Memory API?
    • Was ist der Unterschied zwischen FFM API und JNI?
    • Wie ruft man mit der FFM API fremden Code auf?
    • Wir schreibt und liest man mit der FFM API fremden Speicher?
    • Welche Bedeutung haben die Begriffe Arena, Memory Segment, Memory Layout und Function Descriptor?

    Den Quellcode zum Artikel findest du in diesem GitHub-Repository.

    Was ist die Foreign Function & Memory API?

    Die Foreign Function & Memory API (kurz: FFM API) ermöglicht es Java-Entwicklerinnen und -Entwicklern, Funktionen aus Libraries, die in anderen Programmiersprachen geschrieben wurden (z. B. die Standard-C-Library), unkompliziert aus Java heraus aufzurufen.

    Die FFM API ermöglicht es außerdem, aus Java heraus sicher auf Speicher zuzugreifen, der nicht von der JVM verwaltet wird, also Speicher außerhalb des Java-Heaps.

    Wie das funktioniert, zeige ich dir im übernächsten Abschnitt. Zunächst solltest du wissen, warum die FFM API überhaupt entwickelt wurde.

    Unterschied zwischen der FFM API und JNI

    Um bisher auf nativen Code – also Code außerhalb der JVM – zuzugreifen, mussten Java-Entwickler das seit Java 1.1 existierende Java Native Interface (JNI) einsetzen. Wer das schon einmal gemacht hat, weiß, dass das keine angenehme Aufgabe ist:

    • JNI ist umständlich zu benutzen: Man muss viel Java- und C-Boilerplate-Code schreiben und diesen mit Änderungen im nativen Code synchronisieren. Dafür wurden zwar Tools bereitgestellt, doch die erleichtern die Aufgabe nur marginal.
    • JNI ist fehleranfällig: Fehler beim Zugriff auf nativen Speicher können die JVM leicht zum Absturz bringen.
    • JNI ist extrem langsam.

    Die FFM API hingegen ist:

    • Einfach zu benutzen, wie du im folgenden Abschnitt sehen wirst. Der Implementierungsaufwand wurde mit der modernen FFM API gegenüber JNI laut Aussage der Panama-Entwickler um 90 % reduziert.
    • Sicher: Zugriffe auf nativen Speicher werden durch sogenannte Arenen verwaltet, die sicherstellen, dass Speicheradressen gültig sind und die andersfalls eine Exception werfen (anstatt die JVM abstürzen zu lassen).
    • Schnell: Die FFM API soll um Faktor vier bis fünf schneller sein als JNI.

    Mit der Veröffentlichung der FFM API durch JDK Enhancement Proposal 454 gibt es nun keinen Grund mehr, JNI zu verwenden.

    Kommen wir nun zur spannenden Frage: Wie funktioniert die FFM API?

    Foreign Function & Memory API – Beispiele

    Die neue API lässt sich am besten anhand von Beispielen erklären. Ich zeige dir zunächst ein einfaches Beispiel, das die strlen()-Funktion der Standard-C-Library aufruft. Danach folgt ein komplexeres Beispiel, das die C-qsort()-Funktion aufruft, welche wiederum eine Java-Callback-Funktion zum Vergleich zweier Elemente aufruft.

    Im Anschluss werde ich dir die eingesetzten Komponenten der Foreign Function & Memory API detaillierter erklären.

    Beispiel 1: strlen()-Funktion der Standard-C-Library

    Beginnen wir mit einem sehr einfachen Beispiel (du findest es in der Klasse FFMTestStrlen im GitHub-Repository). Der folgende Code verwendet die strlen()-Methode der Standard-C-Library, um die Länge des Strings „Happy Coding!” zu berechnen.

    Schauen wir uns einmal die Definition dieser C-Funktion an:

    std::size_t strlen( const char* str );Code-Sprache: C++ (cpp)

    Die Methode hat einen Parameter:

    • str – Zeiger auf den zu untersuchenden, Null-terminierten String

    Der Rückgabetyp, size_t, steht für einen unsignierten Integer.

    Ich zeige dir erstmal das Programm zum Aufruf dieser Methode. Eine kurze Erläuterung der einzelnen Schritte findest du in den Kommentaren, eine ausführlichere Erklärung unterhalb des Programmcodes.

    public class FFMTestStrlen {
      public static void main(String[] args) throws Throwable {
        // 1. Get a linker – the central element for accessing foreign functions
        Linker linker = Linker.nativeLinker();
    
        // 2. Get a lookup object for commonly used libraries
        SymbolLookup stdlib = linker.defaultLookup();
    
        // 3. Get the address of the "strlen" function in the C standard library
        MemorySegment strlenAddress = stdlib.find("strlen").orElseThrow();
    
        // 4. Define the input and output parameters of the "strlen" function
        FunctionDescriptor descriptor =
            FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS);
    
        // 5. Get a handle to the "strlen" function
        MethodHandle strlen = linker.downcallHandle(strlenAddress, descriptor);
    
        // 6. Get a confined memory area (one that we can close explicitly)
        try (Arena offHeap = Arena.ofConfined()) {
    
          // 7. Convert the Java String to a C string and store it in off-heap memory
          MemorySegment str = offHeap.allocateFrom("Happy Coding!");
    
          // 8. Invoke the "strlen" function
          long len = (long) strlen.invoke(str);
          System.out.println("len = " + len);
        }
        // 9. Off-heap memory is deallocated at end of try-with-resources
      }
    }
    Code-Sprache: Java (java)

    Was genau passiert in diesem Code? (Die folgende Nummerierung verweist auf die entsprechenden Kommentare im Quellcode.)

    1. Über die statische Methode Linker.nativeLinker() bekommen wir einen Linker – die zentrale Komponente, die den Zugriff auf fremde Funktionen orchestriert.
    2. Über Linker.defaultLookup() lassen wir uns ein SymbolLookup-Objekt liefern, über das wir die Speicheradressen von Methoden häufig verwendeter Bibliotheken abrufen können. Welche Bibliotheken das sind, hängt von Betriebssystem und CPU ab.
    3. Mit SymbolLookup.find(...) fragen wir nach der Speicheradresse der „strlib”-Funktion. Die Methode liefert ein Optional<MemorySegment> zurück, welches leer ist, sollte die Methode nicht existieren.
    4. Mit einem sogenannten Function Descriptor geben wir an, welche Ein- und Ausgabeparameter die strlib()-Methode hat. Das erste Argument, ValueLayout.JAVA_LONG, definiert den Rückgabetyp der Methode. Das zweite Argument, ValueLayout.ADDRESS, definiert den Typ des ersten (und einzigen) Methodenparameters als Speicheradresse (die des Strings, dessen Länge wir bestimmen wollen). Der Funktionsdeskriptor wird beim Aufruf der nativen Funktion sicherstellen, dass Java-Typen ordnungsgemäß in C-Typen umgewandelt werden und umgekehrt.
    5. Die Methode Linker.downcallHandle(...) liefert uns ein MethodHandle für die Methode an der angegebenen Speicheradresse und den zuvor definierten Funktionsdeskriptor. Method Handles sind nichts Neues – es gibt sie bereits seit Java 7.
    6. Arena.ofConfined() liefert uns eine sogenannte Arena – ein Objekt, das den Zugriff auf nativen Speicher verwaltet – mehr dazu später.
    7. Arena.allocateFrom(...) reserviert einen nativen Speicherblock und legt dort die Zeichenkette „Happy Coding!” im UTF-8-Format ab.
    8. Mit MethodHandle.invoke(...) rufen wir die C-strlen()-Methode auf; das Ergebnis casten wir zu einem long (der in Schritt 3 definierte Function Descriptor stellt sicher, dass wir das tun können).
    9. Am Ende des try-with-resources-Block wird Arena.close() aufgerufen und damit alle Speicherblöcke, die über diese Arena verwaltet werden, freigegeben.

    Die hier gezeigten Elemente der Foreign Function & Memory API – Memory Segment, Arena, Value Layout und Function Descriptor – werden im Kapitel Komponenten der FFM API noch einmal näher beschrieben.

    Start des Beispiel-Programms

    Wenn du den Quellcode in der Datei FFMTestStrlen.java speicherst, kannst du ihn wie folgt ausführen:

    $ java FFMTestStrlen.java 
    WARNING: A restricted method in java.lang.foreign.Linker has been called
    WARNING: java.lang.foreign.Linker::downcallHandle has been called by eu.happycoders.java22.ffm.FFMTestStrlen in an unnamed module
    WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
    WARNING: Restricted methods will be blocked in a future release unless native access is enabled
    
    len = 13Code-Sprache: Klartext (plaintext)

    Um die Warnung zu unterdrücken, musst du das Programm wie folgt starten:

    $ java --enable-native-access=ALL-UNNAMED FFMTestStrlen.java
    len = 13Code-Sprache: Klartext (plaintext)

    Die String-Länge wurde korrekt berechnet!

    Beispiel 2: qsort()-Funktion der Standard-C-Library

    Als nächstes wollen wir uns an ein komplexeres Beispiel wagen. Wir wollen mit der qsort()-Funktion ein Array von Integers sortieren. Wir müssen dazu erstmal einen Blick auf die Definiton dieser Funktion werfen:

    void qsort( void *ptr, size_t count, size_t size,
                int (*comp)(const void *, const void *) );Code-Sprache: C++ (cpp)

    Die Methode verwendet die folgenden Parameter:

    • ptr – Zeiger auf das zu sortierende Array
    • count – Anzahl der Elemente im Array
    • size – Größe der einzelnen Elemente des Arrays in Bytes
    • comp – Vergleichsfunktion, die einen negativen ganzzahligen Wert zurückgibt, wenn das erste Argument kleiner als das zweite ist, einen positiven ganzzahligen Wert, wenn das erste Argument größer als das zweite ist, und Null, wenn die Argumente gleich sind.

    Signatur der Vergleichsfunktion:

    int cmp(const void *a, const void *b);Code-Sprache: C++ (cpp)

    Ich zeige dir wieder zunächst den vollständigen Programmcode mit Kommentaren. Im Anschluss erkläre ich dir die neuen Komponenten dieses Beispiels ausführlicher.

    public class FFMTestQsort {
      public static void main(String[] args) throws Throwable {
        // 1. Get a linker - the central element for accessing foreign functions
        Linker linker = Linker.nativeLinker();
    
        // 2. Get a lookup object for commonly used libraries
        SymbolLookup stdlib = linker.defaultLookup();
    
        // 3. Get the address of the "qsort" function in the C standard library
        MemorySegment qsortAddress = stdlib.find("qsort").orElseThrow();
    
        // 4. Define the input and output parameters of the "qsort" function:
        FunctionDescriptor qsortDescriptor =
            FunctionDescriptor.ofVoid(
                ValueLayout.ADDRESS, 
                ValueLayout.JAVA_LONG,
                ValueLayout.JAVA_LONG,
                ValueLayout.ADDRESS);
    
        // 5. Get a method handle to the "qsort" function
        MethodHandle qsortHandle = linker.downcallHandle(qsortAddress, qsortDescriptor);
    
        // 6. Define the input and output parameters of the "compare" function:
        FunctionDescriptor compareDescriptor =
            FunctionDescriptor.of(
                ValueLayout.JAVA_INT,
                ValueLayout.ADDRESS.withTargetLayout(ValueLayout.JAVA_INT),
                ValueLayout.ADDRESS.withTargetLayout(ValueLayout.JAVA_INT));
    
        // 7. Get a handle to the "compare" function
        MethodHandle compareHandle =
            MethodHandles.lookup()
                .findStatic(FFMTestQsort.class, "compare", compareDescriptor.toMethodType());
    
        // 8. Get a confined memory area (one that we can close explicitly)
        try (Arena offHeap = Arena.ofConfined()) {
          // 9. Allocate off-heap memory and store unsorted array in it
          int[] unsorted = createUnsortedArray();
          MemorySegment arrayAddress = offHeap.allocateFrom(ValueLayout.JAVA_INT, unsorted);
    
          // 10. Allocate off-head memory for an "upcall stub" to the comparison function
          MemorySegment compareAddress =
              linker.upcallStub(compareHandle, compareDescriptor, offHeap);
    
          // 11. Invoke the qsort function
          qsortHandle.invoke(
              arrayAddress, 
              unsorted.length, 
              ValueLayout.JAVA_INT.byteSize(), 
              compareAddress);
    
          // 12. Read array from off-heap memory
          int[] sorted = arrayAddress.toArray(ValueLayout.JAVA_INT);
          System.out.println("sorted   = " + Arrays.toString(sorted));
        }
        // 13. Off-heap memory is deallocated at end of try-with-resources
      }
    
      private static int compare(MemorySegment aAddr, MemorySegment bAddr) {
        int a = aAddr.get(ValueLayout.JAVA_INT, 0);
        int b = bAddr.get(ValueLayout.JAVA_INT, 0);
        return Integer.compare(a, b);
      }
    
      private static int[] createUnsortedArray() {
        ThreadLocalRandom random = ThreadLocalRandom.current();
        int[] unsorted = IntStream.generate(() -> random.nextInt(1000)).limit(10).toArray();
        System.out.println("unsorted = " + Arrays.toString(unsorted));
        return unsorted;
      }
    }Code-Sprache: Java (java)

    Die Besonderheiten dieses Programms im Vergleich zum vorherigen:

    • Schritt 4: Für den Funktionsdescriptor verwenden wir die Methode FunctionDescriptor.ofVoid(...), da qsort(...) keinen Rückgabewert hat. Wir geben die folgenden Argumente an:
      • ValueLayout.ADDRESS – für den Zeiger auf das zu sortierende Array
      • ValueLayout.JAVA_LONG – für die Anzahl der Elemente im Array
      • ValueLayout.JAVA_LONG – für die Größe der einzelnen Array-Elemente
      • ValueLayout.ADDRESS – für die Adresse der Vergleichsfunktion
    • Schritt 6: Hier definieren wir einen Funktionsdeskriptor für die Vergleichsfunktion: das erste Argument, ValueLayout.JAVA_INT, gibt den Rückgabetyp an; das zweite und dritte Argument, jeweils ValueLayout.ADDRESS.withTargetLayout(ValueLayout.JAVA_INT) stehen für die Speicheradressen jeweils zweier zu vergleichender Array-Elemente.
    • Schritt 7: Hier lassen wir uns ein Method Handle für die Vergleichsfunktion generieren.
    • Schritt 9: Mit der Methode Arena.allocateFrom(...) allokieren wir Off-Heap-Speicher für ein Integer-Array und speichern darin das übergebene Array.
    • Schritt 10: Mit Linker.upcallStub(...) allokieren wir Off-Heap-Speicher für einen sogenannten „Upcall Stub” für die Vergleichsfunktion. Über diesen Stub kann später die C-Funktion die Java-Callback-Methode compare(...) aufrufen.
    • Schritt 11: Die Adresse dieses Stubs geben wir als viertes Argument beim Aufruf der qsort(...)-Methode an.
    • Schritt 12: Mit MemorySegment.toArray(...) wandeln wir das an der Off-Heap-Speicheradresse arrayAddress gespeicherte Array zurück in ein Java-Array.

    Du findest den vollständigen Programmcode in der Klasse FFMTestQsort im GitHub-Repository.

    Start des Beispiel-Programms

    Wir starten das Programm wie folgt:

    $ java --enable-native-access=ALL-UNNAMED FFMTestQsort.java
    unsorted = [696, 788, 659, 413, 933, 143, 93, 200, 736, 300]
    sorted   = [93, 143, 200, 300, 413, 659, 696, 736, 788, 933]Code-Sprache: Klartext (plaintext)

    Unser Programm hat erfolgreich zehn Zahlen mit qsort() sortiert.

    Komponenten der FFM API

    Anhand der Beispiele hast du die wichtigsten Komponenten der Foreign Function & Memory API – Arena, Memory Segment, Function Descriptor und Value Layout – kennengelernt. In diesem Kapitel gehe ich detaillierter auf diese Komponenten ein.

    Arena

    Eine Arena verwaltet den Zugriff auf nativen Speicher und stellt sicher, dass allokierte Speicherblöcke wieder freigegeben werden und dass wir nicht auf bereits freigegebenen Speicher zugreifen.

    Es gibt vier Typen von Arenen, die wir über statische Factory-Methoden der Arena-Klasse erzeugen können:

    • die globale („global”) Arena,
    • vom Garbage Collector automatisch verwaltete („auto”) Arenen,
    • beschränkte („confined”) Arenen und
    • geteilte („shared”) Arenen.

    In den folgenden Abschnitten lernst du die Unterschiede der verschiedenen Typen kennen.

    Globale Arena

    Von der globalen Arena existiert nur eine einzige Instanz, die von allen Anwendungsthreads geteilt wird. In der globalen Arena allokierte Speichersegmente werden erst beim Beenden der JVM wieder freigegeben.

    Die globale Arena erhälst du wie folgt:

    Arena arena = Arena.global();Code-Sprache: Java (java)

    Du kannst die globale Arena nicht schließen. Ein Aufruf von Arena.global().close() resultiert in einer UnsupportedOperationException.

    Automatische Arena

    In einer automatischen Arena allokierte Speichersegmente werden vom Garbage Collector freigegeben, sobald keine Refenzen auf die entsprechenden MemorySegment-Objekte mehr existieren.

    Eine automatische Arena kann ebenfalls von allen Anwendungsthreads verwendet werden. Du erzeugst sie wie folgt:

    Arena arena = Arena.ofAuto();Code-Sprache: Java (java)

    Beachte, dass jeder Aufruf von Arena.ofAuto() eine neue automatische Arena erzeugt.

    Eine automatische Arena wird dann geschlossen, wenn keine Referenzen mehr auf die Arena selbst und auf alle über sie allokierten Speichersegmente existieren. Ein manueller Aufruf von Arena.global().close() führt zu einer UnsupportedOperationException.

    Beschränkte („confined”) Arena

    Eine automatische Arena hat den Nachteil, dass das Deallokieren der Speichersegmente nicht deterministisch ist. Es passiert erst dann, wenn der Garbage Collector läuft und feststellt, dass es keine Referenzen mehr auf diese gibt.

    Es gibt Anwendungsfälle, in denen wir selbst entscheiden wollen, wann der über eine Arena allokierte Speicher freigegeben wird. Dafür gibt es die sogenannten beschränkten („confined”) Arenen, wie wir sie auch in der Beispiel-Anwendung verwendet haben.

    Die von einer beschränkten Arena allokierten Speichersegmente werden dann freigegeben, wenn die Arena durch den Aufruf von close() geschlossen wird. Da die Arena-Klasse auto-closeable ist, sollten wir die Arena in einem try-with-resources Block erzeugen:

    try (Arena arena = Arena.ofConfined()) {
      . . .
    }Code-Sprache: Java (java)

    Alle innerhalb dieses Blocks allokierten Speichersegmente werden am Ende des Blocks durch den impliziten Aufruf von arena.close() freigegeben.

    Der Versuch eine bereits geschlossene Arena zu verwenden, führt zu einer IllegalStateException.

    Beschränkte Arenen dürfen nur in dem Threads verwendet werden, in dem sie erzeugt wurden.

    Geteilte („shared”) Arena

    Eine geteilte Arena kombiniert die Vorteile der beschränkten Arena (deterministische Lebenszeit der Speichersegmente) mit der Möglichkeit, aus mehreren Threads verwendet zu werden. Du erzeugst eine geteilte Arena wie folgt:

    Arena arena = Arena.ofShared()Code-Sprache: Java (java)

    Eine geteilte Arena wird geschlossen, sobald ein beliebiger Thread deren close()-Methode aufruft. Sollte danach ein anderer Thread versuchen, die Arena zu verwenden, kommt es zu einer IllegalStateException.

    MemorySegment

    Ein MemorySegment ist ein Objekt, dass einen zusammenhängenden Speicherbereich beschreibt. Ein Memory Segment kann auf verschiedene Arten allokiert werden. Die Arena-Klasse bietet dazu u. a. folgende Methoden an:

    • Arena.allocateFrom(String str)
      allokiert ein Memory Segment und speichert darin den übergebenen String als UTF-8-kodierte Bytefolge. Diese Methode haben wir im Beispiel oben verwendet.
    • allocate(long byteSize)
      allokiert ein Speichersegment der angegebenen Größe.
    • allocate(MemoryLayout elementLayout)
      allocate(MemoryLayout elementLayout, long count)
      allokieren ein Speichersegment, dessen Größe genau auf eine bestimmte Anzahl (1 in der ersten Variante, count in der zweiten Variante) von Objekten eines bestimmten Typs (definiert durch elementLayout) abgestimmt ist. Die MemoryLayout-Klasse beschreibe ich im nächsten Abschnitt.

    Eine vollständige Übersicht aller Methoden zum Allokieren von Speichersegmenten findest du in der JavaDoc-Dokumentation von Arena und SegmentAllocator.

    MemoryLayout

    Ein MemoryLayout definiert den Speicheraufbau eines bestimmten Typs, wobei dieser Typ auch eine Kombination anderer Typen sein kann (z. B. ein Array oder Struct).

    ValueLayout

    ValueLayout ist eine Unterklasse von MemoryLayout, die definiert, wie grundlegende Datentypen wie z. B. int, long und double im Speicher repräsentiert werden.

    Im Beispiel haben wir mit ValueLayout.JAVA_LONG den primitiven Java-Typ long beschrieben und mit ValueLayout.ADDRESS eine Speicheradresse der zugrunde liegenden Hardware.

    SequenceLayout

    Ein SequenceLayout, ebenfalls eine Unterklasse von MemoryLayout, beschreibt ein Array eines bestimmten Typs, wobei dieser Typ wiederum durch ein MemoryLayout beschrieben wird. Der folgende Code definiert z. B. ein Array mit zehn Java-Doubles:

    MemoryLayout.sequenceLayout(10, ValueLayout.JAVA_DOUBLE);Code-Sprache: Java (java)

    Und der folgende Code definiert das Speicherlayout für ein Array bestehend aus drei Arrays zu je zehn Integer-Arrays:

    MemoryLayout.sequenceLayout(3, 
        MemoryLayout.sequenceLayout(10, ValueLayout.JAVA_INT));

    StructLayout

    Ein StructLayout, auch eine Unterklasse von MemoryLayout, beschreibt das Speicherlayout eines Structs, also eines Speicherbereichs, in dem verschiedene Datentypen hintereinander abgelegt abgelegt sind. Die Elemente des Structs haben einen Namen und wiederum ein MemoryLayout. Der Name wird nicht mit gespeichert, sondern wird dafür verwendet, um auf die Elemente des Structs zuzugreifen.

    Der folgende Code beschreibt das Speicherlayout für ein Struct, das ein Jahr, einen Monat und einen Tag enthält:

    MemoryLayout.structLayout(
        ValueLayout.JAVA_SHORT.withName("year"),
        ValueLayout.JAVA_SHORT.withName("month"), 
        ValueLayout.JAVA_SHORT.withName("day"));Code-Sprache: Java (java)

    Ein Struct kann auch Arrays oder wiederum Structs enthalten.

    FunctionDescriptor

    Mit einem FunctionDescriptor beschreiben wir die Ein- und Ausgabeparameter einer nativen Funktion. Beim Aufruf einer nativen Funktion über ein Method Handle sorgt der Funktionsdeskriptor dafür, dass die übergebenen Java-Typen in die korrekten C-Typen umgewandelt werden und der Rückgabewert von einem C-Typen in den gewünschten Java-Rückgabewert.

    Die Klasse FunctionDescriptor hat zwei statische Methoden:

    • of(MemoryLayout resLayout, MemoryLayout... argLayouts)
      erzeugt einen Function Descriptor mit dem durch resLayout definierten Rückgabetyp und den durch argLayouts definierten Eingabetypen.
    • ofVoid(MemoryLayout... argLayouts)
      erzeugt einen Function Descriptor ohne Rückgabetyp und mit den durch argLayouts definierten Eingabetypen.

    Du hast nun die Grundelemente der Foreign Function & Memory API kennengelernt. Wie diese Elemente zusammenarbeiten, um Speicherbereiche zu schreiben und zu lesen, erfährst du im folgenden Kapitel.

    Schreiben und Lesen von Memory-Segmenten

    In diesem Kapitel lernst du, wie du auf den durch ein MemorySegment verwalteten Speicherbereich schreibend und lesend zugreifen kannst.

    Wir beginnen mit einem einfachen Beispiel mit einem ValueLayout, gehen zu einem komplizierten Beispiel mit einem SequenceLayout und kommen schließlich zu einem sehr komplexen Beispiel mit einer Kombination aus SequenceLayout und StructLayout.

    MemorySegment und ValueLayout

    Das folgende Programm (Klasse FFMTestInts im GitHub-Repo) legt in der globalen Arena ein MemorySegment mit 100 Java-Integern an, füllt dieses unter Verwendung von MemorySegment.setAtIndex(...) mit Zufallszahlen und liest danach alle 100 Zahlen mit MemorySegment.getAtIndex(...) wieder aus:

    public class FFMTestInts {
      private static final int COUNT = 100;
    
      public static void main(String[] args) {
        MemorySegment numbers = Arena.global().allocate(ValueLayout.JAVA_INT, COUNT);
    
        ThreadLocalRandom random = ThreadLocalRandom.current();
        for (int i = 0; i < COUNT; i++) {
          numbers.setAtIndex(ValueLayout.JAVA_INT, i, random.nextInt());
        }
    
        for (int i = 0; i < COUNT; i++) {
          int number = numbers.getAtIndex(ValueLayout.JAVA_INT, i);
          System.out.println(number);
        }
      }
    }Code-Sprache: Java (java)

    Kommen wir nun zu einem etwas komplizierterem Beispiel…

    MemorySegment und SequenceLayout

    Der folgende Code (Klasse FFMTestMultipleArrays) definiert ein MemoryLayout für ein Array von Integern und allokiert vier solche Arrays.

    Um die Elemente des Arrays zu schreiben, wird für arrayLayout ein VarHandle definiert. Das Argument PathElement.sequenceElement() gibt dabei an, dass wir für den Zugriff auf das Array über das VarHandle den Index des jeweiligen Elements angeben wollen. Schließlich schreiben wir die Array-Elemente mit VarHandle.set(...) und geben dabei als Argumente das Segment, den Offset (die Größe des Array-Layouts multipliziert mit dem Index des Arrays, das wir gerade schreiben), den Index innerhalb des Arrays und den zu schreibenden Wert an.

    Auslesen könnten wir die Werte mit einer analogen VarHandle.get(...)-Methode, doch ich möchte dir eine andere Variante zeigen: Über MemorySegment.elements(...) erzeugen wir einen Stream von Speichersegmenten, die jeweils ein Array enthalten. Über MemorySegment.toArray(...) laden wir das jeweilige Array aus dem Speichersegment.

    public class FFMTestMultipleArrays {
      private static final int ARRAY_LENGTH = 8;
      private static final int NUMBER_OF_ARRAYS = 4;
    
      public static void main(String[] args) {
        SequenceLayout arrayLayout = MemoryLayout.sequenceLayout(ARRAY_LENGTH, JAVA_INT);
        VarHandle arrayHandle = arrayLayout.varHandle(PathElement.sequenceElement());
    
        MemorySegment segment = Arena.global().allocate(arrayLayout, NUMBER_OF_ARRAYS);
    
        ThreadLocalRandom random = ThreadLocalRandom.current();
        for (int i = 0; i < NUMBER_OF_ARRAYS; i++) {
          long offset = i * arrayLayout.byteSize();
          for (int j = 0; j < ARRAY_LENGTH; j++) {
            arrayHandle.set(segment, offset, j, random.nextInt(0, 1000));
          }
        }
    
        segment
            .elements(arrayLayout)
            .forEach(
                arraySegment -> {
                  int[] array = arraySegment.toArray(JAVA_INT);
                  System.out.println(Arrays.toString(array));
                });
      }
    }
    Code-Sprache: Java (java)

    Kommen wir zuletzt zu einem besonders komplizierterem Beispiel…

    MemorySegment und StructLayout

    Das letzte Beispiel (Klasse FFMTestArrayOfStructs) definiert ein StructLayout, das aus den Komponenten year, month und day, jeweils vom Typ short besteht.

    Es definiert zusätzlich ein SequenceLayout für ein Array von Datum-Structs.

    Danach definieren wir VarHandles für die Struct-Elemente innerhalb des Arrays. Wir müssen dazu jeweils zwei Pfadelemente angeben: zuerst den Array-Index und danach den jeweiligen Elementnamen des Structs.

    Wir schreiben die Structs über VarHandle.set(...) und geben als Argumente das Segment, den Offset 0 (da das Memory-Segment nur ein Element enthält, nämlich das Array von Structs), den Array-Index und den zu schreibenden Wert an.

    Auslesen wollen wir die Structs über MemorySegment.elements(...) wie im vorherigen Beispiel. Diese Methode liefert einen Stream von Memory Segmenten, die jeweils einen Struct enthalten. Die Elemente der Structs laden wir schließlich über drei weitere VarHandles für den Struct (die zuvor erstellten VarHandles waren für Structs innerhalb eines Arrays).

    public class FFMTestArrayOfStructs {
      private static final int ARRAY_LENGTH = 8;
    
      public static void main(String[] args) {
        StructLayout dateLayout =
            MemoryLayout.structLayout(
                ValueLayout.JAVA_SHORT.withName("year"),
                ValueLayout.JAVA_SHORT.withName("month"),
                ValueLayout.JAVA_SHORT.withName("day"));
    
        SequenceLayout positionArrayLayout = 
            MemoryLayout.sequenceLayout(ARRAY_LENGTH, dateLayout);
    
        MemorySegment segment = Arena.global().allocate(positionArrayLayout);
        writeToSegment(segment, positionArrayLayout);
        readFromSegment(segment, dateLayout);
      }
    
      private static void writeToSegment(
          MemorySegment segment, SequenceLayout positionArrayLayout) {
        VarHandle yearInArrayHandle =
            positionArrayLayout.varHandle(
                PathElement.sequenceElement(), PathElement.groupElement("year"));
        VarHandle monthInArrayHandle =
            positionArrayLayout.varHandle(
                PathElement.sequenceElement(), PathElement.groupElement("month"));
        VarHandle dayInArrayHandle =
            positionArrayLayout.varHandle(
                PathElement.sequenceElement(), PathElement.groupElement("day"));
    
        ThreadLocalRandom random = ThreadLocalRandom.current();
        for (int i = 0; i < ARRAY_LENGTH; i++) {
          yearInArrayHandle.set(segment, 0, i, (short) random.nextInt(1900, 2100));
          monthInArrayHandle.set(segment, 0, i, (short) random.nextInt(1, 13));
          dayInArrayHandle.set(segment, 0, i, (short) random.nextInt(1, 31));
        }
      }
    
      private static void readFromSegment(MemorySegment segment, StructLayout dateLayout) {
        VarHandle yearHandle = dateLayout.varHandle(PathElement.groupElement("year"));
        VarHandle monthHandle = dateLayout.varHandle(PathElement.groupElement("month"));
        VarHandle dayHandle = dateLayout.varHandle(PathElement.groupElement("day"));
    
        segment
            .elements(dateLayout)
            .forEach(
                positionSegment -> {
                  int year = (int) yearHandle.get(positionSegment, 0);
                  int month = (int) monthHandle.get(positionSegment, 0);
                  int day = (int) dayHandle.get(positionSegment, 0);
                  System.out.printf("%04d-%02d-%02d\n", year, month, day);
                });
      }
    }
    Code-Sprache: Java (java)

    VarHandle.get(…) hat eigentlich einen Rückgabewert vom Typ Object, ist aber mit @MethodHandle.PolymorphicSignature annotiert. Das bedeutet, dass die get(…)-Methode im obigen Beispiel nicht zunächst ein IntegerObjekt zurückgibt, das dann in ein int-Primitiv unboxed wird, sondern direkt ein int-Pritimiv.

    Im GitHub-Repository findest du noch ein weiteres Beispiel, FFMTestArrayOfArrays, das ich hier nicht mit abdrucke, da es keine neuen Konzepte einführt.

    Du hast nun ein solides Grundlagenwissen über Arenen, Speichersegmente, Speicherlayouts und Funktionsdeskriptoren erworben. Damit solltest du bereit sein für erste Ausflüge in die Welt der nativen Funktionen und des nativen Speichers.

    Eine kleine Geschichte der Foreign Function & Memory API

    Zum Abschluss findest du in diesem Abschnitt noch kurzen Rückblick auf die Entwicklungsschritte der FFM API.

    Bereits im März 2020, in Java 14, wurde die sogenannte „Foreign-Memory Access API” im Incubator-Stadium vorgestellt (JEP 370).

    Ein Jahr später wurde in Java 16 die „Foreign Linker API” im Incubator-Stadium vorgestellt (JEP 389).

    In Java 17 wurden die beiden APIs zur „Foreign Function & Memory API” zusammengeführt und diese vereinheitliche API noch einmal als Incubator-Version vorgelegt (JEP 412).

    In Java 19 wurde die FFM API ins Preview-Stadium befördert (JEP 424).

    In Java 22 wurde die API im März 2024 nach langer Entwicklungs- und Reifungszeit als produktionsreif deklariert und finalisiert (JEP 454).

    Fazit

    Die meisten Java-Entwicklerinnen und -Entwickler werden wahrscheinlich selten auf nativen Speicher zugreifen oder nativen Code ausführen müssen. Dennoch ist es hilfreich zu wissen, dass diese Möglichkeit existiert, z. B. um in anderen Sprachen geschriebene KI-Libraries aus Java heraus aufzurufen.

    In diesem Artikel hast du die Grundlagen dafür gelernt. Falls du noch tiefer in die Materie einsteigen möchtest, empfehle ich dir, JDK Enhancement Proposal 454 und die Webseite von Project Panama zu studieren.

    Planst du bereits eine native Library anzubinden? Wenn ja, welche? Lass es mich über die Kommentarfunktion wissen!

  • Stream Gatherers – Schreibe deine eigenen Stream-Operationen!

    Stream Gatherers – Schreibe deine eigenen Stream-Operationen!

    Die Java-Stream-API wurde mit Java 8 im März 2014 veröffentlicht und hat uns ein grundlegend neues Werkzeug an die Hand gegeben, um Datenströme zu verarbeiten.

    Allerdings führt der begrenzte Satz an intermediären Operationen – filter, map, flatMap, mapMulti, distinct, sorted, peak, limit, skip, takeWhile und dropWhile – dazu, dass komplexere Datentransformationen durch die Stream-API nicht ausgedrückt werden können.

    Es fehlen z. B. verbreitete intermediäre Operationen wie window und fold und zahlreiche mehr, wenn man sich die Feature-Requests der Java-Community ansieht.

    Anstatt nun all diese Operationen im Stream-Interface zu implementieren, entschieden die JDK-Developer eine API zu entwickeln, die zum einen im JDK selbst genutzt werden kann, um heiß begehrte intermediäre Operationen zu implementieren, und die zum anderen von Entwicklern dazu eingesetzt werden kann, um eigene Operationen zu entwickeln.

    Diese neue API heißt „Stream Gatherers“ und wurde durch JDK Enhancement Proposal 461 in Java 22 im März 2024, also genau zehn Jahre nach der Einführung der Stream-API, als Preview-Feature veröffentlicht und wird durch JEP 485 in Java 24 finalisiert.

    In diesem Artikel erfährst du,

    • was ein Gatherer ist,
    • wie die neue Stream-Gatherers-API funktioniert,
    • wie du mit ihr beliebige intermediäre Stream-Operationen implementierst,
    • welche Gatherer das JDK-Team bereits implementiert hat und wie du diese Gatherer erzeugst.

    Beginnen wir mit einer kurzen Zusammenfassung, wie die Stream-API überhaupt funktioniert.

    Stufen der Stream-API

    Java-Streams bestehen aus drei Stufen:

    1. Stream-Quelle – diese erzeugt einen Stream, z. B. durch IntStream.of() oder Collection.stream().
    2. Intermediäre Operationen – diese transformieren die im Stream enthaltenen Elemente, z. B. die Stream-Methoden map(), filter() und limit().
    3. Terminale Operationen – diese sammeln z. B. durch toList() die Elemente in einer Liste, durch collect(Collectors.toMap()) in einer Map oder zählen die Elemente mit count().

    Hier ist ein einfaches Beispiel – eine Methode, die zählt, wie viele Wörter einer bestimmten Länge in einer Liste von Wörtern enthalten sind:

    public long countLongWords(List<String> words, int minLength) {
      return words.stream()                       // ⟵ Source
          .map(String::length)                    // ⟵ Intermediate operation
          .filter(length -> length >= minLength)  // ⟵ Intermediate operation
          .count();                               // ⟵ Terminal operation
    }Code-Sprache: Java (java)

    Terminale Operation: Stream Collector

    Und hier ein Beispiel, das die Wörter in Großbuchstaben umwandelt und nach Länge gruppiert in einer Map speichert:

    public Map<Integer, List<String>> groupByLength(List<String> words) {
      return words.stream()                                 // ⟵ Source
          .map(String::toUpperCase)                         // ⟵ Intermediate operation
          .collect(Collectors.groupingBy(String::length));  // ⟵ Terminal operation
    }Code-Sprache: Java (java)

    In diesem zweiten Beispiel wird der terminalen Operation collect() ein sogenannter „Collector“ übergeben. Ein Collector ist ein Objekt einer Klasse, die das Collector-Interface implementiert und definiert, was am Ende des Streams mit den Elementen des Streams passieren soll. In diesem Fall sollen sie nach Länge gruppiert in eine Map gespeichert werden.

    Intermediäre Operation: Stream Gatherer

    Analog dazu definiert die Stream-Gatherers-API die Methode Stream.gather() sowie ein Gatherer-Interface. Das folgende Code-Beispiel verwendet die intermediäre Operation „Fixed Window“, welche die Wörter in Listen zu jeweils drei Wörtern gruppiert:

    public List<List<String>> groupsOfThree(List<String> words) {
      return words.stream()                  // ⟵ Source
          .gather(Gatherers.windowFixed(3))  // ⟵ Intermediate operation
          .toList();                         // ⟵ Terminal operation
    }Code-Sprache: Java (java)

    Wenn wir diese Methode z. B. wie folgt aufrufen:

    List<String> words = List.of("the", "be", "two", "of", "and", "a", "in", "that");
    List<List<String>> groups = groupsOfThree(words);
    System.out.println(groups);Code-Sprache: Java (java)

    Dann ist die Ausgabe:

    [[the, be, two], [of, and, a], [in, that]]Code-Sprache: Klartext (plaintext)

    (Da die Stream-Quelle eine Anzahl an Elementen geliefert hat, die nicht ohne Rest durch drei teilbar ist, enthält die letzte Gruppe nur zwei Wörter.)

    Wie genau ein Stream-Gatherer aufgebaut ist und wie er funktioniert, erfährst du im nächsten Kapitel.

    Aufbau eines Stream Gatherers

    Bevor wir uns den Aufbau eines Stream-Gatherers ansehen, ist es wichtig, zwei Eigenschaften eines Gatherers zu kennen:

    • Sie können einen Status haben, so dass Elemente unterschiedlich transformiert werden können, je nachdem, was vorher passiert ist (wofür das relevant ist, zeige ich dir gleich an einem Beispiel).
    • Sie können den Stream vorzeitig terminieren, wie es z. B. limit() und takeWhile() tun.

    Gatherer werden aus bis zu vier Komponenten aufgebaut:

    • Einem optionalen Initializer“, der den eben erwähnten Status initialisiert.
    • Einem „Integrator“, der jedes Element des Streams verarbeitet (ggf. unter Berücksichtigung des aktuellen Status), ggf. den Status aktualisiert, Elemente an die nächste Stufe der Stream-Pipeline weitergibt und ggf. den Stream vorzeitig beendet.
    • Einem optionalen „Finisher“, der nach der Verarbeitung des letzten Elements aufgerufen wird, um ggf. anhand des Status weitere Elemente an die nächste Stufe der Stream-Pipeline zu emittieren.
    • Und einem optionalen Combiner“, der bei der parallelen Verarbeitung eines Streams eingesetzt wird, um die Status von parallel ausgeführten Transformationen zu kombinieren.

    In den folgenden Abschnitten schauen wir uns die Komponenten nach und nach und mit vielen Beispielen an.

    Integrator

    Der Integrator ist die einzige Komponente, die zwingend erforderlich ist. Mit ausschließlich einem Integrator können wir bereits einen einfachen, statuslosen Gatherer entwickeln.

    Im Folgenden zeige ich dir, wie sich die Stream.map()-Funktion mit einem Gatherer implementieren lässt.

    Die gezeigten Interfaces haben zusätzliche statische oder Default-Methoden, die für ein grundlegendes Verständnis nicht wichtig sind. Ich lasse sie daher weg und schreibe anstelle der ausgelassenen Methoden drei Punkte.

    Integrator ist ein funktionales Interface mit einer integrate()-Methode:

    @FunctionalInterface
    public interface Integrator<A, T, R> {
      boolean integrate(A state, T element, Downstream<? super R> downstream);
      . . .
    }Code-Sprache: Java (java)

    Die integrate()-Methode bekommt drei Parameter übergeben: 

    • den (optionalen) Zustand vom Typ A,
    • das Element vom Typ T – das ist das Element, das die vorherige Stufe der Stream-Pipeline an diese Stufe schickt,
    • einen Downstream, über den die integrate()-Methode ggf. Elemente vom Typ R an die nächste Stufe der Stream-Pipeline schickt.

    Downstream ist ein funktionales Interface mit einer push()-Methode:

    @FunctionalInterface
    public interface Downstream<T> {
      boolean push(T element);
      . . .
    }Code-Sprache: Java (java)

    push() gibt ein boolean zurück, das anzeigt, ob der Downstream danach weitere Elemente geschickt haben möchte oder nicht. Wenn im Downstream z. B. irgendwo ein limit() vorkommt würde hier false zurückkommen, wenn das Limit erreicht ist.

    Auch die Integrator.integrate() gibt ein boolean zurück. Damit zeigt diese Stufe der Stream-Pipeline der vorherigen Stufe an, ob diese Stufe weitere Elemente verarbeiten möchte. Wenn nicht, gibt die integrate()-Methode false zurück und schickt damit sozusagen ein Stop-Signal an die Stream-Quelle.

    Einen Integrator, der eine Mapping-Funktion aufruft und das Ergebnis der Mapping-Funktion an den Downstream emittiert (also an die nächste Verarbeitungsstufe des Streams weiterleitet), können wir als Lambda-Funktion wie folgt schreiben. Da wir hier keinen Zustand benötigen, verwenden wir Void als Typ für die state-Variable:

    Function<T, R> mapper = . . .
    
    Integrator<Void, T, R> integrator = 
        (state, element, downstream) -> {
          R mappedElement = mapper.apply(element);
          return downstream.push(mappedElement);
        };Code-Sprache: Java (java)

    In der ersten Zeile des Integrators wird die Mapping-Funktion auf das vom Upstream eingehende Element angewendet. In der zweiten Zeile wird das Ergebnis-Element der Mapping-Funktion an den Downstream emittiert und die Antwort des Downstreams zurückgegeben.

    Da der oben gezeigte Integrator niemals von sich aus false zurückliefert, sondern nur dann, wenn dies vom Downstream ausgeht, wird der Integrator als „gierig“ (englisch: „greedy“) bezeichnet. Um der Stream-Pipeline dies anzuzeigen und ihr damit Optimierungen zu ermöglichen, sollten wir den Integrator daher einmal mit Integrator.ofGreedy() wrappen:

    Integrator<Void, T, R> integrator =
        Integrator.ofGreedy(
            (state, element, downstream) -> {
              R mappedElement = mapper.apply(element);
              return downstream.push(mappedElement);
            });
    Code-Sprache: Java (java)

    Um aus dem Integrator schließlich einen Gatherer zu machen, verwenden wir die statische Gatherer.of()-Methode:

    Gatherer<T, Void, R> gatherer = Gatherer.of(integrator);Code-Sprache: Java (java)

    Hier ist ein vollständiges Beispiel mit einer Methode, die einen Gatherer für eine bestimmte Mapping-Funktion erzeugt und einer Methode, die solch einen Gatherer nutzt, um eine Liste von Strings auf deren Längen zu mappen:

    public <T, R> Gatherer<T, Void, R> mapping(Function<T, R> mapper) {
      return Gatherer.of(
          Integrator.ofGreedy(
              (state, element, downstream) -> {
                R mappedElement = mapper.apply(element);
                return downstream.push(mappedElement);
              }));
    }
    
    public List<Integer> toLengths(List<String> words) {
      return words.stream()
          .gather(mapping(String::length))
          .toList();
    }Code-Sprache: Java (java)

    Damit ist das grundlegende Konzept eines Gatherers erklärt.

    Initializer

    Der Gatherer des vorherigen Abschnitts war statuslos, d. h. die Transformation eines Elements war unabhängig von allem, was zuvor passiert ist.

    In diesem Abschnitt zeige ich dir, wie sich die Stream.limit()-Funktion mit einem Gatherer implementieren lässt. Dazu muss der Gatherer die verarbeiteten Elemente zählen und den Stream nach Erreichen der gewünschten Anzahl von Elementen vorzeitig terminieren.

    Zum Zählen brauchen wir einen Status, einen Zähler. Der Initializer ist vom Typ Supplier und liefert den initialen Status. Zum Zählen eignet sich als Status z. B. ein AtomicInteger:

    Supplier<AtomicInteger> initializer = AtomicInteger::new;Code-Sprache: Java (java)

    Den limitierenden Integrator implementieren wir wie folgt:

    int maxSize = . . .
    
    Integrator<AtomicInteger, T, T> integrator = 
        (state, element, downstream) -> {
          if (state.get() < maxSize) {
            boolean result = downstream.push(element);
            state.incrementAndGet();
            return result;
          } else {
            return false;
          }
        };Code-Sprache: Java (java)

    Solange unser Status, der Element-Zähler, kleiner ist als maxSize, emittieren wir die Stream-Elemente in den Downstream, erhöhen den Zähler um eins und geben das Antwort-Boolean des Downstreams zurück. Sobald die gewünschte Anzahl Elemente erreicht ist, geben wir false zurück und zeigen damit an, dass der Stream beendet werden soll.

    Wir könnten übrigens auch in der if-Anweisung state.getAndIncrement() anstelle von state.get() schreiben und das state.incrementAndGet() weglassen. Doch das wäre etwas komplizierter zu erklären gewesen.

    Beachte, dass wir diesen Integrator nicht mit Integrator.ofGreedy() wrappen, da dieser Integrator auch von sich aus false zurückliefern kann (also nicht nur, wenn dieser Wert vom Downstream kam).

    Um aus dem Initializer und dem Integrator einen Gatherer zu machen, verwenden wir die Gatherer.ofSequential()-Methode. Der Name dieser Methode weist darauf hin, dass der zurückgelieferte Gatherer nicht parallel arbeiten kann. Das liegt daran, dass er einen Status hat, aber keinen Combiner (dazu später mehr).

    Gatherer<T, AtomicInteger, T> gatherer = Gatherer.ofSequential(initializer, integrator);Code-Sprache: Java (java)

    Das folgende Listing zeigt eine Methode, die einen limitierenden Gatherer aus den zuvor gezeigten Bausteinen erzeugt – dieses Mal mit state.getAndIncrement(), und eine Methode, die diesen Gatherer nutzt, um die ersten drei Wörter der Wort-Liste zurückzugeben:

    public <T> Gatherer<T, AtomicInteger, T> limiting(int maxSize) {
      return Gatherer.ofSequential(
          // Initializer
          AtomicInteger::new,
     
          // Integrator
          (state, element, downstream) -> {
            if (state.getAndIncrement() < maxSize) {
              return downstream.push(element);
            } else {
              return false;
            }
          });
    }
    
    public List<String> firstThreeWords(List<String> words) {
      return words.stream()
          .gather(limiting(3))
          .toList();
    }Code-Sprache: Java (java)

    Stream Gatherers sind übrigens so mächtig, dass nicht nur map() und limit(), sondern jede existierende intermediäre Operation der Stream-API auch als Gatherer implementiert werden kann.

    Finisher

    Um zu erklären, wofür wir einen Finisher benötigen, zeige ich dir in diesem Abschnitt, wie sich der im Einführungskapitel gezeigte „Fixed Window“-Gatherer implementieren lässt.

    Zur Erinnerung: Dieser soll aus einem Stream von Elementen einen Stream von Listen machen, die jeweils eine bestimmte Anzahl der Elemente enthalten.

    Wir beginnen einfach mal mit der Implementierung. An einer Stelle werden wir feststellen, dass wir nicht weiterkommen – und genau dann werde ich den Finisher einführen.

    Zunächst einmal benötigt unserer Gatherer einen Status. Da wir Elemente in Listen gruppieren wollen, liegt es nahe, solch eine Liste als Status zu verwenden. Entsprechend implementieren wir den Initializer:

    Supplier<List<T>> initializer = ArrayList::new;Code-Sprache: Java (java)

    Den Integrator implementieren wir wie folgt:

    Integrator<List<T>, T, List<T>> integrator =
        (state, element, downstream) -> {
          state.add(element);
          if (state.size() == windowSize) {
            boolean result = downstream.push(List.copyOf(state));
            state.clear();
            return result;
          } else {
            return true;
          }
        };Code-Sprache: Java (java)

    Wir hängen das ankommende Element an die Status-Liste an. Sobald die Liste die gewünschte Größe erreicht hat, senden wir eine Kopie der Liste an den Downstream und leeren die Liste.

    Das funktioniert allerdings nur, wenn die Anzahl der Elemente ein Vielfaches der Fenstergröße ist. Wenn wir beispielsweise acht Elemente haben und eine Fenstergröße von drei, dann würden für die ersten sechs Elemente zwei Listen zu je drei Elementen an den Downstream emittiert werden. Das siebte und achte Element würden ebenfalls in einer Liste liegen, doch da diese Liste die gewünschte Größe noch nicht erreicht hat, wird sie vom Integrator nicht in den Downstream emittiert.

    Genau hier kommt der Finisher ins Spiel. Der Finisher bekommt als Input den Status nach der Verarbeitung aller Stream-Elemente sowie den Downstream und kann dann in Abhängigkeit vom Status weitere Elemente in den Downstream emittieren.

    Für die Fixed-Window-Operation würde der Finisher wie folgt aussehen:

    BiConsumer<List<T>, Downstream<List<T>>> finisher =
        (state, downstream) -> {
          if (!state.isEmpty()) {
            downstream.push(List.copyOf(state));
          }
        };Code-Sprache: Java (java)

    Falls die Liste Elemente enthält, wird sie in den Downstream emittiert.

    Initializer, Integrator und Finisher kombinieren wir wie folgt zu einem Gatherer:

    Gatherer<T, List<T>, List<T>> gatherer =
        Gatherer.ofSequential(initializer, integrator, finisher);Code-Sprache: Java (java)

    Das folgende Listing zeigt eine Methode, die aus den zuvor gezeigten Komponenten einen Window-Gatherer erzeugt, sowie eine Methode, die diesen Gatherer nutzt:

    public <T> Gatherer<T, List<T>, List<T>> windowing(int windowSize) {
      return Gatherer.ofSequential(
          // Initializer
          ArrayList::new,
    
          // Gatherer
          (state, element, downstream) -> {
            state.add(element);
            if (state.size() == windowSize) {
              boolean result = downstream.push(List.copyOf(state));
              state.clear();
              return result;
            } else {
              return true;
            }
          },
    
          // Finisher
          (state, downstream) -> {
            if (!state.isEmpty()) {
              downstream.push(List.copyOf(state));
            }
          });
    }
    
    public List<List<String>> groupWords(List<String> words, int groupSize) {
      return words.stream()
          .gather(windowing(groupSize))
          .toList();
    }Code-Sprache: Java (java)

    Der Einfachheit halber habe ich im vorangegangenen Beispiel als Statusobjekt eine ArrayList verwendet. Das Erzeugen von Kopien und das Leeren der Liste stellt jedoch einen nicht unerheblichen Overhead dar.

    Die folgende Lösung verwendet als Status ein Wrapper-Objekt, das eine Liste enthält, welche direkt in den Downstream gepusht und dann neu erzeugt wird. Diese Variante ist ca 20 % schneller:

    public <T> Gatherer<T, ?, List<T>> windowing(int windowSize) {
      return Gatherer.ofSequential(
          // Initializer
          () -> new Object() { ArrayList<T> list = new ArrayList<>(); },
    
          // Gatherer
          (state, element, downstream) -> {
            state.list.add(element);
            if (state.list.size() == windowSize) {
              boolean result = downstream.push(state.list);
              state.list = new ArrayList<>();
              return result;
            } else {
              return true;
            }
          },
    
          // Finisher
          (state, downstream) -> {
            if (!state.list.isEmpty()) {
              downstream.push(state.list);
            }
          });
    }Code-Sprache: Java (java)
    Das Statusobjekt new Object() { ArrayList<T> list = new ArrayList<>(); } ist übrigens ein sogenannter „non-denotable type” – ein Typ, der zwar existiert (weshalb wir auf state.list zugreifen können), der aber keinen Namen hat. Wenn wir dieses Objekt einer Variablen zuweisen wollen, funktioniert das nur, wenn wir die Variable mit var deklarieren:

    var status = new Object() { ArrayList<String> list = new ArrayList<>(); };
    status.list.add("element");

    Würdest du in diesem Code var durch Object ersetzen, käme es zu einem Compilerfehler.

    Im nächsten Abschnitt kommen wir zum letzten Stream-Gatherer-Baustein, dem Combiner.

    Combiner

    Um einen statusbehafteten Gatherer parallel auszuführen, benötigt er eine Combiner-Funktion. Ein Combiner kombiniert in der Join-Phase der parallelen Stream-Verarbeitung jeweils zwei Status zu einem:

    Stream Gatherer parallel ausführen: Split- und Join-Phasen

    In diesem Abschnitt wollen wir einen Gatherer implementieren, der entsprechend eines Comparators das größte aller eingehenden Elemente in den Downstream emittiert.

    Als Status verwenden wir eine AtomicReference, die entweder kein Element oder das aktuell größte gefundene Element enthält:

    Supplier<AtomicReference<T>> initializer = AtomicReference::new;
    Code-Sprache: Java (java)

    Der Integrator speichert das eingehende Element im Status, wenn der Status leer ist oder wenn das eingehende Element größer ist als das im Status gespeicherte Element. Da der Integrator immer true zurückliefert, kennzeichnen wir ihn als „greedy“:

    Integrator<AtomicReference<T>, T, T> integrator =
        Integrator.ofGreedy(
            (state, element, downstream) -> {
              T bestElement = state.get();
              if (bestElement == null || comparator.compare(element, bestElement) > 0) {
                state.set(element);
              }
              return true;
            });
    Code-Sprache: Java (java)

    Der Finisher sendet das Element, sofern der Status eines enthält, an den Downstream:

    BiConsumer<AtomicReference<T>, Downstream<T>> finisher =
        (state, downstream) -> {
          T bestElement = state.get();
          if (bestElement != null) {
            downstream.push(bestElement);
          }
        };
    Code-Sprache: Java (java)

    Und der Combiner kombiniert zwei Status zu einem:

    • Ist ein Status leer, gibt er den jeweils anderen Status zurück.
    • Enthalten beide Status ein Element, gibt er den Status mit dem größeren Element zurück.

    Wenn der Eingangsstream leer ist, wird der Combiner nie aufgerufen, d. h. der Fall, dass beide Status leer sind, kann nicht eintreten.

    BinaryOperator<AtomicReference<T>> combiner =
        (state1, state2) -> {
          T bestElement1 = state1.get();
          T bestElement2 = state2.get();
    
          if (bestElement1 == null) {
            return state2;
          } else if (bestElement2 == null) {
            return state1;
          } else if (comparator.compare(bestElement1, bestElement2) > 0) {
            return state1;
          } else {
            return state2;
          }
        };Code-Sprache: Java (java)

    Initializer, Integrator, Combiner und Finisher kombinieren wir wieder mit Gatherer.of() zu einem Gatherer:

    Gatherer<T, AtomicReference<T>, T> gatherer =
        Gatherer.of(initializer, integrator, combiner, finisher);
    Code-Sprache: Java (java)

    Und auch für den Combiner folgt ein Listing mit einer Methode, die einen Maximum-Gatherer aus den zuvor gezeigten Bausteinen erzeugt, sowie einer Methode, die diesen in einem parallelen Stream nutzt, um das längste Wort einer Liste zu finden:

    public <T> Gatherer<T, AtomicReference<T>, T> maximumBy(Comparator<T> comparator) {
      return Gatherer.of(
          // Initializer
          AtomicReference::new,
    
          // Integrator
          Integrator.ofGreedy(
              (state, element, downstream) -> {
                T bestElement = state.get();
                if (bestElement == null || comparator.compare(element, bestElement) > 0) {
                  state.set(element);
                }
                return true;
              }),
    
          // Combiner
          (state1, state2) -> {
            T bestElement1 = state1.get();
            T bestElement2 = state2.get();
    
            if (bestElement1 == null) {
              return state2;
            } else if (bestElement2 == null) {
              return state1;
            } else if (comparator.compare(bestElement1, bestElement2) > 0) {
              return state1;
            } else {
              return state2;
            }
          },
    
          // Finisher
          (state, downstream) -> {
            T bestElement = state.get();
            if (bestElement != null) {
              downstream.push(bestElement);
            }
          });
    }
    
    public Optional<String> getLongest(List<String> words) {
      return words.parallelStream()
          .gather(maximumBy(Comparator.comparing(String::length)))
          .findFirst();
    }Code-Sprache: Java (java)

    Damit haben wir alle Komponenten eines Stream Gatherers beisammen. Wir brauchen allerdings nicht für jeden Zweck einen eigenen Gatherer zu implementieren. Für einige häufig benötigte intermediäre Transformationen haben das die JDK-Entwickler bereits für uns getan.

    Welche Gatherers bereits zur Verfügung stehen, erfährst du im nächsten Kapitel.

    Im JDK verfügbare Stream Gatherers

    Die im JDK vordefinierten Gatherer kannst du über entsprechende Factory-Methoden der Gatherers-Klasse erzeugen. Einen Gatherer hast du bereits im Einführungskapitel kennengelernt: den „Fixed Window“-Gatherer.

    Im Folgenden findest du eine Übersicht der wichtigsten vordefinierten Gatherer:

    • Gatherers.fold(Supplier initial, BiFunction folder)
      kombiniert alle Elemente des Streams zu einem einzigen Element, ähnlich wie ein Collector. Ist dann hilfreich, wenn eine terminale Operation auf einem aus den Stream-Elementen kombinierten Element aufgerufen werden soll.
    • Gatherers.mapConcurrent(int maxConcurrency, Function mapper)
      führt die angegebene Mapping-Funktion in der angegebenen Anzahl virtueller Threads gleichzeitig aus.
    • Gatherers.peek(Consumer effect)
      Gatherers.peekOrdered(Consumer effect)

      senden jedes Element des Streams an den Consumer, bevor es an die nächste Stufe der Stream-Pipeline weitergeleitet wird. peekOrdered() stellt bei einem parallelen Stream die Verarbeitung in der richtigen Reihenfolge sicher.
    • Gatherers.scan(Supplier initial, BiFunction scanner)
      führt einen sogenannten Prefix-Scan durch.
    • Gatherers.windowFixed(int windowSize)
      Gatherers.windowSliding(int windowSize)

      gruppieren die Stream-Elemente in Listen der angebenen Größe. Bei der Sliding-Variante überlappen die Listen und sind um jeweils ein Element verschoben, so erzeugt bspw. Stream.of(1, 2, 3, 4, 5).gather(Gatherers.windowSliding(3)).toList() die folgende Liste von Listen: [[1, 2, 3], [2, 3, 4], [3, 4, 5]].

    Gatherers kombinieren

    So wie du beispielsweise mehrere filter()– und map()-Operationen hintereinander schalten kannst, kannst du auch mehrere Gatherer hintereinander aufrufen, z. B. so:

    var result = source
        .gather(a)
        .gather(b)
        .gather(c)
        .collect();Code-Sprache: Java (java)

    Wenn du eine bestimmte Abfolge von Gatherern regelmäßig benötigst, kannst du diese – ganz im Sinne von DRY (don’t repeat yourself) – auch zu einem einzigen Gatherer kombinieren, z. B. so:

    Gatherer abc = a.andThen(b).andThen(c);
    
    var result = source
        .gather(abc)
        .collect();Code-Sprache: Java (java)

    So kannst du eine beliebig lange Transformationssequenz redundanzfrei auf verschiedene Streams anwenden.

    Limitierungen von Stream Gatherern

    Stream Gatherer sind leistungsstarke Werkzeuge, haben jedoch zwei wesentliche Einschränkungen:

    • Es gibt sie (genau wie Collectoren) nicht für die primitiven Streams IntStream, LongStream und DoubleStream.
    • Sie haben (genau wie Collectoren) keinen Zugriff auf die (im Spliterator-Interface definierten) Charakteristika des Streams. Das bedeutet, dass sie nicht auf der Grundlage dieser Eigenschaften optimiert werden können (z. B. der Tatsache, dass die Größe bekannt ist oder dass der Stream nur unterschiedliche Elemente enthält).

    Fazit

    Mit Stream Gatherers können wir beliebige intermediäre Stream-Operationen implementieren, so wie wir mit Kollektoren schon immer beliebige terminale Operationen schreiben konnten. Das erlaubt uns, wesentlich aussagekräftigere Stream-Pipelines zu schreiben als bisher.

    Über die Gatherers-Klasse können wir vorgefertigte Gatherer für eine Vielzahl von intermediären Operationen abrufen.

    Welcher ist der erste der vorgefertigten Gatherer, den du einsetzen wirst? Welche Funktionalität planst du selbst als Gatherer zu implementieren? Schreibe mir einen Kommentar!

  • Java Deep Reflection: Wie man Integer und String hackt

    Java Deep Reflection: Wie man Integer und String hackt

    Ich lese gerade das Buch „The Pragmatic Programmer“ von Andrew Hunt und David Thomas. Darin stellen die Autoren folgende Aufgabe:

    Which of these „impossible“ things can happen?

    […]
    3. In C++: a = 2; b = 3; if (a + b != 5) exit(1);
    […]

    Eine der richtigen Antworten ist 3. In C++ gibt es mehrere Gründe, warum die Bedingung „a + b != 5“ erfüllt sein könnte:

    • Operator Overloading: z. B. kann man den ‚+‘-Operator eine Multiplikation ausführen lassen.
    • Variable Aliasing: b ist ein Alias für a, damit setzt die Zuweisung b = 3 auch a auf 3, und die Summe ist 6.

    Da es beides in Java nicht gibt, habe ich mich gefragt: Kann ich denselben Code auch in Java so schreiben, dass die Bedingung erfüllt ist? Die Antwort lautet: ja. Wie das möglich ist, erfährst du in diesem Artikel.

    Die Code-Beispiele des Artikels findest du in meinem GitHub-Repository.

    2 + 3 = 5

    Fangen wir ganz einfach an: mit einer main-Funktion, in der zwei primitive ints, a und b, deklariert werden, gefolgt vom zu testenden Code:

    import static java.lang.System.exit;
    
    public class ImpossibleThings1 {
      public static void main(String[] args) {
        int a, b;
        a = 2; b = 3; if (a + b != 5) exit(1);
      }
    }Code-Sprache: Java (java)

    Selbstverständlich gilt hier a + b = 5, so dass das Programm regulär, also mit Exit-Code 0, endet.

    2 + 3 = 6: Deep Reflection mit Integer

    Mit einer gar nicht allzu großen Änderung können wir dafür sorgen, dass die Bedingung wahr wird (also, dass 2 + 3 nicht 5 ist) und das Programm mit Fehlercode 1 endet:

    public class ImpossibleThings2 {
      static {
        try {
          Field VALUE = Integer.class.getDeclaredField("value");
          VALUE.setAccessible(true);
          VALUE.set(2, 3);
        } catch (ReflectiveOperationException e) {
          throw new Error(e);
        }
      }
    
      public static void main(String[] args) {
        Integer a, b;
        a = 2; b = 3; if (a + b != 5) exit(1);
      }
    }Code-Sprache: Java (java)

    Hier ist der Beweis:

    Screenshot mit der Ausgabe "exit code 1"
    Screenshot mit der Ausgabe „exit code 1“

    Was haben wir getan? Wir verwenden hier Integer statt int und machen von reichlich Auto-Boxing und -Unboxing Gebrauch. Was hier genau passiert, beschreibe ich im nächsten Abschnitt.

    Auto-(un)boxing aufgedeckt

    Im Folgenden habe ich Auto-Boxing und -Unboxing durch explizites Boxing und Unboxing ersetzt. So wird deutlicher, was passiert. Die Änderungen sind gelb markiert (um dies zu ermöglichen, musste ich hier ein einfache Textbox verwenden).

    public class ImpossibleThings3 {
      static {
        try {
          Field VALUE = Integer.class.getDeclaredField("value");
          VALUE.setAccessible(true);
          Integer two = Integer.valueOf(2);
          VALUE.set(two, 3);
        } catch (ReflectiveOperationException e) {
          throw new Error(e);
        }
      }
    
      public static void main(String[] args) {
        Integer a, b;
        a = Integer.valueOf(2);
        b = Integer.valueOf(3);
        if (a.intValue() + b.intValue() != 5) exit(1);
      }
    }

    Integer.valueOf() liefert für die Werte -128 to 127 gecachte Integer-Instanzen zurück.*

    Ein Integer-Objekt speichert den eigentlichen Wert in einem privaten value-Feld und gibt eben diesen über intValue() zurück.

    Im statischen Initializer holen wir uns das gecachten Integer-Objekt für den Wert 2. Mittels Deep Reflection setzen wir dessen value auf die Zahl 3. Da value ein privates Feld ist, müssen wir den Zugriff darauf mit Field.setAccessible(true) zunächst gestatten.

    Würden wir nun mit System.out.println(two) dieses Objekt ausgeben, würden wir diese „3“ sehen.

    In der main-Methode wird a = 2 zu a = Integer.valueOf(2) geboxt, was wiederum dieselbe gecachte Integer-Instanz liefert wie two, dessen Wert mittlerweile 3 ist. b ist ebenfalls 3 und somit ergibt a + b an dieser Stelle 6. Und das ist bekanntermaßen ungleich 5 (sofern diese nicht auch „gehackt“ wurde … was aber meines Wissens nach bei einem int-Primitiv nicht möglich ist) .

    (*Garantiert ist dieses Verhalten nicht, aber praktisch ist es so. Mit -XX:AutoBoxCacheMax kann der gecachte Integer-Bereich vergrößert werden.)

    Deep Reflection mit Strings

    Dasselbe lässt sich auch mit Strings machen. Die folgenden Beispiele funktionieren so ab Java 9, eine Anpassung für ältere Versionen folgt weiter unten.

    public class ImpossibleThings4 {
      static {
        try {
          Field VALUE = String.class.getDeclaredField("value");
          VALUE.setAccessible(true);
          VALUE.set("Hello world", "You have been hacked".getBytes());
        } catch (ReflectiveOperationException e) {
          throw new Error(e);
        }
      }
    
      public static void main(String[] args) {
        System.out.println("Hello world");
      }
    }Code-Sprache: Java (java)

    Die Ausgabe dieses Programms lautet „You have been hacked“. Hier der Beweis:

    Screenshot mit der Ausgabe von "You have been hackend"
    Screenshot mit der Ausgabe von „You have been hackend“

    So einfach, wie es in diesem Beispiel scheint, funktioniert es allerdings nicht immer. In­wie­weit wir Strings mit Deep Reflection manipulieren können, hängt von drei Faktoren ab:

    • ob Strings als Konstanten vorliegen oder zur Laufzeit erstellt werden,
    • ob Strings Sonderzeichen enthalten, die nicht als Latin-1 kodiert werden können,
    • welche Java-Version wir verwenden.

    Strings müssen als Konstanten vorliegen

    Zunächst einmal müssen Strings als Kontanten im Code enthalten sein. Nur String-Konstanten werden, wenn sie gleich sind, durch dieselbe Objektreferenz ersetzt.

    Folgendes funktioniert noch:

    public class ImpossibleThings5 {
      static { ... }
    
      public static void main(String[] args) {
        System.out.println("Hello" + " " + "world");
      }
    }Code-Sprache: Java (java)

    Hier fügt bereits der Compiler die drei Teile zu einem einzigen String zusammen – zur Laufzeit ist das dann derselbe String wie der, dessen value-Inhalt wir ändern.

    Folgendes hingegen funktioniert nicht:

    public class ImpossibleThings6 {
      static { ... }
    
      public static void main(String[] args) {
        System.out.println("Hello " + getName());
      }
    
      private static String getName() {
        return "world";
      }
    }Code-Sprache: Java (java)

    Hier werden erst zur Laufzeit „Hello “ und „world“ verkettet. Dabei entsteht ein neues String-Objekt mit dem value-Inhalt „Hello world“.

    Vergleich der Objekt-Identitäten

    Etwas klarer wird es, wenn wir uns die Identitäten der String-Objekte anschauen, hier noch einmal am ersten String-Beispiel:

    public class ImpossibleThings4WithIdentity {
      static {
        try {
          Field VALUE = String.class.getDeclaredField("value");
          VALUE.setAccessible(true);
          String s1 = "Hello world";
          System.out.println("identityHashCode(s1) = " + System.identityHashCode(s1));
          VALUE.set(s1, "You have been hacked".getBytes());
        } catch (ReflectiveOperationException e) {
          throw new Error(e);
        }
      }
    
      public static void main(String[] args) {
        String s2 = "Hello world";
        System.out.println("identityHashCode(s2) = " + System.identityHashCode(s2));
        System.out.println(s2);
      }
    }Code-Sprache: Java (java)

    Die Ausgabe lautet:

    Screenshot mit Anzeige der Objekt-Identitäten
    Screenshot mit Anzeige der Objekt-Identitäten

    Das String-Objekt s1, das wir im statischen Initialisierer modifizieren, ist also identisch* zum String-Objekt s2, das wir in der main-Methode ausgeben. Wir geben also genau den String aus, den wir per Deep Reflection verändert haben.

    Dasselbe prüfen wir noch einmal für den String, der im Quellcode aus String-Konstanten verkettet wird:

    public class ImpossibleThings5WithIdentity {
      static { ... }
    
      public static void main(String[] args) {
        String s2 = "Hello" + " " + "world";
        System.out.println("identityHashCode(s2) = " + System.identityHashCode(s2));
        System.out.println(s2);
      }
    }Code-Sprache: Java (java)

    Wir erhalten folgende Ausgabe:

    Screenshot mit Anzeige der Objekt-Identitäten
    Screenshot mit Anzeige der Objekt-Identitäten

    Auch bei diesem Beispeil sind die String-Objekte s1 und s2 identisch.*

    Und zuletzt überprüfen wir die Objekt-Identitäten bei der dritten Variante, bei der ein Teil des Strings durch eine Methode zurückgeliefert wird:

    public class ImpossibleThings6WithIdentity {
      static { ... }
    
      public static void main(String[] args) {
        String s2 = "Hello " + getName();
        System.out.println("identityHashCode(s2) = " + System.identityHashCode(s2));
        System.out.println(s2);
      }
    
      private static String getName() {
        return "world";
      }
    }Code-Sprache: Java (java)

    Hier die Ausgabe des dritten Tests:

    Screenshot mit Anzeige der Objekt-Identitäten
    Screenshot mit Anzeige der Objekt-Identitäten

    Wir haben also die Bestätigung, dass es sich bei s1 und s2 um zwei unterschiedliche String-Objekte handelt. Die Änderung von s1 per Reflection wirkt sich daher nicht auf s2 aus.

    (* Auch zwei nicht-identische Objekte könnten denselben Identity-HashCode haben. Wir müssten eigentlich noch mit s1 == s2 die Identität überprüfen. Die Wahrscheinlichkeit dafür ist allerdings minimal, so dass mir für die Beispiele der Vergleich der HashCodes genügt.)

    String-Repräsentation: Latin-1 vs. UTF-16

    Wenn wir das erste String-Beispiel leicht abändern, kommt ein zunächst ziemlich unerwartetes Ergebnis heraus. Wir ändern den auszugebenen String von „Hello world“ in „Hello world ✓“ (mit einem Häkchen am Ende):

    public class ImpossibleThings7 {
      static {
        try {
          Field VALUE = String.class.getDeclaredField("value");
          VALUE.setAccessible(true);
          VALUE.set("Hello world ✓", "You have been hacked".getBytes());
        } catch (ReflectiveOperationException e) {
          throw new Error(e);
        }
      }
    
      public static void main(String[] args) {
        System.out.println("Hello world ✓");
      }
    }Code-Sprache: Java (java)

    Was wird hier ausgegeben? Was denkst du? (Wir sind immer noch bei Java 9 oder höher.)

    1. „Hello world ✓“
    2. „You have been hacked“
    3. „You have been hacked ✓“
    4. „潙⁵慨敶戠敥慨正摥“

    Die Antwort findest du in folgendem Screenshot:

    Screenshot mit der Ausgabe chinesischer Zeichen
    Screenshot mit der Ausgabe chinesischer Zeichen

    Wie lässt sich das erklären?

    Dazu müssen wir uns die interne Repräsentation eines Strings anschauen. Seit Java 9 wird ein String intern als byte[] dargestellt. Die Art der Kodierung von Characters in Bytes hängt dabei davon ab, ob der String ausschließlich Latin-1-kodierbare Zeichen enthält oder auch andere. Enthält der String nur Zeichen, die in Latin-1 kodiert werden können, wird pro Zeichen genau ein Byte verwendet. Enthält der String jedoch auch andere Zeichen, wird er als UTF-16 kodiert.

    Dieses Feature nennt sich „String Compaction“, wurde im JEP 254 definiert und ist per default aktiviert. Es kann mit -XX:-CompactStrings deaktiviert werden – dann werden Strings grundsätzlich als UTF-16 gespeichert.

    Was bedeutet das für unser Beispiel?

    • Der String „Hello world“ wird durch folgende Bytes repräsentiert:
      48 65 6c 6c 6f 20 77 6f 72 6c 64
      ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^
      H  e  l  l  o     W  o  r  l  d
    • Der String „Hello world ✓“ wird wie folgt gespeichert:
      48 00 65 00 6c 00 6c 00 6f 00 20 00 77 00 6f 00 72 00 6c 00 64 00 20 00 13 27
      ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^
        H     e     l     l     o           W     o     r     l     d           ✓
      (Hier im Little-Endian-Format, da ich auf einem Intel-System arbeite.)

    Die Information, wie der String kodiert wird, wird im Feld coder des Strings abgelegt. Dabei steht eine 0 für Latin-1 und eine 1 für UTF-16.

    Im String „Hello world ✓“ enthält das Feld coder also aufgrund der UTF-16-Kodierung den Wert 1.

    Im vorangegangenen Code-Beispiel setzen wir das Feld value des Strings „Hello world ✓“ auf "You have been hacked".getBytes(). Die Methode getBytes() liefert die Bytes in der Standard-Zeichenkodierung zurück, die – sofern nicht über die System Property „file.encoding“ anders definiert – UTF-8 ist (zumindest seit Java 1.5; davor war es ISO-8859-1).

    Da der String „You have been hacked“ keinerlei Sonderzeichen enthält, ist dessen UTF-8-Kodierung identisch mit seiner Latin-1-Kodierung, belegt also genau ein Byte pro Zeichen.

    Der String „Hello world ✓“ enthält somit im Feld value folgende Byte-Folge:

    59 6f 75 20 68 61 76 65 20 62 65 65 6e 20 68 61 63 6b 65 64
    ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^
    Y  o  u     h  a  v  e     b  e  e  n     h  a  c  k  e  d

    Da im „Hello world ✓“-Feld coder nach wie vor eine 1 steht (wegen der ursprünglichen UTF-16-Kodierung), wird das Byte-Array als UTF-16 interpretiert – und genau das führt dann zur Ausgabe der chinesischen Zeichen.

    Grob gesagt haben wir also folgendes getan:

    byte[] bytes = "You have been hacked".getBytes(StandardCharsets.UTF_8);
    String string = new String(bytes, StandardCharsets.UTF_16);Code-Sprache: Java (java)

    Wie können wir das Problem lösen?

    Ziemlich einfach: wir müssen lediglich neben dem Inhalt von value auch den Inhalt von coder kopieren. Bei der Gelegenheit ändern wir auch das Kopieren von value so ab, dass wir das entsprechende Feld aus dem String „You have been hacked“ auslesen, anstatt dessen getBytes()-Methode aufzurufen. Diese hat nämlich bisher nur zufällig das zugrunde liegende Byte-Array geliefert, weil „You have been hacked“ keine Sonderzeichen enthält und die System Property „file.encoding“ (zumindest bei mir und höchstwahrscheinlich auch bei dir) nicht gesetzt ist.

    public class ImpossibleThings8 {
      static {
        try {
          Field VALUE = String.class.getDeclaredField("value");
          VALUE.setAccessible(true);
    
          Field CODER = String.class.getDeclaredField("coder");
          CODER.setAccessible(true);
    
          VALUE.set("Hello world ✓", VALUE.get("You have been hacked"));
          CODER.set("Hello world ✓", CODER.get("You have been hacked"));
        } catch (ReflectiveOperationException e) {
          throw new Error(e);
        }
      }
    
      public static void main(String[] args) {
        System.out.println("Hello world ✓");
      }
    }Code-Sprache: Java (java)

    Statt chinesischer Zeichen bekommen wir nun wieder „You have been hacked“ ausgegeben:

    Screenshot mit der Ausgabe von "You have been hacked" anstatt chinesischer Zeichen
    Screenshot mit der Ausgabe von „You have been hacked“ anstatt chinesischer Zeichen

    Dass die String-Konstanten hier zweimal angegeben werden, gehört sich so natürlich nicht. Das lösen wir, in dem wir den Code in eine Methode auslagern und die zwei Strings als Parameter übergeben:

    public class StringHacker_Java9 {
      public static void hackString(String victim, String replacement) {
        try {
          Field VALUE = String.class.getDeclaredField("value");
          VALUE.setAccessible(true);
    
          Field CODER = String.class.getDeclaredField("coder");
          CODER.setAccessible(true);
    
          VALUE.set(victim, VALUE.get(replacement));
          CODER.set(victim, CODER.get(replacement));
        } catch (ReflectiveOperationException e) {
          throw new Error(e);
        }
      }
    }Code-Sprache: Java (java)

    Als nächstes müssen wir noch einen Blick auf ältere Java-Versionen werfen.

    String-Repräsentation: byte[] vs. char[]

    Wie in der Einleitung dieses Kapitels erwähnt, funktionieren die Beispiele nur mit Java 9. Der Grund dafür ist, dass bis zu Java 8 der Wert eines Strings nicht in einem byte[], sondern in einem char[] abgelegt wurde. Entsprechend existierte auch bis Java 8 das Feld coder nicht.

    Würden wir die bisherigen Beispiele mit Java 8 starten, bekämen wir …

    • beim Aufruf von VALUE.set("...".getBytes()) eine IllegalArgumentException: Can not set final [C field java.lang.String.value to [B
    • in den letzten zwei Beispielen (in denen wir nicht explizit ein Byte-Array setzen, sondern den Inhalt von value kopieren) beim darauf folgenden Aufruf von String.class.getDeclaredField("coder") eine NoSuchFieldException: coder.

    Die IllegalArgumentException haben wir, wie gesagt, in den letzten zwei Beispielen schon verhindert. Die NoSuchFieldException können wir einfach ignorieren – wenn es das Feld coder nicht gibt, brauchen wir es auch nicht zu kopieren:

    public class StringHacker_Java7 {
      public static void hackString(String from, String to) {
        try {
          Field VALUE = String.class.getDeclaredField("value");
          VALUE.setAccessible(true);
          VALUE.set(from, VALUE.get(to));
    
          // For "Compact Strings" introduced in Java 9
          try {
            Field CODER = String.class.getDeclaredField("coder");
            CODER.setAccessible(true);
            CODER.set(from, CODER.get(to));
          } catch (NoSuchFieldException e) {
            // Ignore
          }
        } catch (ReflectiveOperationException e) {
          throw new Error(e);
        }
      }
    }Code-Sprache: Java (java)

    Hier der Beweis, dass dieser Code auch unter Java 7 läuft:

    String Deep Reflection mit Java 7
    String Deep Reflection mit Java 7

    Sub-Strings mit offset und count

    Gehen wir weiter in der Java-Geschichte zurück, kommen wir zu einer weiteren Änderung der String-Interna von Java 6 zu Java 7. Bis Java 6 wurde das value-Character-Array wiederverwendet, wenn man mit String.substring() einen Teil-String erzeugt hat.

    Dazu wurde das Character-Array des ursprünglichen Strings in den Teil-String unverändert übernommen. Und in den Feldern offset und count des Teil-Strings wurde gespeichert, welcher Abschnitt des Character-Arrays dessen Inhalt repräsentiert.

    Ziel dieser Logik war es Speicher zu sparen.

    Häufiger passierte jedoch das Gegenteil: Wenn der ursprüngliche String nicht mehr benötigt wurde, hielt der kürzere Teil-String nach wie vor eine Referenz auf das ursprüngliche, dann unnötig längere Character-Array. Von daher haben die Java-Entwickler in Java 7 die Funktionalität von String.substring() so geändert, dass nur der benötigte Teil des Character-Arrays in den Teil-String kopiert wurde.

    Um unseren Code auf Java 6 und niedriger lauffähig zu machen, müssen wir also auch die Felder offset und count kopieren.

    Vor Java 7 gab es leider auch weder die ReflectiveOperationException, noch die Möglichkeit mehrere Exception-Typen in einem catch-Block abzufangen, so dass dieser etwas umständlicher wird. Hier der auch unter 6 lauffähige Code:

    public class StringHacker {
      public static void hackString(String from, String to) {
        try {
          Field VALUE = String.class.getDeclaredField("value");
          VALUE.setAccessible(true);
          VALUE.set(from, VALUE.get(to));
    
          // "offset" and "count" for Strings up to Java 6
          try {
            Field OFFSET = String.class.getDeclaredField("offset");
            OFFSET.setAccessible(true);
            OFFSET.setInt(from, OFFSET.getInt(to));
    
            Field COUNT = String.class.getDeclaredField("count");
            COUNT.setAccessible(true);
            COUNT.setInt(from, COUNT.getInt(to));
          } catch (NoSuchFieldException e) {
            // Ignore
          }
    
          // For "Compact Strings" introduced in Java 9
          try {
            Field CODER = String.class.getDeclaredField("coder");
            CODER.setAccessible(true);
            CODER.set(from, CODER.get(to));
          } catch (NoSuchFieldException e) {
            // Ignore
          }
        } catch (IllegalAccessException e) {
          e.printStackTrace();
        } catch (NoSuchFieldException e) {
          e.printStackTrace();
        }
      }
    }Code-Sprache: Java (java)

    Der folgende Screenshot zeigt, wie der Code unter Java 6 läuft:

    String Deep Reflection mit Java 6
    String Deep Reflection mit Java 6

    Experiment „Compressed Strings“ in Java 6u21

    Der Artikel wäre nicht vollständig, würden wir nicht kurz auf die in Java 6 als „experimental“ eingeführten „Compressed Strings“ eingehen (nicht zu verwechseln mit den o. g. in Java 9 eingeführten „Compact Strings“!).

    Wenn über die VM-Option -XX:+UseCompressedStrings aktiviert, dann wird, sofern ein String nur Latin-1-Zeichen enthält, im value-Feld anstatt eines Character-Arrays ein Byte-Array gespeichert. Dies wurde jedoch nicht im String-Quellcode gemacht, sondern intern in der JVM. Diese Optimierung hat zwar Speicher gespart, war aber sehr unperformant, da das Byte-Array für die fast alle String-Operationen in ein Character-Array konvertiert werden musste. In Java 7 wurde die Funktion wieder entfernt.

    Da diese Optimierung JVM-intern durchgeführt wurde, ist unser Code ohne weitere Anpassung auch mit aktivierten „Compressed Strings“ lauffähig:

    String Deep Reflection mit Java 6 und "-XX:+UseCompressedStrings"
    String Deep Reflection mit Java 6 und „-XX:+UseCompressedStrings“

    Fazit

    In der Praxis solltet ihr davon absehen, die internen Werte von gecachten Integer-Objekten oder von Strings zu verändern. Dies könnte unvorhersehbare Konsequenzen haben. Die Änderung hat nicht nur Auswirkungen auf euren eigenen Code, sondern auch auf den restlichen Code des Projekts inklusive aller Libraries und Frameworks, die vom selben Classloader geladen werden.

    Ihr solltet euch auch nicht auf die interne Repräsentation einer Klasse verlassen. Wie am String-Beispiel gezeigt, kann sich diese von einer Java-Version zur nächsten ändern.

    Außerdem bekommen wir für die Code-Beispiele aus diesem Artikel seit Java 9 eine Fehlermeldung:

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

    Das bedeutet: Wir dürfen nicht davon ausgehen, dass unser Code für immer und ewig funktioniert. In Java 14 (release candidate) und 15 (early access) allerdings funktioniert der Code nach wie vor. Und da zahlreiche 3rd-Party-Frameworks von Deep Reflection Gebrauch machen, wird Oracle diese Funktion auch sicher in absehbarer nicht Zukunft entfernen.