Stable Values are variables that can be set only once during an application's lifetime at any moment and stay unchanged after that. They provide a standardized way to lazily initialize constants, letting the JVM optimize them just like it does with final values.
Stable Values are released as a preview feature in Java 25, specified in JDK Enhancement Proposal 502.
Related: Learn about Structured Concurrency in Java 25 and Scoped Values here.
As Java developers, we prefer to use immutable objects as much as possible because:
Java has long provided the final
keyword to create immutable fields. When you declare a field as final
, it can only be assigned once and cannot be changed afterward. This works great for simple constants:
public class ApiConfig { // Simple constant - initialized at declaration private static final String API_VERSION = "v2.1"; // Computed constant - initialized at declaration private static final int MAX_RETRIES = Integer.parseInt(System.getProperty("api.retries", "3")); }
For more complex initialization, you can use static initializer blocks:
public class CacheConfig { private static final Map<String, Integer> CACHE_SIZES; static { Map<String, Integer> sizes = new HashMap<>(); sizes.put("user", 1000); sizes.put("product", 5000); sizes.put("order", 2000); CACHE_SIZES = Collections.unmodifiableMap(sizes); } }
Instance fields can be initialized in constructors:
public class OrderProcessor { private final PaymentService paymentService; public OrderProcessor(String environment) { this.paymentService = PaymentServiceFactory.create(environment); } }
While final
fields work well for many scenarios, they have a significant limitation: they must be initialized when the class loads or when an object is constructed. This can be problematic for expensive operations.
The final
approach works perfectly when initialization is fast and always needed. However, modern applications often deal with expensive resources that shouldn't be created until they're actually required:
Consider a microservice that might need to connect to multiple external services:
public class ExternalServiceManager { private static final HttpClient httpClient = createHttpClient(); // 200ms private static final ElasticsearchClient esClient = createEsClient(); // 500ms private static final RedisClient redisClient = createRedisClient(); // 300ms // Application startup is delayed by 1000ms }
In cloud environments where startup time matters, this eager initialization can significantly impact application performance.
Sometimes we don't know at startup whether a resource will be needed. The obvious solution is to initialize resources only when needed:
public class EmailService { private static SMTPClient smtpClient; // WARNING: Not thread-safe! public static void sendEmail(String to, String subject, String body) { if (smtpClient == null) { smtpClient = new SMTPClient(loadConfiguration()); } smtpClient.send(to, subject, body); } }
This simple lazy initialization approach has several critical issues:
final
, there's no compiler guarantee that it won't be accidentally modified.final
, the JVM cannot perform constant folding or other optimizations, leading to slower performance.public class EmailService { private static SMTPClient smtpClient; public static synchronized void sendEmail(String to, String subject, String body) { if (smtpClient == null) { smtpClient = new SMTPClient(loadConfiguration()); } smtpClient.send(to, subject, body); } }
This approach synchronizes every access, not just initialization, causing unnecessary performance overhead.
A more sophisticated approach uses double-checked locking to improve performance:
public class EmailService { private static volatile SMTPClient smtpClient; public static void sendEmail(String to, String subject, String body) { SMTPClient localRef = smtpClient; if (localRef == null) { synchronized (EmailService.class) { localRef = smtpClient; if (localRef == null) { smtpClient = localRef = new SMTPClient(loadConfiguration()); } } } localRef.send(to, subject, body); } }
volatile
keyword or implement incorrectlyJava 25 introduces Stable Values to solve all these lazy initialization challenges elegantly. Stable Value acts as a wrapper around a field that can be set only once and the wrapper ensures that the field cannot be modified after it is set.
Stable Values provide:
Let's see how Stable Values transform our problematic EmailService
example above
public class EmailService { private static final StableValue<SMTPClient> smtpClient = StableValue.of(); public static void sendEmail(String to, String subject, String body) { SMTPClient client = smtpClient.orElseSet(() -> new SMTPClient(loadConfiguration())); client.send(to, subject, body); } }
Here we use static factory method StableFactory.of()
to create a new unset StableValue
instance with no content.
Then we use the method StableValue::orElseSet(Supplier)
to retrieve the contents of the stable value. If the stable value contains no contents, orElseSet
computes and sets the contents with the provided Supplier
.
Alternatively, we can create a set StableValue
directly using the static factory method StableValue.of(T contents)
StableValue<String> value = StableValue.of("value");
However here we will not get lazy initialization and hence not benefit from JVM optimizations.
You can also use StableValue.supplier()
to create a stable supplier that caches the result:
public class FeatureFlags { private static final Supplier<List<String>> enabledFeatures = StableValue.supplier(() -> loadFeatureFlagsFromDatabase()); public static List<String> getEnabledFeatures() { return enabledFeatures.get(); } public static boolean isFeatureEnabled(String feature) { return getEnabledFeatures().contains(feature); } }
Here when we call enabledFeatures.get()
it will check if stable value is set. If it is not set, it will compute the value and set it and then return the value.
Java 25 introduces specialized collections StableList and StableMap designed to optimize the initialization of collection elements by deferring their computation until the first access.
A stable list is an unmodifiable list, backed by an array of stable values and associated with an IntFunction to compute the elements.
The StableValue.list(int size, IntFunction<? extends E> mapper)
method creates a StableList
. This list initializes its elements lazily: each element is computed using the provided mapper
function only upon its first access, and the result is then cached for subsequent accesses.
Example:
Suppose we want to generate a list representing the 5 times table up to 10. Instead of computing all values upfront, we can use a StableList
to compute each value only when needed:
public class MathTables { private static final List<Integer> fiveTimesTable = StableValue.list(11, index -> { System.out.println("Computing 5 * " + index); return index * 5; }); public static void demonstrateStableList() { System.out.println("Accessing element at index 0:"); System.out.println(fiveTimesTable.get(0)); // Outputs: Computing 5 * 0, then 0 System.out.println("Accessing element at index 5:"); System.out.println(fiveTimesTable.get(5)); // Outputs: Computing 5 * 5, then 25 System.out.println("Accessing element at index 0 again:"); System.out.println(fiveTimesTable.get(0)); // Outputs: 0 (no computation) } }
In this example, each multiplication is performed only when the corresponding index is accessed for the first time, enhancing performance by avoiding unnecessary computations.
Similarly, the StableValue.map(Set<K> keys, Function<? super K, ? extends V> mapper)
method returns a StableMap
. This map initializes its values lazily: the value for each key is computed using the provided mapper
function only upon its first access, and the result is then cached.
Example:
Consider a scenario where we have a set of user ids and an expensive function that determines the profile for each user. Using a StableMap
, we can ensure that the profile is determined only when needed:
public class UserProfileCache { private static final Set<String> userIds = Set.of("alice", "bob", "carol"); private static final Map<String, String> userProfiles = StableValue.map(userIds, userId -> { System.out.println("Loading profile for: " + userId); // Simulate an expensive profile fetch return switch (userId) { case "alice" -> "Alice Smith, Admin"; case "bob" -> "Bob Johnson, Editor"; case "carol" -> "Carol Lee, Viewer"; default -> throw new IllegalArgumentException("Unknown user: " + userId); }; }); public static void demonstrateStableMap() { System.out.println("Fetching profile for alice:"); System.out.println(userProfiles.get("alice")); // Outputs: Loading profile for: alice, then Alice Smith, Admin System.out.println("Fetching profile for bob:"); System.out.println(userProfiles.get("bob")); // Outputs: Loading profile for: bob, then Bob Johnson, Editor System.out.println("Fetching profile for alice again:"); System.out.println(userProfiles.get("alice")); // Outputs: Alice Smith, Admin (no loading) } }
In this case, the expensive lookup function is invoked only when the profile for a specific user is requested for the first time, thereby optimizing resource usage.
The Stable Value API also provides StableValue.function(Set<? extends T> inputs, Function<? super T, ? extends R> underlying)
, which returns a function that caches results for specific inputs. The first parameter defines a set of expected inputs - you can only use these inputs when calling the function. If you use a value not in this set, an exception will occur.
When you call the function for the first time with a specific input, it computes and stores the result. Subsequent calls with the same input return the cached result without recomputation.
Example:
Using our user profiles example, we can create a stable function:
public class UserProfileCache { private static final Set<String> userIds = Set.of("alice", "bob", "carol"); private static final Function<String, String> userProfiles = StableValue.function(userIds, userId -> { System.out.println("Loading profile for: " + userId); // Simulate an expensive profile fetch return switch (userId) { case "alice" -> "Alice Smith, Admin"; case "bob" -> "Bob Johnson, Editor"; case "carol" -> "Carol Lee, Viewer"; default -> throw new IllegalArgumentException("Unknown user: " + userId); }; }); public static void demonstrateStableFunction() { System.out.println("Fetching profile for alice:"); System.out.println(userProfiles.apply("alice")); // Outputs: Loading profile for: alice, then Alice Smith, Admin System.out.println("Fetching profile for bob:"); System.out.println(userProfiles.apply("bob")); // Outputs: Loading profile for: bob, then Bob Johnson, Editor System.out.println("Fetching profile for alice again:"); System.out.println(userProfiles.apply("alice")); // Outputs: Alice Smith, Admin (no loading) } }
Internally, a stable value is kept in a non-final field marked with the JDK’s internal @Stable annotation. This tells the JVM that, although the field isn’t final, its value will only be set once and won’t change afterward. As a result, if the field holding the stable value is itself final, the JVM can safely treat the stable value as a constant. This enables optimizations like constant folding, even when accessing immutable data through several layers of stable values.
Stable Values allow you to define constants that are initialized lazily, only when first accessed. Once set, they become immutable and the JVM treats them like final fields, enabling optimizations such as constant folding. Introduced as a preview feature in Java 25, Stable Values are expected to become a common approach for managing configuration, initializing services, and handling resources in modern Java applications.
To stay updated with the latest updates in Java and Spring, follow us on LinkedIn and Medium.
Learn about the Structured concurrency API in Java 25, new way to write concurrent code
Scoped Values, a finalized feature in Java 25, provides an advanced way of sharing data in concurrent applications addressing the limitations of thread-local variables.
Get instant AI-powered summaries of YouTube videos and websites. Save time while enhancing your learning experience.