Stable Values in Java 25: Safe Lazy Initialization for Modern Applications

    Stable Values in Java 25: Safe Lazy Initialization for Modern Applications

    29/09/2025

    Introduction

    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.

    Why do we need Stable Values?

    As Java developers, we prefer to use immutable objects as much as possible because:

    • They are inherently thread-safe and can be shared freely between threads without synchronization.
    • The JVM can optimize immutable objects, such as through constant folding, leading to better performance.
    • Code using immutable objects is easier to read, reason about, and debug since object state never changes unexpectedly.

    Traditional Approach: Using "final" Fields

    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 Need for Lazy Initialization

    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:

    Performance Problems with Eager Initialization

    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.

    Conditional Initialization Scenarios

    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:

    • Thread safety issues: In a multi-threaded environment, multiple threads could simultaneously check the null condition and create multiple instances.
    • Loss of immutability guarantees: Since the field cannot be final, there's no compiler guarantee that it won't be accidentally modified.
    • No JVM optimizations: Without final, the JVM cannot perform constant folding or other optimizations, leading to slower performance.

    Attempting Thread-Safe Solutions

    Synchronized Methods - Simple but Slow

    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.

    Double-Checked Locking - Complex and Error-Prone

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

    Problems with Double-Checked Locking:

      1. Complexity: The pattern is notoriously difficult to get right
      1. Subtle bugs: Easy to forget the volatile keyword or implement incorrectly
      1. Still mutable: The field can be modified after initialization
      1. No compiler optimizations: JVM cannot optimize access patterns
      1. Maintenance burden: Future developers must understand the intricate pattern

    The Solution: Stable Values

    Java 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:

    • Thread-safe lazy initialization: No manual synchronization needed
    • True immutability: Once set, values cannot be changed (even internally)
    • JVM optimizations: Full constant folding and performance optimizations
    • Clean, simple API: Minimal boilerplate code
    • Error handling support: Proper exception handling during initialization

    Basic Stable Values Usage

    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.

    Supplier for Initialization

    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.

    Stable Collections

    Java 25 introduces specialized collections StableList and StableMap designed to optimize the initialization of collection elements by deferring their computation until the first access.

    StableList

    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.

    StableMap

    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.

    Stable Functions

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

    How Do Stable Values Work Internally?

    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.

    Conclusion

    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.

    References

    Summarise

    Transform Your Learning

    Get instant AI-powered summaries of YouTube videos and websites. Save time while enhancing your learning experience.

    Instant video summaries
    Smart insights extraction
    Channel tracking