JEP 539: Strict Field Initialization in the JVM (Preview)

OwnerDan Smith
TypeFeature
ScopeSE
StatusIntegrated
Release28
Discussionvalhalla dash dev at openjdk dot org
EffortM
DurationL
Relates toJEP 401: Value Objects (Preview)
Reviewed byAlex Buckley, Brian Goetz
Endorsed byBrian Goetz
Created2025/02/20 21:25
Updated2026/07/31 18:04
Issue8350458

Summary

Introduce strictly-initialized fields in the Java Virtual Machine. Such fields must be initialized before they are read, thus default values such as 0 or null are never observed. For strictly-initialized fields that are final, the same value is always observed. This is a preview VM feature, available for use by compilers that emit class files.

Goals

Non-Goals

Motivation

The Java Platform specifies that every variable is initialized before use, ensuring that a program can never read from uninitialized memory. If a field in a class — whether a static field or an instance field — is not initialized explicitly then it is initialized implicitly before it is used, by being set to a default value. This value is always some form of zero: the number 0, the boolean false, or a null reference.

Default values are a mixed blessing. They provide a straightforward safety net, ensuring that a program never observes uninitialized memory, but they can often be misinterpreted as legitimate data rather than as a signal that nothing has yet been written.

For example, a method may read a null value from a field and then pass that on to other methods and constructors, only to trigger a NullPointerException somewhere far from where the field was read. JDK 14 improved the messages in such exceptions to make it easier to pinpoint the source of the error in a specific line of code, but these messages cannot direct you back to the initialization bug that supplied the null in the first place.

The Java Platform also specifies that variables declared final cannot be mutated, ensuring that any two reads of a final variable produce the same value. For final fields, however, this rule does not apply while the class or instance is being initialized. A program may thus read different values at different times as the fields are set to their intended values.

Field initialization bugs in practice

The following example illustrates the problems of unexpected default values and inconsistent final fields. In these classes, the final field App.appID may be read by code in the Log class before it is assigned its proper value. When that happens, different program components end up working with conflicting field values.

class App {

    public static final long appID = Log.currentPID(); // [1], [4], [6]

    public static void main() {
        IO.println("App[" + appID + "] has started");
        // ...
        Log.log("Completed 'main'");
    }

}

class Log { // [2]

    private static final String prefix = "App[" + App.appID + "]: "; // [3]

    public static void log(String msg) {
        IO.println(prefix + msg);
    }

    public static long currentPID() {
        return ProcessHandle.current().pid(); // [5]
    }

}

When the class App is run from the command line, the output is something like:

App[96052] has started
App[0]: Completed 'main'

The discrepancy between ID numbers arises because the invocation of Log.currentPID() in the App class [1] triggers initialization of the Log class [2], and during that class's initialization, the default 0 value of the appID field is read [3] and embedded into the prefix string. After the Log class is initialized, the call to its currentPID method from the App class [4] proceeds, producing the current process's ID number [5], which is finally assigned to App.appID [6]. That assignment is, however, too late for the prefix field.

In complex systems, these sorts of bugs are difficult to recognize and diagnose. One subtlety is that the order of initialization matters: If the Log class is initialized first, the discrepancy is not observed. Another subtlety is that the circular dependency between the classes App and Log is easy to create by mistake and easy to overlook later; if the utility method currentPID were declared in some other class, the circularity would not exist and everything would behave as expected.

Most kinds of Java variables do not suffer from these problems. A local variable must be explicitly assigned before it is read, and a final local variable may only be assigned once. Fields are unique in their reliance upon default values.

A strict approach to field initialization

We propose an alternative approach to initializing fields, both non-final and final. Instead of every field being initialized to a default value when it is created, we alter the JVM to ensure that some fields, designated strictly-initialized, are explicitly initialized in bytecode before they are allowed to be read. Compilers such as javac are responsible for choosing which fields are designated strictly-initialized based on the language features used in source code. We call this strict field initialization because it imposes additional restrictions on the code that initializes fields.

Strict field initialization makes it impossible to have unexpected default values and inconsistent final fields. Every read from a strictly-initialized field observes a previously-written value and, if the field is final, every read observes the same value. These properties are what we already intuitively expect from fields; strict field initialization promotes these properties from mere intuitions to actual integrity guarantees, enforced by the JVM.

Strict field initialization improves integrity

Strict field initialization lays the foundation for new Java language features:

As shown above, the process of field initialization can be delicate. The JVM must not impose new initialization behavior upon existing programs since they could depend upon the existing behavior. New language features, by contrast, can define new rules and behaviors for field initialization and then adopt strict field initialization. As the language evolves and new features are adopted, program components will gradually be hardened against field initialization bugs.

Description

A strictly-initialized field does not have a default value. It cannot be read before it has been explicitly initialized and, if it is final, all reads produce the same value. Compilers mark fields that are subject to strict initialization with a new flag in the class file, ACC_STRICT_INIT (0x0800).

For strictly-initialized fields, the JVM enforces these invariants:

The invariants of strictly-initialized fields give the JVM new opportunities to optimize uses of those fields. For example, the HotSpot JVM's JIT compiler will treat strictly-initialized final fields as trusted. A trusted final field is known to never change, so once a value has been read from it, subsequent reads can reuse that same value. As a result, JIT-compiled code has fewer interactions with memory and may run faster.

Below, we review the class initialization process in the JVM and discuss new rules for strictly-initialized static fields in more depth. We then review the instance initialization process and discuss new rules for strictly-initialized instance fields.

This is a preview VM feature, disabled by default

The ACC_STRICT_INIT flag denoting a strictly-initialized field is recognized only in class files with a preview version number (72.65535), and only when preview features are enabled at run time.

To enable preview features at run time, use the --enable-preview command-line option:

$ java --enable-preview Main

Value classes, a new Java language feature, rely upon strict field initialization: Compilers mark all the fields of value classes as ACC_STRICT_INIT. To program with value classes, you must enable preview features at both compile time and run time in order to enable both value classes and strict field initialization.

Strict field initialization is a standalone feature in the JVM. It does not assume that value classes exist, and it can be used by compilers of non-Java languages. Regardless of the compiler, class files with fields marked as ACC_STRICT_INIT can be loaded only if preview features are enabled at run time.

Class initialization today

Whenever a class is loaded by the JVM, it must be initialized. In bytecode, a class or interface can declare a class initialization method, named <clinit>, for this purpose. The class initialization method is free to execute arbitrary code. Usually, class initialization includes setting all of the class's static fields to appropriate initial values; it may also involve interactions with global state.

In Java source code, a class's initialization method is not written directly; it is, rather, an aggregation of the class's static field initializers and static initializer blocks.

Each class in a hierarchy may have its own <clinit> method. Every superclass must be initialized before executing the <clinit> method of a subclass.

A class whose initialization has begun but not yet completed is considered larval. It is developing, but not yet fully formed.

The JVM tracks the initialization state of each class at run time. In today's JVM (see JVMS §5.5), a class's initialization state is one of:

The <clinit> method runs while the class is in the larval state. The class is not yet initialized at this point, but its fields and methods can be freely accessed by code running in the current thread. If the <clinit> method completes successfully, the class transitions to the initialized state. If an exception is thrown, the class transitions to the erroneous state and can never become initialized.

The constraints on class initialization are enforced dynamically, at run time. For example, each getstatic instruction checks the initialization state of the resolved field's class. If the class is not initialized, but is in the larval state in another thread, then the getstatic instruction blocks until initialization completes.

Strict initialization of static fields

To implement strict initialization of static fields, we enhance the larval class initialization state to track whether each static field of the class has been set, and whether each static field of the class has been read.

When executing a putstatic or getstatic instruction, if the resolved field is declared by a class in the larval state in the current thread, the state is updated to record that the field has been set (by putstatic) or read (by getstatic). This occurs even if the field is accessed from another method or class, and even if the field is accessed through a subclass.

A field declared with the ConstantValue attribute is always considered set.

With this information, the JVM can enforce the invariants of strictly-initialized static fields:

(In some complex cases, such as during exception handling, a static final field may be written multiple times during initialization. This is allowed, but only the ultimate value of the field will be readable.)

The above rules are enforced even if a static field is read or written reflectively during class initialization via, e.g., the java.lang.reflect.Field or java.lang.invoke.VarHandle APIs.

Instance initialization today

Whenever a class instance is created with the new bytecode, that instance must be initialized. In bytecode, a class can declare multiple instance initialization methods, named <init>, for this purpose. These methods are free to execute arbitrary code. Through a chain of <init> method invocations, every class in an inheritance hierarchy defines what constitutes an initialized class instance. Usually, instance initialization includes setting all of the object's instance fields to appropriate initial values; it may also involve interactions with the static fields of the class, or other global state.

In Java source code, instance initialization methods are mainly expressed with constructors, and delegation between constructors is expressed with super(...) and this(...) calls. Instance initialization methods may also include code from a class's instance field initializers and instance initializer blocks.

Each class in a hierarchy has at least one <init> method, and that method must, at some point before it completes, delegate to another <init> method of either the current class or its superclass. This recursion bottoms out at Object::<init>.

An instance whose initialization has begun but not yet completed is, like a class, considered larval. It is developing, but not yet fully formed.

Like classes, instances have an initialization state, although this is expressed only indirectly in the JVM Specification. Today, an object's initialization state is one of:

An <init> method begins execution in the early-larval state. Most operations, including method invocations, are not allowed on an object in the early-larval state, and the object may not be shared with other code. However, its fields may be assigned with putfield. Eventually, another <init> method is invoked and the initialization process continues recursively, eventually reaching Object::<init>. At that point, the instance transitions to the late-larval state and, one by one, the recursively invoked <init> methods complete their execution and return. In the late-larval state, use of the object, including its fields and methods, is unrestricted; the object may even be shared across threads. The object is considered initialized once the outermost <init> method returns successfully. Alternatively, any <init> call in the stack might fail with an exception; in that case, the object transitions to the erroneous state and can never become initialized.

The constraints on instance initialization are enforced statically, by the bytecode verifier. Verification determines a type state for each instruction, which is either restricted (for code operating on an instance in the early-larval state) or unrestricted (for code operating on an instance in the late-larval and initialized states, and for code in static methods).

For instructions with restricted type states, the verifier prevents most operations on the current object. It also ensures that an unrestricted type state can be reached only via a chain of recursively delegating <init> calls that eventually reaches Object::<init>. The return instruction, which makes a newly constructed object available to the caller of <init>, is only allowed in an unrestricted type state.

Strict initialization of instance fields

To implement strict initialization of instance fields, we enhance the early-larval instance initialization state to track whether each instance field of the class has been set.

In the verifier, this is expressed with a restricted type state that carries a list of all the current class's strictly-initialized instance fields that have not yet been set. A putfield on the current class instance in a restricted type state removes the named field from the list.

The enhanced type state supports the following rules to enforce the invariants of strictly-initialized instance fields:

It has never been permitted to use getfield on an instance in a restricted type state. Thus, there is no rule for getfield analogous to the getstatic rule for static fields, and no need to track whether final fields have been read.

Jumps between restricted and unrestricted type states are not allowed. Jumps between different restricted type states are allowed, as long as the jump is to a type state in which fewer fields are set.

These verification rules ensure that all strictly-initialized fields of an object are set while it is in an early-larval state, before any reads can occur, and that no strictly-initialized final fields are mutated once the object enters the late-larval state. When the verified code executes, there is no need for additional run-time checks to enforce the initialization invariants.

In a class file, the StackMapTable attribute expresses the expected incoming type state for a jump target. In the past, a restricted type state has been expressed simply by including the special type uninitializedThis in the list of local variables. But when a class has strictly-initialized fields, the type state may also need to indicate whether each field has been set. This is accomplished with a new kind of StackMapTable frame entry:

early_larval_frame {
    u1 frame_type = EARLY_LARVAL; /* 246 */
    u2 number_of_unset_fields;
    u2 unset_fields[number_of_unset_fields];
        // array of NameAndType constants
    base_stack_map_frame base_frame;
        // any other kind of stack frame
}

Alternatively, if a stack frame has any other frame_type but mentions uninitializedThis, the stack frame is implicitly restricted, with unset fields inferred as whatever fields were unset in the previous frame.

Strictly-initialized final fields cannot be mutated by deep reflection

Some applications and frameworks use deep reflection, as embodied in the setAccessible and set methods of the [java.lang.reflect.Field] API, to manipulate an object's private or final fields after instance initialization completes. In JDK 26, the mutation of final fields by deep reflection is permitted but causes a warning; in a future release, those who need this capability will have to enable it explicitly at startup. (See JEP 500 for more information.)

The mutation of strictly-initialized final fields by deep reflection is inconsistent with the invariants of strict field initialization: Different reads of the same final field could observe different values. The setAccessible method therefore categorizes these fields as non-modifiable, just as it does for static final fields and the final fields of record classes. Attempting to set a strictly-initialized final field always throws an IllegalAccessException. Using --enable-final-field-mutation=... will not enable mutation of these non-modifiable fields.

To set a strictly-initialized final instance field of a class, you must employ one of the class's constructors, which has the exclusive ability to assign to the field.

Strictly-initialized fields require custom deserialization

Object deserialization, as embodied in the ObjectInputStream API, skips the usual execution of an <init> method in the class being instantiated. Instead, the API does its own construction via reflective library code. Much like deep reflection, this capability bypasses the verification-based enforcement of constraints on strictly-initialized instance fields, and cannot be used for classes that declare these fields.

The ObjectOutputStream::writeObject and ObjectInputStream::readObject methods therefore throw an InvalidClassException if a class being serialized or deserialized declares a strictly-initialized instance field and the class is not a record class.

To avoid this exception, implement the writeReplace and readResolve methods. Doing so causes a replacement object to be serialized and deserialized in place of the object with strictly-initialized fields.

(We anticipate a future enhancement to serialization which allows you to designate construction code that ObjectInputStream::readObject can use to safely create new instances from the data in a serialization stream. This process will rely on regular constructor invocation, and so will be compatible with strictly-initialized instance fields.)

Supporting changes

Alternatives

Risks and Assumptions