StableValue in Java 25
-
Last Updated: November 9, 2025
-
By: javahandson
-
Series
In this article, we’ll explore everything about StableValue in Java 25 – from what it is and why it exists, to how it works internally, its syntax, performance behavior, and how we can migrate existing lazy-initialization patterns to this modern, efficient API. By the end, we’ll clearly understand how StableValue simplifies concurrent programming and paves the way for Java’s future under Project Valhalla.
In Java, we often use the final keyword, records, or libraries like ImmutableList to make objects unchangeable. But these only give shallow immutability – meaning we can’t reassign a field, but the objects inside it can still change. This can cause problems in multi-threaded programs or when objects are shared across different parts of an application.
To solve this, Java 25 introduces a new preview feature called StableValue (JEP 502). It allows us to create truly immutable classes, meaning once an object is created, nothing inside it can change. This gives developers a simple and reliable way to make data safe to share across threads without worrying about unexpected modifications.
StableValue is a big improvement for writing safer and faster Java programs. It removes the need for extra defensive copies or synchronization and brings Java closer to modern value-based programming – where objects are small, stable, and efficient by design.
Before we understand why StableValue is needed, it’s important to see where immutability in Java often falls short. The confusion usually comes from two levels — reference-level immutability and object-level immutability.
Let’s start with a mutable object. A mutable object can change its internal state after it’s created.
StringBuilder sb = new StringBuilder("Java");
sb.append(" HandsOn!");
System.out.println(sb); // prints "Java HandsOn!"
Here, the same StringBuilder object was modified – no new object was created. Its internal data (the characters) changed directly, which makes StringBuilder mutable.
Now compare this with an immutable object like String:
String s = "Java";
s.concat(" HandsOn!");
System.out.println(s); // prints "Java"
In this case, s.concat(” HandsOn!”) creates a new String object. The original “Java” remains unchanged, which means String is immutable.
Case 1: Reference-Level Mutability
Consider this example:
Logger LOGGER = Logger.create("OrderService");
LOGGER = Logger.create("PaymentService"); // changes reference
Here, the variable LOGGER starts by pointing to one object and then points to another. The reference itself is mutable.
To prevent this, we use the final keyword:
final Logger LOGGER = Logger.create("OrderService");
LOGGER = Logger.create("PaymentService"); // Compile-time error
Now, LOGGER cannot point to any other object – the reference is immutable.
But remember, this only stops reassignment of the variable. It doesn’t make the object itself immutable.
Case 2: Object-Level Mutability
Let’s take this simple class:
class Logger {
private String level;
private String name;
Logger(String name) { this.name = name; }
void setLevel(String level) { this.level = level; }
String getLevel() { return level; }
}
Now we create a final reference:
final Logger LOGGER = new Logger("OrderService");
LOGGER.setLevel("DEBUG");
LOGGER.setLevel("ERROR");
Even though the reference LOGGER cannot point to another object, its internal state (the level field) keeps changing.
The reference is immutable, but the object itself is still mutable.
Strings are a perfect example of deep immutability – both reference and object-level immutability.
final String str = "Hello"; str = "World"; // not allowed (reference immutable)
And even internally:
str.replace("H", "J"); // returns new "Jello", original "Hello" stays
Here, a new object is created, and the original string never changes.
The reference is immutable, and so is the object itself.
StableValue aims to give this kind of immutability guarantee to any class we define not just String. It enforces both reference-level and object-level immutability automatically, so our objects become deeply stable and thread-safe by design.
StableValue is a new preview feature introduced in Java 25 (JEP 502) that brings true deep immutability to user-defined classes. It allows developers to declare a class as stable, meaning once an object of that class is created, nothing inside it can ever change – not even the objects it references.
In simpler terms, StableValue lets us store a reference that can be safely initialized once – either eagerly or lazily – and then stays constant for the lifetime of the object. It’s ideal for cases where we want thread-safe, one-time initialization without using locks, synchronization, or volatile variables. Once the value is set, the JVM guarantees that all threads will see the same final value, making it safe for concurrent use.
package com.javahandson;
import java.lang.StableValue;
class Student {
// 'id' can be set only once
private final StableValue<String> id = StableValue.of();
private final String name;
public Student(String name) {
this.name = name;
}
public String getId() {
// Lazily assign ID only once
return id.orElseSet(this::generateUniqueId);
}
private String generateUniqueId() {
System.out.println("Generating ID for " + name + "...");
return "STU-" + System.currentTimeMillis();
}
public void printDetails() {
System.out.println("Name: " + name + ", ID: " + getId());
}
}
public class Main {
public static void main(String[] args) {
Student s = new Student("Suraj");
s.printDetails();
s.printDetails(); // Second call reuses the same ID, not recalculated
}
}
Output:
Generating ID for Suraj...
Name: Suraj, ID: STU-1761640021363
Name: Suraj, ID: STU-1761640021363
In the above example, the ID is assigned only once — even if getId() is called multiple times or from multiple threads.
In short, StableValue brings a new, JVM-level way to create stable, unchangeable data, improving both safety and performance compared to manual synchronization or final references.
In Java, immutability has always been encouraged because it makes code safer, simpler, and easier to reason about. Immutable objects can be freely shared across threads without worrying about unexpected changes. However, traditional tools like the final keyword or even records only provide shallow immutability – they make the reference unchangeable but don’t protect the internal state of the object. This means we can still accidentally modify nested or referenced objects inside an instance, breaking immutability in practice.
For example, declaring a field as final only ensures that the variable cannot point to a new object, but it doesn’t stop the existing object from changing its state. Developers often rely on defensive copying or external synchronization to prevent shared data from being modified, which adds both complexity and runtime cost. In large applications, especially those involving concurrency, this approach can lead to subtle bugs and performance bottlenecks.
StableValue fixes this gap by introducing a JVM-supported mechanism for set-once, deeply stable references. Once a StableValue is initialized, the runtime guarantees that its value will never change again, no matter how many threads access it. This makes it perfect for scenarios like lazy initialization, caching, configuration management, or shared constants — where data should be written once and then treated as read-only for the rest of the program’s life.
In short, we need StableValue because it gives Java developers a reliable, built-in way to achieve deep immutability and thread safety without relying on synchronization, volatile variables, or third-party libraries. It simplifies code, removes common concurrency risks, and makes immutable design patterns a first-class part of the Java language.
StableValue works by introducing a new kind of JVM-level immutability contract — something Java developers previously had to simulate using patterns like “double-checked locking,” volatile, or AtomicReference. Unlike these approaches, StableValue is built into the Java runtime, meaning the JIT compiler and memory model both understand its semantics and can optimize it safely.
Under the hood, a StableValue<T> object starts in an uninitialized state. When we call methods like #trySet(value) or #orElseSet(supplier), the JVM checks whether the value has already been set. If not, it performs an atomic write to store the value and marks it as stable. From that point onward, the reference is considered deeply immutable – no further changes are allowed.
Here’s a simple example:
package com.javahandson;
import java.lang.StableValue;
class Student {
// 'id' can be set only once
private final StableValue<String> id = StableValue.of();
private final String name;
public Student(String name) {
this.name = name;
}
public String getId() {
// Lazily assign ID only once
return id.orElseSet(this::generateUniqueId);
}
private String generateUniqueId() {
System.out.println("Generating ID for " + name + "...");
return "STU-" + System.currentTimeMillis();
}
}
public class Main {
public static void main(String[] args) {
Student s = new Student("Suraj");
System.out.println("ID: " + s.getId());
System.out.println("ID: " + s.getId()); // Second call reuses the same ID, not recalculated
}
}
Output:
Generating ID for Suraj...
ID: STU-1761640509084
ID: STU-1761640509084
When #getId() is called the first time, the JVM uses #orElseSet() to assign the value atomically. On subsequent calls, it simply reads the already-stored value – no locks, no synchronization, no recomputation. This behavior is thread-safe by design, thanks to the JVM’s intrinsic handling of StableValue fields.
In essence, StableValue brings JVM-enforced immutability and lock-free lazy initialization into our code. Once set, the value can be read many times, shared across threads, and safely trusted to remain constant – offering both performance and correctness guarantees that traditional final or volatile references cannot fully provide.
Here are some important internal details:
1. Set-once rule enforced by the JVM – Once a value is written to a StableValue, it cannot be overwritten. This is not just a convention, it’s enforced by the runtime itself. Any second write attempt fails silently or is ignored.
2. Memory visibility guarantees – When a StableValue is set, the JVM ensures that all threads see the same value immediately. This avoids the usual volatile or synchronization requirements. The Java Memory Model treats the “set” operation as a happens-before event for all subsequent reads.
3. JIT compiler optimization – Since the runtime knows the reference won’t change, the JIT compiler can perform constant folding and eliminate redundant reads, making access to StableValue nearly as fast as a plain final field after initialization.
4. Lock-free concurrency – Internally, StableValue relies on atomic CAS (Compare-And-Swap) operations. This means even if multiple threads try to initialize it at the same time, only one will succeed, and the rest will safely get the same stable value.
Example – Using #trySet() : Only one thread succeeds
package com.javahandson;
import java.lang.StableValue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class Main {
public static void main(String[] args) throws InterruptedException {
var winner = StableValue.<String>of();
int threads = 5;
var pool = Executors.newFixedThreadPool(threads);
var latch = new CountDownLatch(1);
for (int i = 0; i < threads; i++) {
final int id = i;
pool.submit(() -> {
try {
latch.await();
} catch (InterruptedException ignored) {
}
String attempt = "changed-value-" + id;
boolean ok = winner.trySet(attempt); // set-once attempt
System.out.printf("%s: trySet(%s) -> %s%n",
Thread.currentThread().getName(), attempt, ok);
});
}
latch.countDown(); // release all threads to race
pool.shutdown();
pool.awaitTermination(2, TimeUnit.SECONDS);
// Only one value wins; all threads see the same final contents
System.out.println("Final: " + winner.orElseThrow());
}
}
Output:
pool-1-thread-3: trySet(changed-value-2) -> true
pool-1-thread-2: trySet(changed-value-1) -> false
pool-1-thread-5: trySet(changed-value-4) -> false
pool-1-thread-4: trySet(changed-value-3) -> false
pool-1-thread-1: trySet(changed-value-0) -> false
Final: changed-value-2
Example – #orElseSet(Supplier): supplier runs at most once, even under races.
package com.javahandson;
import java.lang.StableValue;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class Main {
public static void main(String[] args) throws InterruptedException {
var id = StableValue.<String>of();
int threads = 5;
var pool = Executors.newFixedThreadPool(threads);
var latch = new CountDownLatch(1);
for (int i = 0; i < threads; i++) {
pool.submit(() -> {
try {
latch.await();
} catch (InterruptedException ignored) {
}
String v = id.orElseSet(() -> {
// This lambda is guaranteed to be evaluated at most once
System.out.println("Initializing in " + Thread.currentThread().getName());
return "ID-" + UUID.randomUUID();
});
System.out.println(Thread.currentThread().getName() + " Value: " + v);
});
}
latch.countDown();
pool.shutdown();
pool.awaitTermination(2, TimeUnit.SECONDS);
}
}
Output:
Initializing in pool-1-thread-2
pool-1-thread-3 Value: ID-c4f9d6c9-1ffa-43ca-9ee9-c1d8ec8160ec
pool-1-thread-2 Value: ID-c4f9d6c9-1ffa-43ca-9ee9-c1d8ec8160ec
pool-1-thread-4 Value: ID-c4f9d6c9-1ffa-43ca-9ee9-c1d8ec8160ec
pool-1-thread-5 Value: ID-c4f9d6c9-1ffa-43ca-9ee9-c1d8ec8160ec
pool-1-thread-1 Value: ID-c4f9d6c9-1ffa-43ca-9ee9-c1d8ec8160ec
In the above example, the “Initializing in …” line appears once; every thread prints the same ID-… value. The API explicitly guarantees the supplier is evaluated at most once, even with concurrent orElseSet(…) calls.
StableValue is a holder whose contents can be set at most once (eagerly or lazily); after that, reads are safe and may even be optimized by the JVM like true constants.
# compile & run with preview enabled javac --enable-preview --release 25 StableValueShowcase.java java --enable-preview StableValueShowcase
1. Empty (unset) : StableValue<String> id = StableValue.of();
Starts unset; we’ll set it later.
2. Pre-set : StableValue version = StableValue.of(“25.0”);
Starts set; immutable thereafter.
package com.javahandson;
import java.lang.StableValue;
import java.util.logging.Logger;
public class Main {
private static final Logger LOG = Logger.getLogger("StableValueDemo");
// Pre-set StableValue
private static final StableValue<String> VERSION = StableValue.of("25.0.1");
// Empty StableValue — will be set later
private static final StableValue<String> STUDENT_ID = StableValue.of();
public static void main(String[] args) {
LOG.info(() -> "VERSION.orElseThrow() = " + VERSION.orElseThrow());
LOG.info(() -> "STUDENT_ID.isSet() = " + STUDENT_ID.isSet());
LOG.info(() -> "STUDENT_ID.orElse(\"UNKNOWN\") = " + STUDENT_ID.orElse("UNKNOWN"));
}
}
Output:
INFO: VERSION.orElseThrow() = 25.0.1
INFO: STUDENT_ID.isSet() = false
INFO: STUDENT_ID.orElse("UNKNOWN") = UNKNOWN
Setting the Value (exactly once)
We have three options:
1. boolean trySet(T) – optimistic, non-throwing
boolean won = id.trySet("S-1001"); // true only for the first writer
2. void setOrThrow(T) – strict
id.setOrThrow("S-1001"); // throws IllegalStateException if already set
We have to use this when a second write is a hard error.
3. T orElseSet(Supplier) – lazy and atomic
String v = id.orElseSet(() -> computeIdOnce());
If unset: compute once and set atomically – even under races; losers just observe the same value. Returns the contents in all cases.
package com.javahandson;
import java.lang.StableValue;
import java.util.Random;
import java.util.logging.Logger;
public class Main {
private static final Logger LOG = Logger.getLogger("StableValueDemo");
public static void main(String[] args) {
StableValue<String> id = StableValue.of();
// 1. trySet — optimistic
boolean first = id.trySet("S-1001");
LOG.info(() -> "trySet 1st attempt: " + first);
boolean second = id.trySet("S-1002");
LOG.info(() -> "trySet 2nd attempt: " + second);
LOG.info(() -> "Current value: " + id.orElseThrow());
// 2. orElseSet — lazy, atomic, at-most-once
StableValue<String> raceOnce = StableValue.of();
Runnable task = () -> {
String value = raceOnce.orElseSet(() -> {
try {
Thread.sleep(new Random().nextInt(30) + 10);
} catch (InterruptedException ignored) {
}
return "COMPUTED-" + System.nanoTime();
});
LOG.info(() -> Thread.currentThread().getName() + " got: " + value);
};
Thread t1 = new Thread(task, "T1");
Thread t2 = new Thread(task, "T2");
Thread t3 = new Thread(task, "T3");
t1.start();
t2.start();
t3.start();
try {
t1.join();
t2.join();
t3.join();
} catch (InterruptedException ignored) {
}
LOG.info(() -> "Final shared value: " + raceOnce.orElseThrow());
}
}
Output:
INFO: trySet 1st attempt: true
INFO: trySet 2nd attempt: false
INFO: Current value: S-1001
INFO: T2 got: COMPUTED-76632563866100
INFO: T3 got: COMPUTED-76632563866100
INFO: T1 got: COMPUTED-76632563866100
INFO: Final shared value: COMPUTED-76632563866100
StableValue ships convenient caching wrappers that compute at most once per call-site, index, or key and then cache:
1. Stable supplier – Caches one computed result for repeated #get() calls.
Supplier<Logger> logger = StableValue.supplier(() -> Logger.getLogger("App"));
Logger L = logger.get(); // computes once, then cached
2. Stable IntFunction – Precomputes/caches results per index.
IntFunction<Integer> pow2 = StableValue.intFunction(6, n -> 1 << n); int sixteen = pow2.apply(4); // may constant-fold at runtime
3. Stable Function – Caches per key from a defined key set.
Function<Integer,Integer> log2 =
StableValue.function(Set.of(1,2,4,8,16,32),
i -> 31 - Integer.numberOfLeadingZeros(i));
int four = log2.apply(16);
4. Stable List – Lazily builds an immutable list.
List<String> names = StableValue.list(3, i -> "N" + i); // at-most-once per index
5. Stable Map – Lazily builds an immutable map.
Map<String,Integer> ranks = StableValue.map(Set.of("A","B"), k -> k.charAt(0) - '@');
package com.javahandson;
import java.lang.StableValue;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.function.IntFunction;
import java.util.function.Supplier;
import java.util.logging.Logger;
public class Main {
// Stable supplier — caches once
private static final Supplier<Logger> LOGGER =
StableValue.supplier(() -> Logger.getLogger("App"));
// Stable IntFunction — caches results for indices [0, size)
private static final IntFunction<Integer> POW2 =
StableValue.intFunction(6, n -> 1 << n);
// Stable Function — caches once per allowed key
private static final Function<Integer, Integer> LOG2 =
StableValue.function(Set.of(1, 2, 4, 8, 16, 32),
i -> 31 - Integer.numberOfLeadingZeros(i));
// Stable collections — lazily compute once per element
private static final List<String> NAMES =
StableValue.list(3, i -> "N" + i); // ["N0","N1","N2"]
private static final Map<String, Integer> RANKS =
StableValue.map(Set.of("A", "B"), k -> k.charAt(0) - '@'); // A->1, B->2
public static void main(String[] args) {
Logger log = LOGGER.get();
log.info(() -> "POW2[4] = " + POW2.apply(4));
log.info(() -> "LOG2(16) = " + LOG2.apply(16));
// Force list evaluation
for (int i = 0; i < NAMES.size(); i++) {
NAMES.get(i);
}
// Force map evaluation
for (var k : RANKS.keySet()) {
RANKS.get(k);
}
log.info(() -> "NAMES = " + NAMES);
log.info(() -> "RANKS = " + RANKS);
}
}
Output:
INFO: POW2[4] = 16
INFO: LOG2(16) = 4
INFO: NAMES = [N0, N1, N2]
INFO: RANKS = {B=2, A=1}
Explanation of the above example:
1. StableValue.supplier(…)
private static final Supplier LOGGER =<br>StableValue.supplier(() -> Logger.getLogger("App"));
Creates a stable supplier that runs the lambda exactly once, even if multiple threads call LOGGER.get() simultaneously. After the first execution, the result is cached and reused for all subsequent get() calls. This is perfect for singleton-style lazy initialization.
In main() : Logger log = LOGGER.get();
The first call creates a Logger named “App”. Every later call returns the same instance — no new loggers created, no locking needed.
2. StableValue.intFunction(size, mapper)
private static final IntFunction POW2 =<br>StableValue.intFunction(6, n -> 1 << n);
Builds a cache for indexed computations. The size parameter (6) means valid indices are 0 through 5. Each index value (n) is computed once using the mapper n -> 1 << n (which calculates 2ⁿ).
Usage: POW2.apply(4); // Returns 16 (computed once, then cached)
If called again with 4, the cached result 16 is instantly returned. Ideal for pre-computed lookup tables or small fixed arrays.
3. StableValue.function(keys, mapper)
private static final Function LOG2 =<br>StableValue.function(Set.of(1, 2, 4, 8, 16, 32),
i -> 31 - Integer.numberOfLeadingZeros(i));
Creates a key-based cache for a finite key set. Each allowed key (1, 2, 4, 8, 16, 32) is mapped once using the given function. Later lookups are constant-time and thread-safe.
Usage : LOG2.apply(16); // returns 4
(log₂(16) = 4), computed only on the first access for that key.
4. StableValue.list(size, mapper) and StableValue.map(keys, mapper)
private static final List NAMES =<br>StableValue.list(3, i -> "N" + i); // ["N0","N1","N2"]
private static final Map RANKS =<br>StableValue.map(Set.of("A", "B"), k -> k.charAt(0) - '@'); // A→1, B→2
These helpers build immutable, lazily initialized collections. Each element or entry is computed at most once on first access. After computation, the list/map behaves like an unmodifiable, cached view.
Purpose – Designed for lazy, at-most-once initialization with lock-free concurrency. Once a value is set, it becomes stable and visible to all threads.
Usage – Use StableValue.of(), trySet(), or orElseSet() to safely initialize shared objects like caches, configuration, or singletons.
Behavior – Multiple threads racing to initialize will result in only one successful write; others simply observe the same established value.
Performance benefit – After initialization, reads can be treated by the JVM like reads of constants (eligible for optimization).
Helper APIs – Comes with convenient builders (supplier(), function(), intFunction(), list(), map()) for one-time-computed suppliers, tables, or collections.
Status – Still a preview API in Java 25; not yet part of the permanent standard library. Enable it with –enable-preview.
Purpose – Guarantees that a reference or variable cannot be reassigned after initialization.
Initialization – Must be assigned inside the constructor (for instance, fields) or at declaration (for static fields). No lazy setting afterward.
Thread safety – Java’s memory model gives safe-publication semantics to properly constructed final fields, ensuring visibility of their value after object construction.
Limitation – Does not provide deferred initialization; we must know the value at construction time.
Usage example – Perfect for constants, immutable identifiers, and value objects whose state never changes.
Status – Fully stable and part of Java since its first version.
Purpose – Introduced in Java 16 to represent immutable data aggregates with minimal boilerplate.
Syntax sugar – The compiler automatically creates a constructor, getters, equals(), hashCode(), and toString().
Semantics – Record components are implicitly final, making records naturally immutable by reference.
Focus – Solves data-modeling problems, not concurrency or lazy initialization.
Usage – Ideal for DTOs, configuration snapshots, request/response payloads, and small immutable structures.
Limitation – Doesn’t give any “compute-once” behavior; initialization still happens eagerly when a record instance is created.
Purpose – Brings identity-free, flattened value types that behave like lightweight objects but store like primitives.
Goal – Improve performance and memory density by removing object identity overhead (no object header, no reference indirection).
Semantics – “Code like a class, work like an int” — fully immutable, compared by value, cannot be null.
Use cases – Mathematical tuples, vectors, and small immutable aggregates where performance matters.
Differences from StableValue – Focused on representation and performance, not on lazy or concurrent initialization.
Status – Still under development (Project Valhalla); not yet part of the mainstream JDK, but planned for future Java releases.
Use StableValue – when we want lazy, lock-free, once-only initialization of shared or expensive resources.
Use final – when we want immutability and thread-safe publication, but can initialize eagerly.
Use record – when we want a clean, immutable data carrier with automatic methods and no boilerplate.
Wait for Valhalla value objects – when we need high-performance, identity-less, memory-efficient data structures.
When Java 25 introduced the StableValue API as a preview feature, it wasn’t merely another convenience wrapper — it represents a new memory stability contract between our code and the JVM.
It’s designed to offer lock-free, atomic, once-only initialization that the JVM can safely treat as a constant afterward.
1. Lock-Free CAS (Compare-And-Swap)
2. Happens-Before Relationship
3. Visibility Without Volatile Reads
1. Write (Initialization) Path
Cost: Slightly higher than a simple assignment, but far cheaper than locking.
2. Read (Access) Path
Cost: Equivalent to reading a final field – nanoseconds.
3. Contention & Scalability
Effect: Perfectly scalable under concurrent initialization.
4. Memory Footprint
Based on the Java 25 preview implementation of StableValue (JEP 502), below are some migration techniques. But before rolling out the changes to production, it’s best to verify all the changes and if the old and new changes behave the same.
1. Replace Double-Checked Locking
Before (traditional pattern):
private static volatile Config config;
public static Config getConfig() {
if (config == null) {
synchronized (MyService.class) {
if (config == null) {
config = loadConfig(); // expensive call
}
}
}
return config;
}
After (with StableValue):
private static final StableValue<Config> CONFIG = StableValue.of();
public static Config getConfig() {
return CONFIG.orElseSet(MyService::loadConfig);
}
Benefits:
2. Replace AtomicReference for Lazy Caching
Before:
private static final AtomicReference<CurrencyRates> RATES = new AtomicReference<>();
public static CurrencyRates getRates() {
CurrencyRates r = RATES.get();
if (r == null) {
CurrencyRates newRates = fetchRates();
if (RATES.compareAndSet(null, newRates)) r = newRates;
else r = RATES.get();
}
return r;
}
After:
private static final StableValue<CurrencyRates> RATES = StableValue.of();
public static CurrencyRates getRates() {
return RATES.orElseSet(MyApp::fetchRates);
}
Benefits – Cleaner syntax, no explicit CAS logic.
3. Replace the LazyHolder Singleton Idiom
Before:
class Service {
private Service() {}
private static class Holder { static final Service INSTANCE = new Service(); }
static Service getInstance() { return Holder.INSTANCE; }
}
After:
class Service {
private static final StableValue<Service> INSTANCE = StableValue.of();
static Service getInstance() { return INSTANCE.orElseSet(Service::new); }
}
Benefits – Works even when initialization logic involves external resources or exceptions.
4. Use StableValue Helpers for Cached Functions
Before:
private static final Map<String, Integer> CACHE = new ConcurrentHashMap<>();
public static int getLength(String s) {
return CACHE.computeIfAbsent(s, String::length);
}
After:
private static final Function<String, Integer> LENGTH_FN =
StableValue.function(Set.of("A","AB","ABC"), String::length);
public static int getLength(String s) {
return LENGTH_FN.apply(s);
}
Benefits – Built-in caching without maps or locks.
5. In short, Migrate wherever we use “lazy init + concurrency” – StableValue gives us the same safety, cleaner code, and better performance.
The introduction of StableValue in Java 25 marks a turning point in how Java handles safe, concurrent initialization. It elegantly replaces decades-old boilerplate with a clean, lock-free abstraction that guarantees correctness, visibility, and performance. Developers can now express lazy singletons, caches, and configuration loaders with a single line of code — no explicit synchronization, no race conditions, and no complexity.
While still a preview feature, StableValue lays the foundation for Java’s next generation of immutable and identity-free programming models. If we’re upgrading to Java 25, it’s worth experimenting with this API today – because in the near future, stability won’t just be a design principle, it will be a language-level feature.