Zum Inhalt springen

Lazy Constants in Java (früher Stable Values) – Werte lazy initialisieren

Fünf Glasfläschchen vor Pfirsichrosa: drei mit Gold gefüllt und verschlossen, in eines fällt ein goldener Tropfen unter seinem Deckel, eines ist leer

Lazy Constants (in Java 25 hießen sie 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, Lazy Sets 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). In Java 27 folgte eine dritte Preview-Runde (JEP 531), die die API weiter verkleinert und um Lazy Sets ergänzt hat.

In 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)?

In 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 Entwickler:innen 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 bei der Initialisierung 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);

  // . . .
}

Alternativ mit einem static-Block:

public class UserService {
  private static final Logger LOGGER;

  static {
    LOGGER = LoggerFactory.getLogger(UserService.class);
  }

  // . . .
}

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

public class Task {
  private final UUID taskId = UUID.randomUUID();

  // . . .
}

Alternativ im Konstruktor:

public class Task {
  private final UUID taskId;

  public Task() {
    taskId = UUID.randomUUID();
  }

  // . . .
}

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;
  }

  // . . .
}

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;
  }

  // . . .
}

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

@Service
public class BusinessService {
  private Settings settings;

  @PostConstruct
  private void initializeSettings() {
    settings = loadSettingsFromDatabase();
  }

  // . . .
}

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;
}

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;
}

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 erst beim ersten Zugriff initialisiert – und das garantiert threadsicher. 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 Constant 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
  }

  // . . .
}

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.

Wirft die Berechnungsfunktion eine Unchecked Exception oder liefert sie null, geht die Lazy Constant in einen Fehlerzustand über: get() wirft eine NoSuchElementException, deren Ursache die ursprüngliche Exception ist (bei null eine NullPointerException). Jeder weitere get()-Aufruf wirft wieder eine NoSuchElementException, ohne die Berechnungsfunktion noch einmal aufzurufen – einen zweiten Versuch gibt es nicht. Eine Lazy Constant kann also nie null enthalten; für optionale Werte verwendest du ein Optional. Dasselbe gilt pro Element für die weiter unten beschriebenen Lazy Lists, Lazy Sets und Lazy Maps.

Vier Zustände einer Lazy Constant: nicht initialisiert, Berechnungsfunktion läuft, initialisiert, Fehlerzustand – aus dem Fehlerzustand führt kein Pfeil zurück
Beim ersten get()-Aufruf läuft die Berechnungsfunktion höchstens einmal – danach ist die Lazy Constant initialisiert oder dauerhaft im Fehlerzustand

Das von of() zurückgelieferte LazyConstant-Objekt könntest du übrigens auch in einer Variablen vom Typ Supplier speichern, da LazyConstant das Supplier-Interface erweitert.

Neben get() bietet LazyConstant keine weiteren fachlichen Methoden – mit Absicht: Jeder Zugriff auf den Inhalt läuft über get().

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 getFirst(), get(int index) oder getLast() – initialisiert wird.

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

List<Double> squareRoots = List.ofLazy(100, Math::sqrt);

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:

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());
}

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.9498743710662

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 Maps

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 initialisiert und dann gespeichert.

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

Set<Locale> supportedLocales = getSupportedLocales();
Map<Locale, ResourceBundle> resourceBundles =
    Map.ofLazy(supportedLocales, this::loadResourceBundle);

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

Lazy Sets

Seit Java 27 gibt es mit Set.ofLazy(…) auch eine Lazy-Variante für Set, sodass alle drei grundlegenden Collection-Typen – List, Set und Map – abgedeckt sind. Einem Lazy Set übergibst du eine Menge möglicher Kandidaten sowie eine Funktion, die für jeden Kandidaten entscheidet, ob er tatsächlich zur Menge gehört. Diese Funktion wird erst beim ersten Test eines Elements ausgeführt – und pro Element höchstens einmal.

Das folgende Beispiel prüft, welche Features für die aktuelle Anwendung freigeschaltet sind. Die möglichen Features stehen fest; ob ein Feature aktiv ist, klärt eine (möglicherweise teure) Abfrage gegen einen Feature-Flag-Service:

Set<String> candidates =
    Set.of("dark-mode", "beta-export", "ai-assistant", "live-collab");

Set<String> enabledFeatures =
    Set.ofLazy(candidates, this::isFeatureEnabled);

// ⟵ Only here is isFeatureEnabled("ai-assistant") called:
if (enabledFeatures.contains("ai-assistant")) {
  // ...
}

isFeatureEnabled() wird nur für die Features aufgerufen, die du auch wirklich abfragst – und wie bei Lazy Lists und Lazy Maps threadsicher.

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 Constant 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 stellt ein synchronized-Block während der Initialisierung sicher; die Sichtbarkeit des Inhalts für alle Threads regeln Acquire/Release-Memory Barriers, 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, Lazy Sets und Lazy Maps, die die Elemente in Listen, Sets 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“), Java 26 und im aktuellen Java 27 enthalten; ein Folge-JEP für Java 28 ist noch nicht eingeplant.

Wenn dir der Artikel weitergeholfen hat, würde ich mich sehr über eine positive Bewertung auf meinem ProvenExpert-Profil freuen. Dein Feedback hilft mir, meine Inhalte weiter zu verbessern und motiviert mich, neue informative Artikel zu schreiben.

👉 Bewertung abgeben

Dieses Thema im eigenen Code?

Du hast den Artikel gelesen – im Training arbeitet ihr damit. 2 Tage lang gehen wir die Themen an euren eigenen Projekten durch, statt an konstruierten Beispielen.

Praxisnah, verständlich und direkt auf euren Projektalltag übertragbar. Statt Theorie vermittle ich Prinzipien, die euch helfen, Code langfristig besser, wartbarer und performanter zu schreiben.

Java 25 SchulungAlle Trainings ansehen

Werde ein:e bessere:r Java-Entwickler:in

Mit meinem kostenlosen Newsletter bleibst du vorn. Modernes Java: neue Versionen & Features, Performance und JVM-Insights – 1x im Monat.

Suche