JEP 502 is a preview JDK Enhancement Proposal. For preview JEPs, we take a brief look; for stable JEPs, we dive deep. This is a brief look at JEP 502: Stable Value (Preview).
JEP Link: https://openjdk.org/jeps/502
1. Overview#
The StableValue API introduced by JEP 502 aims to solve a fundamental contradiction in Java development: the trade-off between immutability and initialization flexibility.
1.1. Limitations of Traditional Approaches#
We often encounter this design scenario: a field should not change after initialization, but its initialization timing may be uncertain and may need to be determined dynamically at runtime. Traditionally, Java provides two main approaches to achieve this:
Limitations of final fields:
- Must be set during construction or static initialization, but in practice, we often cannot determine a value in the constructor when writing programs
- Initialization order is constrained by declaration order
- Leads to “eager initialization” problems at application startup
Problems with mutable fields:
- Lose constant folding optimization opportunities
- Require complex synchronization mechanisms (requiring double-checked locking DCL), and in certain concurrent access scenarios, fields must be volatile or use
VarHandle’sgetRelease()andsetAcquire()methods to ensure visibility - Performance impact of volatile fields from DCL: even after the field is initialized, accessing it still requires reading the volatile field. Volatile reads have additional memory barriers, but the field actually no longer changes
1.2. StableValue Design#
StableValue solves these problems through the following design principles:
- Deferred Immutability: Values can be set at any time, but once set, they are immutable
- At-Most-Once Semantics: Even in concurrent environments, initialization happens only once
- JVM Optimization Friendly: Uses the
@Stableannotation to enable constant folding optimizations by the JVM
1.3. StableValue API Design#
Core interface: StableValue<T>
public sealed interface StableValue<T>
permits StableValueImpl {
// Setting operations
boolean trySet(T contents);
void setOrThrow(T contents);
// Reading operations
T orElseThrow();
T orElse(T other);
T orElseSet(Supplier<? extends T> supplier);
// Status query
boolean isSet();
// Factory methods
static <T> StableValue<T> of();
static <T> StableValue<T> of(T contents);
static <T> Supplier<T> supplier(Supplier<? extends T> underlying);
static <T> List<T> list(int size, IntFunction<? extends T> function);
static <K, V> Map<K, V> map(Set<K> keys, Function<? super K, ? extends V> function);
static <T> IntFunction<T> intFunction(int size, IntFunction<? extends T> function);
static <T> Function<T, R> function(Set<T> keys, Function<? super T, ? extends R> function);
}
Simply put: This interface uses a sealed interface to restrict implementations, ensuring:
- Only
StableValueImplcan implement this interface - Prevents unsafe external implementations
- Facilitates special optimizations by the JVM
1.3.1. orElseSet() - The Core Method#
T orElseSet(Supplier<? extends T> supplier);
Design Points:
- Guaranteed at-most-once execution: Even in high-concurrency scenarios, the supplier executes only once
- Thread-safe: Internal synchronization ensures thread safety (also using DCL)
- Exception handling: If the supplier throws an exception, the value is not set, and the exception propagates to the caller
- Recursion prevention: Detects and prevents recursive initialization (when the same thread re-enters, i.e., calling
orElseSetagain withinorElseSet, it throwsIllegalStateException)
Usage Comparison:
// Traditional approach: requires manual DCL synchronization
private Logger logger = null;
private final Object lock = new Object();
Logger getLogger() {
if (logger == null) {
synchronized (lock) {
if (logger == null) {
logger = Logger.create(...);
}
}
}
return logger;
}
// StableValue approach: concise and safe
private final StableValue<Logger> logger = StableValue.of();
Logger getLogger() {
return logger.orElseSet(() -> Logger.create(...));
}
1.3.2. trySet() vs setOrThrow()#
Design differences between the two setting methods:
boolean trySet(T contents); // Returns false if already set
void setOrThrow(T contents); // Throws exception if already set
Design Considerations:
trySet(): Suitable for “try to set, failure is acceptable” scenariossetOrThrow(): Suitable for “must set successfully” scenarios, providing clear error messages
1.3.3. Advanced Abstractions: Stable Supplier, Function, Collection#
1.3.3.1. Stable Supplier#
static <T> Supplier<T> supplier(Supplier<? extends T> underlying);
- Encapsulates initialization and access logic at the declaration point
- More concise client code:
logger.get()vsgetLogger() - Reduces boilerplate code (no need for separate getter methods)
Implementation:
record StableSupplier<T>(StableValueImpl<T> delegate,
Supplier<? extends T> original)
implements Supplier<T> {
@ForceInline
@Override
public T get() {
return delegate.orElseSet(original);
}
}
The underlying implementation still uses orElseSet() from StableValueImpl.
1.3.3.2. Stable List#
static <T> List<T> list(int size, IntFunction<? extends T> function);
- Fixed-size immutable list
- Each element independently lazy-initialized
- Supports random access (implements
RandomAccessinterface) - Suitable for object pool patterns
Usage Example:
// Create object pool
static final List<OrderController> POOL =
StableValue.list(POOL_SIZE, index -> new OrderController());
// Allocate by thread ID
OrderController getController() {
int index = (int)(Thread.currentThread().getId() % POOL_SIZE);
return POOL.get(index);
}
1.3.3.3. Stable Function#
static <T, R> Function<T, R> function(Set<T> keys, Function<? super T, ? extends R> function);
- Partial function: only valid for predefined key sets
- Suitable for caching computed results (e.g., lookup tables, conversion tables)
- Supports constant folding optimization
Usage Example:
// Compute logarithm lookup table
private static final Set<Integer> KEYS = Set.of(1, 2, 4, 8, 16, 32);
private static final Function<Integer, Integer> LOG2 =
StableValue.function(KEYS, i -> 31 - Integer.numberOfLeadingZeros(i));
public static int log2(int n) {
return LOG2.apply(n); // If input belongs to KEYS, it will be folded to a constant by JIT
}
2. Brief Analysis of Core Implementation Source Code#
The core implementation class of StableValue is StableValueImpl
2.1. Core Fields of StableValueImpl#
File Location: src/java.base/share/classes/jdk/internal/lang/stable/StableValueImpl.java
// Unsafe allows using StableValue early in the startup sequence
static final Unsafe UNSAFE = Unsafe.getUnsafe();
// Offset for direct field access
private static final long CONTENTS_OFFSET =
UNSAFE.objectFieldOffset(StableValueImpl.class, "contents");
// Sentinel object to represent null values
private static final Object NULL_SENTINEL = new Object();
// Core field marked with @Stable annotation
// | Value | Meaning |
// | -------------- | ------------ |
// | null | Unset |
// | NULL_SENTINEL | Set(null) |
// | other | Set(other) |
@Stable
private Object contents;
Reasons for using Unsafe: - Allows use early in JVM startup (doesn’t depend on reflection, MethodHandles) - Can precisely control memory semantics (acquire/release) - Avoids method call overhead
NULL_SENTINEL Design:
- In Java, null cannot distinguish between “unset” and “set to null”
- Uses a sentinel object to solve this problem
- Adds slight memory overhead but ensures semantic correctness
Role of the @Stable annotation used internally in JDK:
- Tells the JVM that the field’s value is stable after initialization
- Allows JIT to perform constant folding optimizations
2.2. Core Method Implementation Analysis#
2.2.1. Read Operation: wrappedContentsAcquire()#
File Location: src/java.base/share/classes/jdk/internal/lang/stable/StableValueImpl.java
@ForceInline
public Object wrappedContentsAcquire() {
return UNSAFE.getReferenceAcquire(this, CONTENTS_OFFSET);
}
- Acquire semantics: Ensures that before reading
contents, all previous memory operations are completed.Release/Acquiresemantics generally appear in pairs, and memory barriers are lighter than volatile:Release/Acquire: In JMM abstraction,ReleaseplacesLoadStoreandStoreStorebarriers before;AcquireplacesLoadLoadandLoadStorebarriers aftervolatile: In JMM abstraction,volatile writeplacesLoadStoreandStoreStorebarriers before, andStoreLoadafter;volatile readplacesLoadLoadandLoadStorebarriers afterStoreLoadbarriers generally have significant overhead, typically full barriers in CPUs and operating systems
- Uses
@ForceInlineto hint the JVM to inline this method, reducing call overhead
2.2.2. Setting Operations: trySet() and wrapAndSet()#
File Location: src/java.base/share/classes/jdk/internal/lang/stable/StableValueImpl.java
@ForceInline
@Override
public boolean trySet(T contents) {
// Fast path: if already set, return false directly
if (wrappedContentsAcquire() != null) {
return false;
}
// Prevent recursive initialization
preventReentry();
// Protected by mutex lock, coordinated with orElseSet
synchronized (this) {
return wrapAndSet(contents);
}
}
private void preventReentry() {
if (Thread.holdsLock(this)) {
throw new IllegalStateException("Recursive initialization...");
}
}
@ForceInline
private boolean wrapAndSet(T newValue) {
assert Thread.holdsLock(this);
// Under lock, plain semantics are sufficient
if (contents == null) {
// Use Release semantics to write, ensuring all previous operations are visible
UNSAFE.putReferenceRelease(this, CONTENTS_OFFSET, wrap(newValue));
return true;
}
return false;
}
Double-Checked Locking Pattern (DCL):
- First check:
wrappedContentsAcquire() != null(fast path) - Second check after locking:
contents == null(slow path) - Avoids unnecessary lock contention
Memory barrier ordering:
- Read: uses
getReferenceAcquire(acquire semantics) - Write: uses
putReferenceRelease(release semantics) - Forms happens-before relationship
Recursion prevention mechanism: Prevents recursive calls by the same thread causing repeated initialization.
2.2.3. Lazy Initialization: orElseSet()#
File Location: src/java.base/share/classes/jdk/internal/lang/stable/StableValueImpl.java
@ForceInline
@Override
public T orElseSet(Supplier<? extends T> supplier) {
Objects.requireNonNull(supplier);
// Fast path: if already set, return directly
final Object t = wrappedContentsAcquire();
return (t == null) ? orElseSetSlowPath(supplier) : unwrap(t);
}
@DontInline // Explicitly marked not to inline, keeping method boundaries clear
private T orElseSetSlowPath(Supplier<? extends T> supplier) {
preventReentry(); // Prevent recursion
synchronized (this) {
// Check again inside lock (double-check)
final Object t = contents; // Plain semantics sufficient (lock already held)
if (t == null) {
// Execute supplier, may take time
final T newValue = supplier.get();
// Set value and return
wrapAndSet(newValue);
return newValue;
}
// Other thread has set it, return directly
return unwrap(t);
}
}
Fast path optimization:
- In most cases, the value is already set, return quickly
- Uses
@ForceInlineto inline the main method - Avoids unnecessary synchronization overhead
Slow path separation:
- Uses
@DontInlineto mark the slow path - Maintains method boundaries for debugging and performance analysis
- Avoids code bloat from excessive inlining
2.2.4. Null Value Handling Mechanism#
File Location: src/java.base/share/classes/jdk/internal/lang/stable/StableValueImpl.java
// Wrap null value as sentinel object
@ForceInline
private static Object wrap(Object t) {
return (t == null) ? NULL_SENTINEL : t;
}
// Unwrap sentinel object to null
@SuppressWarnings("unchecked")
@ForceInline
private static <T> T unwrap(Object t) {
return t != NULL_SENTINEL ? (T) t : null;
}
Why is this mechanism needed?
In Java, object reference fields cannot distinguish between the following two cases:
- Field uninitialized (null)
- Field initialized to null value
StableValue needs to distinguish these two states, therefore:
- Uses
nullto represent “unset” - Uses
NULL_SENTINELto represent “set to null”
Memory overhead:
- Each
StableValueinstance: one object reference (8 bytes) NULL_SENTINEL: globally shared object (one instance per JVM)- Total overhead: almost negligible
2.2.5. Advanced Abstraction Implementations#
2.2.5.1. StableSupplier Implementation#
File Location: src/java.base/share/classes/jdk/internal/lang/stable/StableSupplier.java
public record StableSupplier<T>(StableValueImpl<T> delegate,
Supplier<? extends T> original)
implements Supplier<T> {
@ForceInline
@Override
public T get() {
return delegate.orElseSet(original);
}
// Identity-based equals and hashCode
@Override
public int hashCode() {
return System.identityHashCode(this);
}
@Override
public boolean equals(Object obj) {
return obj == this;
}
}
The underlying implementation still uses the orElseSet() method from StableValueImpl for lazy initialization.
2.2.5.2. StableFunction Implementation#
File Location: src/java.base/share/classes/jdk/internal/lang/stable/StableFunction.java
public record StableFunction<T, R>(
Map<? extends T, StableValueImpl<R>> values,
Function<? super T, ? extends R> original)
implements Function<T, R> {
@ForceInline
@Override
public R apply(T value) {
// Find corresponding StableValue
final StableValueImpl<R> stable = values.get(value);
if (stable == null) {
throw new IllegalArgumentException("Input not allowed: " + value);
}
// Lazy initialize and return
return stable.orElseSet(new Supplier<R>() {
@Override public R get() {
return original.apply(value);
}
});
}
}
Map structure:
- Each input value corresponds to a
StableValueImpl, still usingorElseSet()for lazy initialization - Independent lazy initialization
- Supports partial functions (only allows predefined inputs)
Exception handling:
- Disallowed inputs throw exceptions immediately
- Doesn’t create unnecessary
StableValueinstances - Provides clear error messages
3. JIT Optimization#
At the JDK level, StableValue uses the @Stable annotation; at the JIT level, it recognizes StableValue usage patterns and performs optimizations.
3.1. Role of the @Stable Annotation#
@Stable field optimization support:
- C1 Compiler: Supports basic constant folding, but optimization is limited
- C2 Compiler: Supports full constant folding and more aggressive optimizations (dead code elimination, loop unrolling, etc.)

3.2. Effect of JIT Optimization on StableValue Core Method orElseSet()#
Original code:
// Calling code
Logger logger = stableValue.orElseSet(() -> Logger.create(...));
// orElseSet method in StableValueImpl.java
@ForceInline
@Override
public T orElseSet(Supplier<? extends T> supplier) {
Objects.requireNonNull(supplier);
final Object t = wrappedContentsAcquire();
return (t == null) ? orElseSetSlowPath(supplier) : unwrap(t);
}
If the code enters JIT optimization, it must have been called many times, so the value must be initialized. First is C1 optimization, step one, method inlining:
Due to the @ForceInline annotation, C1 will inline all methods marked with @ForceInline:
Logger logger;
{
Objects.requireNonNull(supplier); // supplier is a lambda
final Object t = UNSAFE.getReferenceAcquire(this, CONTENTS_OFFSET);
logger = (t == null) ? orElseSetSlowPath(supplier) : (t != NULL_SENTINEL ? (T) t : null);
}
Where:
wrappedContentsAcquire()is@ForceInline, inlined as:UNSAFE.getReferenceAcquire(this, CONTENTS_OFFSET)unwrap(t)is@ForceInline, inlined as:(t != NULL_SENTINEL ? (T) t : null)
Next, C1 will perform constant folding optimization:
Logger logger;
{
Objects.requireNonNull(supplier);
final Object t = <constant value>; // Directly use constant, no memory read needed
logger = (t == null) ? orElseSetSlowPath(supplier) : (t != NULL_SENTINEL ? (T) t : null);
}
If the code runs enough times and enters C2, C2 will do the previous optimizations, then perform more aggressive optimizations, first dead code elimination:
// After C2 dead code elimination
Logger logger;
{
Objects.requireNonNull(supplier); // May be eliminated (if compiler can prove supplier is non-null)
final Object t = <constant value>; // Known != null
// Branch (t == null) proven to be false
// orElseSetSlowPath(supplier) call completely eliminated (dead code)
logger = t != NULL_SENTINEL ? (T) t : null; // Only this path remains
}
If C2 can prove t != NULL_SENTINEL (most cases), it can further optimize:
// C2 final optimization result (t != NULL_SENTINEL)
Logger logger = <constant value>; // Direct assignment, zero overhead
Or if t == NULL_SENTINEL:
// C2 final optimization result (t == NULL_SENTINEL)
Logger logger = null; // Directly return null



