Uploaded image for project: 'JDK'
  1. JDK
  2. JDK-8312611

Stable Values (Preview)

XMLWordPrintable

    • Icon: JEP JEP
    • Resolution: Unresolved
    • Icon: P4 P4
    • None
    • core-libs
    • None
    • Per Minborg & Maurizio Cimadamore
    • Feature
    • Open
    • SE
    • S
    • S

      Summary

      Introduce an API for stable values, which are objects that represent immutable data. Stable values are treated as constants by the JVM, enabling the same performance optimizations that are possible by marking a field final. Yet, stable values offer greater flexibility as to the timing of initialization compared to final fields. This is a preview API.

      Goals

      • Improve the startup of Java applications by breaking up monolithic initialization of the application state.
      • Decouple the creation of a stable value from its initialization, without significant performance penalties.
      • Guarantee that stable values are initialized at most once, even in a multi-threaded program.
      • Allow user-defined code to safely enjoy constant-folding optimizations previously only available to JDK internals.

      Non-goals

      • It is not a goal to provide Java language support for declaring stable values.
      • It is not a goal to alter the semantics of final fields.

      Motivation

      Most Java developers have heard the advice "prefer immutability" or "minimize mutability" (Effective Java, Third Edition, Item 17). Immutability confers many advantages, as immutable objects can be only in one state, and can therefore be freely shared across multiple threads.

      Java's main tool for managing immutability is final fields (and more recently, record classes). Unfortunately, final fields come with restrictions; final fields must be set eagerly either by the end of the constructor (for instance fields) or during class initialization (for static fields). Moreover, the order in which final fields are initialized is determined by the textual order in which the fields are declared. Such limitations restrict the applicability of final in many real-world applications.

      Immutability in practice

      As an effective and yet nearly ubiquitous example, consider a simple application component that needs to record relevant events through a logger object:

      class OrderController {
          private final Logger logger = Logger.create(OrderController.class);
      
          void submitOrder(User user, List<Product> products) {
              logger.info("order started");
              ...
              logger.info("order submitted");
          }
      }

      As logger is a final field of the OrderController class, this field must be initialized eagerly, when a new instance of OrderController is created. This means that creating a new OrderController is rather slow -- after all, obtaining a logger often entails expensive operations, such as reading and parsing configuration data, or preparing the storage where logging events will be recorded.

      Furthermore, if an application is composed of not just one component with a logger but several components, then the application as a whole will start slowly because each and every component will eagerly initialize its logger:

      class Application {
          static final OrderController   ORDERS   = new OrderController();
          static final ProductRepository PRODUCTS = new ProductRepository();
          static final UserService       USERS    = new UserService();
      }

      All this initialization is not only detrimental to the application startup; it might also be redundant. After all, the application might never need to log an event, so why pay the cost for this expensive initialization upfront?

      Embracing mutability for more flexible initialization

      For these reasons, an application will typically want to move the initialization of complex objects as much forward in time as possible, so that objects are only created when needed. One way to achieve that is to abandon final and rely on mutable fields to express "at-most-once" mutation:

      class OrderController {
          private Logger logger = null;
      
          Logger getLogger() {
              if (logger == null) {
                  logger = Logger.create(OrderController.class);
              }
              return logger;
          }
      
          void submitOrder(User user, List<Product> products) {
              getLogger().info("order started");
              ...
              getLogger().info("order submitted");
          }
      }

      As logger is no longer a final field, we can move its initialization into the OrderController.getLogger method. This method checks whether a logger object already exists. If not, a new logger object is created and stored in the logger field. While this approach improves application startup, it comes with some drawbacks:

      • The application has a subtle new invariant: all accesses to the logger field in OrderController must be mediated via the getLogger method. Failure to respect this invariant might expose a not-yet-initialized field, which will result in a NullPointerException.
      • Relying on mutable fields poses correctness issues if the application is multi-threaded. For example, concurrent calls to submitOrder can result in multiple logger objects being created.
      • One might expect the JVM to optimize access to the logger field, e.g., by constant-folding access to an already-initialized logger field, or by eliding the logger == null check in getLogger. Unfortunately, since the field is no longer final, the JVM cannot trust its contents to never change after its initial update. As such, flexible initialization implemented with mutable fields is not efficient.

      Towards deferred immutability

      In a nutshell, the ways in which the Java language allows developers to control field updates are either too constrained or too unconstrained. On the one hand, final fields are too constrained, requiring initialization to occur at a precise moment in the lifetime of an object or a class; this often comes at the expense of application startup. On the other hand, achieving initialization flexibility through the use of mutable non-final fields limits our ability to reason about the correctness of our application. The tension between immutability and flexibility of initialization leads developers to adopt imperfect workarounds that do not address the underlying problem and result in code that is even more brittle and difficult to maintain (some examples of this are shown in the Alternatives section).

      What developers are missing is a way to promise that a field will be initialized by the time it is used, with a value that is computed at most once and furthermore safely in a multi-threaded application. In other words, a way to defer immutability. This would give the Java runtime maximum opportunity to stage and optimize its computation, thus avoiding the penalties that plague the workarounds.

      First-class support for deferred immutability would fill an important gap between mutable and immutable fields.

      Description

      A stable value is an object, of type StableValue, that represents immutable data and that is initialized at a time of the developer's choice. Once the data has been initialized, the stable value acts as a constant for the rest of the program's execution. In effect, a stable value is a way of achieving deferred immutability.

      The order controller class can now be rewritten to use a stable value for its logger:

      class OrderController {
          /* Old:    
          private Logger logger = null; */
      
          /* New */
          private final StableValue<Logger> logger = StableValue.of();
      
          Logger getLogger() {
             return logger.computeIfUnset(() -> Logger.create(OrderController.class));
          }
      
          void submitOrder(User user, List<Product> products) {
              getLogger().info("order started");
              ...
              getLogger().info("order submitted");
          }
      }

      The logger field holds a stable value, created with the static factory method StableValue.of. Initially, the stable value is unset, meaning it has no underlying data. The stable value itself can be used, e.g., by passing the reference to logger to another method, but there is no way to use the underlying data.

      The getLogger method calls logger.computeIfUnset on the stable value to retrieve its underlying data. If the stable value is unset, then computeIfUnset initializes the underlying data, causing the stable value to become set; the underlying data is then returned to the client. In other words, computeIfUnset guarantees that a stable value's underlying data is initialized before it is used.

      Even though the stable value, once set, is immutable, we are not forced to initialize its underlying data in a constructor (or, for a static stable value, in the class initializer). Rather, we can initialize it on demand. Furthermore, computeIfUnset guarantees that the lambda expression provided is evaluated only once, even when logger.computeIfUnset is invoked concurrently. This property is crucial as evaluation of the lambda expression may have side effects, e.g., the call above to Logger.create may result in storage resources being prepared.

      This is a preview API, disabled by default

      To use the Stable Value API, you must enable preview features as follows:

      • Compile the program with javac --release 24 --enable-preview Main.java and run it with java --enable-preview Main; or,
      • When using the source code launcher, run the program with java --source 24 --enable-preview Main.java; or,
      • When using jshell, start it with jshell --enable-preview.

      Flexible initialization with stable values

      Stable values give us the same guaranteed initialization as immutable final fields while retaining the flexibility of mutable non-final fields:

      .storage-kinds { table, th, td { border: 1px solid black; border-collapse: collapse; text-align: center; } tr:nth-child(3) { background-color: #f2f2f2; } th { background-color: #f2f2f2; } }

      Storage kind #Updates Update location Constant folding Concurrent updates Mutable (non-final) [0, ∞) Anywhere No Yes Stable (StableValue) [0, 1] Anywhere Yes, after update Yes, by winner Immutable (final) 1 Constructor or static initializer Yes No

      The flexibility of stable values lets us re-imagine the initialization of entire applications. In particular, we can compose stable values from other stable values. Just as we used a stable value in the OrderController component, we can also use a stable value to store the OrderController component itself:

      class Application {   
          /* Old
          static final OrderController   ORDERS   = new OrderController(); 
          static final ProductRepository PRODUCTS = new ProductRepository();
          static final UserService       USERS    = new UserService();  */
      
          /* New */
          static final StableValue<OrderController>   ORDERS   = StableValue.of();
          static final StableValue<ProductRepository> PRODUCTS = StableValue.of();   
          static final StableValue<UserService>       USERS    = StableValue.of();
      
          public static OrderController   orders()   { return ORDERS.computeIfUnset(OrderController::new); }
          public static ProductRepository products() { return PRODUCTS.computeIfUnset(ProductRepository::new); }
          public static UserService       users()    { return USERS.computeIfUnset(UserService::new); }
      }

      Application startup is improved because its components, such as an OrderController, no longer need to be initialized up front. Rather, we initialize each component on demand, via computeIfUnset, and each component initializes its sub-components on demand, e.g., the logger of OrderController shown earlier.

      Furthermore, there is mechanical sympathy between stable values and the Java runtime: under the hood, the underlying data of a stable value is modeled as a non-final field annotated with the special @Stable annotation. This annotation, a common feature of low-level JDK code, guarantees that the field, even if non-final, can be trusted not to change after its initial (and only) update. This allows the JVM to treat the underlying data of a stable value as a constant, provided that the field which refers to the stable value is final.

      This means the JVM can perform constant-folding optimizations for code that accesses immutable data through multiple levels of stable values, e.g., Application.orders().getLogger().

      Consequently, developers no longer have to choose between flexible initialization and peak performance.

      Easier access to underlying data

      Our examples have shown the underlying data of a stable value being initialized in an arbitrary location, e.g., calling logger.computeIfUnset in the getLogger method, far from the declaration of the logger field. This allows the underlying data to be initialized using any application state available to the getLogger method, but unfortunately, it means that all access to the stable value logger has to go through getLogger. It would be more convenient if the logger field's declaration could specify how the underlying data of a stable value is to be initialized, but without actually creating a stable value or initializing its data. This can be done by turning logger from a stable value into a stable supplier:

      class OrderController {
          /* Old:    
          private final StableValue<Logger> logger = StableValue.of(); */
      
          /* New: */  
          private final Supplier<Logger>    logger = 
                  StableValue.ofSupplier( () -> Logger.create(OrderController.class) );
      
          void submitOrder(User user, List<Product> products) {
              logger.get().info("order started");
              ...
              logger.get().info("order submitted");
          }
      }

      In the above, logger is no longer a stable value -- it is a stable supplier, meaning a supplier of the underlying data for a stable value. When a stable supplier is first created -- this is done with StableValue.ofSupplier -- its stable value's underlying data is not yet initialized.

      To access the underlying data, clients call logger.get rather than logger.computeIfUnset. Upon the first invocation of logger.get, the lambda expression provided to ofSupplier is evaluated, and its result is used to initialize the underlying data of the stable value, which is returned to the client. Subsequent invocations of logger.get return the underlying data immediately.

      The use of a stable supplier, rather than a stable value, improves maintainability. The declaration and initialization of logger are now adjacent, resulting in more readable code, while the OrderController class no longer has to document the invariant that every logger access must go through the getLogger method, which now can be removed.

      Furthermore, the JVM can perform constant-folding optimizations for code that accesses the underlying data of stable values through stable suppliers.

      Aggregating stable values

      So far, we have used a separate stable value for each piece of deferred immutable data we needed to model (e.g. a component's logger object). While this is useful, many applications work with collections whose elements are themselves modeled as deferred immutable data, sharing similar initialization logic.

      Consider the case where an application might want to create not a single OrderController but a pool of OrderController objects. Different application requests can then be served by different OrderController objects, effectively creating an efficient load-sharing scheme across all the OrderController objects in the pool. Furthermore, objects in the pool should not be initialized eagerly, but only when a new object is needed by the application. This can be achieved using a stable list:

      class Application {   
          /* Old:    
          static final OrderController       ORDERS = new OrderController(); */
      
          /* New */  
          static final List<OrderController> ORDERS = 
                  StableValue.ofList(POOL_SIZE, OrderController::new);
      
          public static OrderController orders() {
              long index = Thread.currentThread().threadId() % POOL_SIZE;
              return ORDERS.get((int)index);
          }
      }

      In the above, ORDERS is no longer a stable value -- it is a stable list, meaning a list where each element is the underlying data for some stable value. When a stable list is first created -- via the StableValue.ofList factory -- the stable list has a fixed size (e.g. POOL_SIZE), and the underlying data of the stable values associated with the list elements is not yet initialized.

      To access the underlying data, clients call ORDERS.get rather than ORDERS.computeIfUnset. Upon the first invocation of ORDERS.get with a particular index, a new OrderController object is created (using a lambda expression or, as above, a method reference) and then used to initialize the underlying data of the element's stable value, which is returned to the client. Subsequent invocations of ORDERS.get with the same index return the element's underlying data immediately. Elements in a stable list are initialized independently, and only before they are needed. For example, if the application runs in a single thread, only one OrderController will ever be initialized and added to ORDERS.

      Stable lists retain many of the benefits of stable suppliers, as the lambda expression used to initialize the stable list elements is provided together with the definition of its field (e.g. ORDERS). Moreover, the JVM can perform constant-folding optimizations for code that accesses the underlying data of stable values through stable lists.

      Alternatives

      In this section, we discuss common ways in which Java programs can express deferred immutability. Unfortunately, all the workarounds proposed here come with disadvantages which range from limited applicability, to increased startup cost, to loss of constant-folding optimizations.

      Class-holder idiom

      A common workaround is the so-called class-holder idiom. The class holder idiom ensures deferred immutability with at-most-once semantics, by leveraging the laziness in the JVM's class initialization process:

      class OrderController {
      
          public staic Logger getLogger() {
      
              class Holder {
                private static final Logger LOGGER = Logger.create(...);
              }
      
              return Holder.LOGGER;
          }
      }

      While the class holder idiom allows for constant-folding optimizations, it is only applicable to static fields. Moreover, if several variables are to be handled, there needs to be a separate holder class for each variable, which makes applications harder to read, slower to start up, and requires more memory for the class definitions.

      Double-checked locking

      Another alternative is to resort to the double-checked locking idiom. The basic idea behind double-checked locking is to provide a fast path for accessing a variable's value that has already been initialized; and then provide a slower path in the (assumed rare) case where the variable's value appears unset:

      class OrderController {
      
          private volatile Logger logger;
      
          public Logger getLogger() {
              Logger v = logger;
              if (v == null) {
                  synchronized (this) {
                      v = logger;
                      if (v == null) {
                          logger = v = Logger.create(...);
                      }
                  }
              }
              return v;
          }
      }

      As logger is a mutable field, no constant-folding optimization can be applied here. More importantly, for the double-checked idiom to work, the logger field must be marked as volatile. This guarantees that the field's value is read and updated consistently across multiple threads. Unfortunately, volatile is not enough to guarantee correctness when dealing with arrays of deferred immutable values as shown in the next section.

      Double-checked locking on arrays

      Implementing a double-checked locking construct capable of supporting arrays of deferred immutable values is much more difficult, as there is no way to declare an array whose elements must be accessed with volatile semantics. Instead, a client needs to handle volatile access to array elements explicitly, using a VarHandle object:

      class OrderController {
      
          private static final VarHandle LOGGERS_HANDLE =
                 MethodHandles.arrayElementVarHandle(Logger[].class);
      
          private final Object[] mutexes;
          private final Logger[] loggers;
      
          public OrderController(int size) {
      this.mutexes = Stream.generate(Object::new).limit(size).toArray();
      this.loggers = new Logger[size]; } public Logger getLogger(int index) { // Volatile semantics is needed here to guarantee we only // see fully initialized element objects Logger v = (Logger) LOGGERS_HANDLE.getVolatile(loggers, index); if (v == null) { // Use distinct mutex objects for each index synchronized (mutexes[index]) { // Plain read semantics suffice here as updates to an element // always takes place under the same mutex object as for this read v = loggers[index]; if (v == null) { // Volatile semantics needed here to establish a // happens-before relation with future volatile reads LOGGERS_HANDLE.setVolatile(loggers, index, v = Logger.create(... index ...)); } } } return v; } }

      The code above is even more convoluted and error-prone: we now need a separate synchronization object for each array element, and remember to specify the correct semantics (getVolatile, setVolatile) upon each access. Moreover, access to array elements is not efficient, as constant-folding optimizations cannot be applied here.

      Concurrent map

      Deferred immutability can also be achieved with thread-safe maps like ConcurrentHashMap, uppdated with the Map.computeIfAbsent method:

      class OrderController {
      
              private final Map<Class<?>, Logger> logger = new ConcurrentHashMap<>();;
      
              public Logger getLogger() {
                  return logger.computeIfAbsent(OrderController.class, Logger::create);
              }
      }

      As the JVM cannot trust the content of a map entry to never be updated again after it is first added to the map, no constant-folding optimization can be applied here. Moreover, Map.computeIfAbsent is null-hostile: if the computing function returns null no new entry will be added to the map, which makes this solution impractical in some cases.

      Risks and assumptions

      The constant-folding optimizations provided by stable values hinges on the JVM's ability to trust that instance final fields can be updated only once. Currently, this only happens in a handful of cases -- e.g. when the final field models a record component. Broadening the set of conditions under which an instance final field is trusted to be updated only once might be the work of a future JEP.

            mr Mark Reinhold
            pminborg Per-Ake Minborg
            Brian Goetz
            Votes:
            1 Vote for this issue
            Watchers:
            8 Start watching this issue

              Created:
              Updated: