Java Stable Values JEP 502 Feature ImageJava Stable Values JEP 502 Feature Image
HappyCoders Glasses

Stable Values in Java - Endlich Werte sicher initialisieren!

Sven Woltmann
Sven Woltmann
Aktualisiert: 9. April 2025

„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 Stable Values und wie benutzt man sie?
  • Welche Vorteile bringt die Unveränderlichkeit eines Stable Values?
  • Wie haben wir Unveränderlichkeit bisher implementiert und welche Nachteile hatte das?
  • Was sind Stable Lists, Stable Maps und Stable Functions?
  • Was tun die einzelnen Methoden der StableValue-Klasse?
  • Wie funktionieren Stable Values intern?

Stable Values sind ein Preview-Feature, das voraussichtlich in Java 25 veröffentlicht wird. Sie werden in JDK Enhancement Proposal 502 definiert.

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

Warum Immutability (Unveränderlichkeit)?

Im der Einführung habe ich erklärt, dass es sich bei Stable Values 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 Wikipedia-Artikel über Double-checked Locking nachlesen.

Eine Alternative ist das sogenannte Class-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 und verstehen 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 Stable Values!

Die Lösung: Stable Values

Ein Stable Value ist ein Container, der ein Objekt enthält, den sogenannten „Inhalt“ (englisch: „content“). Ein Stable Value wird genau einmal initialisiert, bevor sein Inhalt abgerufen wird, danach ist er unveränderlich. Ein Stable Value ist thread-safe. Und die JVM kann einen Stable Value genauso gut durch Constant Folding optimieren wie ein finales Feld.

Im Folgenden zeige ich dir zwei Varianten, wie du das Settings-Beispiel mit einem Stable Value implementieren kannst.

Erste Variante, mit einem Getter:

public class BusinessService {
  private final StableValue<Settings> settings = StableValue.of();

  private Settings getSettings() {
    return settings.orElseSet(this::loadSettingsFromDatabase);
  }

  public Locale getLocale() {
    return getSettings().getLocale(); // ⟵ Here we access the stable value
  }

  // . . .
}Code-Sprache: Java (java)

Die statische StableValue.of()-Methode erzeugt einen uninitialisierten Stable Value.

Die orElseSet()-Methode prüft, ob der Inhalt des Stable Values bereits gesetzt ist. Wenn ja, gibt sie ihn zurück. Wenn nein, wird der Wert über den übergebenen Supplier (im Beispiel: die Methodenreferenz this::loadSettingsFromDatabase) abgerufen und im Stable Value gespeichert. Jeder nachfolgene Aufruf von orElseSet() gibt dann den gespeicherten Wert zurück.

Die orElseSet()-Methode garantiert, dass der Supplier nur einmal aufgerufen wird, auch wenn die Methode aus mehreren Threads gleichzeitig aufgerufen wird.

Das ist schon deutlich weniger kompliziert als zuvor, da die if-Prüfung wegfällt und wir uns um die Threadsicherheit keine Gedanken mehr machen müssen. Aber wir haben immer noch eine Hilfsmethode.

Geht es auch ohne Hilfsmethode?

Stable Supplier

Ja, mit der zweiten Variante, einem Supplier:

public class BusinessService {
  private final Supplier<Settings> settings =
      StableValue.supplier(this::loadSettingsFromDatabase);

  public Locale getLocale() {
    return settings.get().getLocale(); // ⟵ Here we access the stable value
  }

  // . . .
}
Code-Sprache: Java (java)

StableValue.supplier() liefert einen sogenannten „Stable Supplier“. Beim ersten Aufruf der get()-Methode wird der Inhalt des Stable Values einmalig initialisiert.

Die Deklaration und die Initialisierung des Feldes liegen bei einem Stable Supplier direkt nebeneinander, was den Code lesbarer macht, als wenn die Initialisierung in einer Hilfsmethode liegt.

Warum sollte man dann überhaupt Variante eins, also die Hilfsmethode wählen? Weil es Use Cases geben könnte, in denen die Initialisierung eines Stable Values auf Informationen zurückgreifen muss, auf die man eben nur in so einer Hilfsmethode Zugriff hat.

Stable List

Wir können nicht nur einzelne Stable Values definieren, sondern auch eine Liste von Stable Values, 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 Stable List, in der jedes Element bei dessen ersten Aufruf mit der Quadratwurzel des Listenindexes initialisiert wird:

List<Double> squareRoots = StableValue.list(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 Stable Lists.

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

void main() {
  List<Double> squareRoots = StableValue.list(100, i -> {
    println("Initializing list element at index " + i);
    return Math.sqrt(i);
  });

  println("squareRoots[0]    = " + squareRoots.get(0));
  println("squareRoots[1]    = " + squareRoots.get(1));
  println("squareRoots[2]    = " + squareRoots.get(2));
  println("squareRoots[0]    = " + squareRoots.get(0));
  println("squareRoots.first = " + squareRoots.getFirst());
  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 Stable 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()).

Stable Map

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

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

Set<Locale> supportedLocales = getSupportedLocales();
Map<Locale, ResourceBundle> resourceBundles = 
    StableValue.map(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.

Stable Function

Die gleiche Funktionalität können wir auch mit einer Stable Function implementieren:

Set<Locale> supportedLocales = getSupportedLocales();
Function<Locale, ResourceBundle> resourceBundles = 
    StableValue.<em>function</em>(supportedLocales, this::loadResourceBundleForLocale);Code-Sprache: Java (java)

Stable Function und Stable Map unterscheiden sich durch ihre APIs:

  • Bei einer Stable Map können wir die Methoden des Map-Interfaces verwenden – z. B. get(), containsKey() und size()put() hingegen führt zu einer UnsupportedOperationException.
  • Bei einer Stable Function können wir die Methoden des Function-Interfaces verwenden – apply(), andThen() und compose().

Stable IntFunction

Ein Spezialfall der Stable Function ist eine Stable IntFunction. Diese hat als Eingabeparameter ein int. Damit ähnelt sie wiederum der Stable List:

IntFunction<Double> squareRoots = StableValue.intFunction(100, Math::sqrt);Code-Sprache: Java (java)

Stable IntFunction und Stable List unterscheiden sich wiederum durch ihre APIs:

  • Bei einer Stable List können wir die Methoden des List-Interfaces verwenden – z. B. get(), getFirst() und getLast() – alle modifizierenden Methoden hingegen führen zu einer UnsupportedOperationException.
  • Bei einer Stable IntFunction können wir die (einzige) Methode des IntFunction-Interfaces verwenden – apply().

StableValue API

Du hast im Laufe dieses Artikels die statischen Methoden of(), supplier(), list(), map(), function() und intFunction() sowie die Instanzmethode orElseSet() kennengelernt. Hier noch einmal kurz und knapp deren Definitionen:

Statische Methoden:

  • of() – erzeugt einen Stable Value ohne Inhalt und ohne Supplier für den Inhalt.
  • supplier(Supplier supplier) – erzeugt einen Stable Supplier, der beim Aufruf der get()-Methode einmalig den Inhalt über den übergebenen Supplier initialisiert.
  • list(int size, IntFunction mapper) – erzeugt ein stabile Liste, also eine Liste, deren Elemente einmalig beim Zugriff über die übergebene mapper-Funktion initialisiert werden.
  • map(Set keys, Function mapper) – erzeugt ein stabile Map, analog zur stabilen Liste, die für jeden Eingabewert aus dem keys-Set die übergebene mapper-Funktion maximal einmal aufruft und den zurückgegebenen Wert speichert.
  • function(Set inputs, Function original) – erzeugt einen stabilen Wrapper um eine Function, der für jeden Eingabewert aus dem inputs-Set die original-Funktion maximal einmal aufruft und den zurückgegebenen Wert speichert.
  • intFunction(int size, IntFunction original) – erzeugt einen stabilen Wrapper um eine IntFunction, der für jeden int-Wert im Bereich 0 bis size-1 die original-Funktion maximal einmal aufruft und den zurückgegebenen Wert speichert.

Instanzmethoden:

  • orElseSet(Supplier supplier) – gibt den Inhalt des Stable Values zurück, wenn er gesetzt ist; setzt andernfalls den Inhalt durch Aufruf des übergebenen Suppliers und gibt diesen zurück.

Die StableValue-Klasse definiert noch einige weitere Methoden, die ich an dieser Stelle nur kurz beschreibe, ohne ausführliche Beispiele.

Weitere statische Methoden:

  • of(T content) – erzeugt einen bereits mit content initialisierten Stable Value.

Weitere Instanzmethoden:

  • trySet(T content) – setzt den Inhalt und gibt true zurück, wenn der Inhalt noch nicht gesetzt ist; gibt andernfalls false zurück.
  • orElse(T other) – gibt den Inhalt zurück, wenn er gesetzt ist; gibt andernfalls other zurück, ohne dabei den Inhalt auf other zu setzen.
  • orElseThrow() – gibt den Inhalt zurück, wenn er gesetzt ist, wirft anderfalls eine NoSuchElementException.
  • isSet() – prüft ob der Inhalt gesetzt ist und gibt entsprechend true oder false zurück.
  • setOrThrow(T content) – ähnlich wie trySet(T content): setzt den Inhalt, wenn er noch nicht gesetzt ist; wirft andernfalls eine IllegalStateException.

Eine vorläufige Javadoc-Dokumentation findest du hier.

Wie funktionieren Stable Values intern?

Stable Values 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 eines Stable Values 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, StableValue 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 StableValue 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 StableValue in Zukunft durch Millionen von Java-EntwicklerInnen genutzt wird, können wir auch sicher sein, dass eventuelle Bugs – selbst subtile Concurrency-Bugs – schnell gefunden und behoben werden.

Fazit

Stable Values 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.

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

Neben Stable Values gibt es Stable Lists, Stable Maps und Stable Functions, die die Elemente in Listen, Values in Maps und Rückgabewerte von Funktionen einmalig initialisieren und dann unveränderlich speichern.

Stable Values sind ein Preview-Feature und im aktuellen Early-Access-Build von Java 25 (Build 17) noch nicht enthalten. Falls du mit ihnen experimentieren willst, müsstest du zum aktuellen Zeitpunkt die Klassen aus dem Pull Request kopieren.

Was hälst du von Stable Values? Teile deine Meinung in den Kommentaren!

Möchtest du als einer der Ersten informiert werden, sobald Stable Values im Early-Access-Release von Java 25 verfügbar sind ... oder sobald Java 25 released wird? Dann klicke hier, und melde dich für den HappyCoders-Newsletter an, in dem ich dich regelmäßig über die neusten Entwicklungen aus der Java-Welt auf dem Laufenden halte.