JEP 401: Value Objects (Preview)

OwnerDan Smith
TypeFeature
ScopeSE
StatusSubmitted
Componentspecification
Discussionvalhalla dash dev at openjdk dot org
EffortXL
DurationXL
Relates toJEP 539: Strict Field Initialization in the JVM (Preview)
Reviewed byAlex Buckley, Brian Goetz
Created2020/08/13 19:31
Updated2026/07/09 12:21
Issue8251554

Summary

Enhance the Java Platform with value objects: class instances that have only final fields and lack object identity. This is a preview language and VM feature.

Goals

Non-Goals

Motivation

Java developers often need to represent simple immutable data: the date of an event, the color of a pixel, the shipping address of an order, and so on. Developers usually model these values with classes that contain just enough logic to construct, validate, and transform instances. The toString, equals, and hashCode methods in these classes are defined so that equivalent instances can be used interchangeably.

As an example, event dates can be represented with the JDK's LocalDate class:

jshell> LocalDate d1 = LocalDate.of(1996, 1, 23)
d1 ==> 1996-01-23

jshell> LocalDate d2 = d1.plusYears(30)
d2 ==> 2026-01-23

jshell> LocalDate d3 = d2.minusYears(30)
d3 ==> 1996-01-23

jshell> d1.equals(d3)
$4 ==> true

Developers will regard the "essence" of a LocalDate object as its year, month, and day values. But in the Java language, the essence of any object is its identity. Each time the of method in LocalDate invokes new LocalDate(...), an object with a unique identity is allocated, distinguishable from every other object in the system.

The easiest way to observe the identity of an object is with the == operator:

jshell> d1 == d3
$6 ==> false

Even though d1 and d3 represent the same year-month-day triple (d1.equals(d3) is true), they are two objects with distinct identities.

Immutable data does not need identity

For mutable objects, identity is important: it lets us distinguish two objects that have the same state now but will have different state in the future. For example, suppose a class Rectangle has a field position that is mutated when a user interacts with the rectangle. Two Rectangle objects might have the same position, but it would be a coincidence; when one of the rectangles move, the application will mutate the position of that object but not the other, relying on identity to pick the right one.

In other words, when objects are mutable, they cannot be interchangeable. But most immutable data values are interchangeable. There is no practical difference between two LocalDate objects representing 1996-01-23, because their state is fixed and unchanging. They represent the same value, both now and in the future. There is no need to distinguish the two objects via their identities.

In fact, object identity is actively confusing when objects are immutable and interchangeable. Most developers will recall the experience of unwittingly using == to compare objects, as in d1 == d3 above, and being mystified by a false result even though the objects' state and behavior seem identical.

The Integer class uses a cache to avoid creating Integer objects with unique identities. There is typically just a single Integer object representing the value 1. However, this cache, somewhat arbitrarily, does not extend to larger int values like 1996:

jshell> Integer i = 1, j = 1;
i ==> 1
j ==> 1

jshell> i == j
$3 ==> true

jshell> Integer x = 1996, y = 1996;
x ==> 1996
y ==> 1996

jshell> x == y
$6 ==> false

For classes like Integer, the fact that each object has unique identity is unwanted complexity that leads to surprising behavior and exposes incidental implementation choices. This extra complexity could be avoided if objects whose state and behavior make them interchangeable could be freed from the legacy requirement to have distinct identities.

Object identity is expensive at run time

The Java language's requirement that every object have identity, even if some objects do not want it, is a performance impediment. It means the JVM has to allocate memory for each newly created object, distinguishing it from every object already in the system, and reference the location in memory whenever the object is used or stored.

For example, suppose a program creates arrays of int values and LocalDate references:

jshell> int[] ints = { 1996, 2006, 1996, 1, 23 }
ints ==> int[5] { 1996, 2006, 1996, 1, 23 }

jshell> LocalDate[] dates = { d1, d1, d2, null, d3 }
dates ==> LocalDate[5] { 1996-01-23, 1996-01-23, 2026-01-23,
                         null, 1996-01-23 }

The int array can be allocated by the JVM as a simple block of memory:

+----------+
| int[5]   |
+----------+
| 1996     |
| 2006     |
| 1996     |
| 1        |
| 23       |
+----------+

In contrast, the LocalDate array must be represented as a sequence of pointers, each referencing a location in memory where an object has been allocated:

+--------------+
| LocalDate[5] |
+--------------+
| 87fa1a09     | -----------------------> +-----------+
| 87fa1a09     | -----------------------> | LocalDate |
| 87fb4ad2     | ------> +-----------+    +-----------+
| 00000000     |         | LocalDate |    | y=1996    |
| 87fb5366     | ---     +-----------+    | m=1       |
+--------------+   |     | y=2026    |    | d=23      |
                   v     | m=1       |    +-----------+
        +-----------+    | d=23      |
        | LocalDate |    +-----------+
        +-----------+
        | y=1996    |
        | m=1       |
        | d=23      |
        +-----------+

Even though the data modeled by the LocalDate array is not significantly more complex than the int array—a year-month-day triple is effectively 48 bits of primitive data—the memory footprint is far greater because of the pointers and allocated objects.

Worse, when a program iterates over the LocalDate array, each pointer may need to be dereferenced. CPUs use caches to enable fast access to chunks of memory; if the array exhibits poor memory locality (a distinct possibility if the LocalDate objects were allocated at different times or out of order), every dereference may require caching a different chunk of memory, frustrating performance.

In some application domains, developers program for speed by creating as few objects as possible, thus de-stressing the garbage collector and improving locality. For example, they might encode event dates with an int representing an epoch day. Unfortunately, this approach gives up the functionality of classes that makes Java code so maintainable: meaningful names, private state, data validation by constructors, convenience methods, etc. A developer operating on dates represented as int values might accidentally interpret the value relative to a start date in 1601 or 1980 rather than the intended 1970 start date.

Programming without identity

Trillions of Java objects are created every day, each one bearing a unique identity. We believe the time has come to let Java developers choose which objects in the program need identity, and which do not. A class like LocalDate that represents simple immutable data could opt out of identity, so that two LocalDate objects representing the date 1996-01-23 would be indistinguishable, just as two int values representing the number 4 are indistinguishable.

By opting out of identity, developers are opting in to a programming model that can enable the best of both worlds: the abstraction of classes with the simplicity and performance benefits of primitives.

In the future, this programming model will support new Java Platform APIs, such as classes that encode different kinds of integers and floating-point values, and new Java language features, such as user-defined conversions and mathematical operators for immutable data.

Description

Java NN introduces value objects to model simple immutable data. A value object is an instance of a value class, declared with the value modifier. Classes without the value modifier are called identity classes, and their instances are identity objects.

Java programs manipulate objects through references. A reference to an object is stored in a variable and lets us find the object's fields. Traditionally, a reference also encodes the unique identity of an object: each execution of new allocates a fresh object and returns a unique reference, which can then be stored in multiple variables (aliasing). And, traditionally, the == operator compares objects by comparing references, so distinct references to two objects are not == even if the referenced objects are interchangeable.

Value objects are different. A reference to a value object is stored in a variable and lets us find the object's fields, but it does not serve as the unique identity of the object. For a value class, executing new might not allocate a fresh object and might instead return a reference to an existing object, or even a "reference" that embodies the object directly. The == operator compares value objects by comparing their field values, so references to two objects are == if the objects have identical field values.

Developers can save memory and improve performance by using value objects for immutable data. Because programs cannot tell the difference between two value objects with identical field values (not even with ==), the Java Virtual Machine is able to change how a value object is laid out in memory without affecting the program; for example, its fields could be stored on the stack rather than the heap.

The following sections explore how value objects differ from identity objects and illustrate how to declare value classes. This is followed by an in-depth treatment of the special behaviors of value objects, considerations for value class declarations, and the JVM's handling of value classes and objects.

Enabling preview features

Value objects are a preview language feature, disabled by default.

To try the examples below in JDK NN you must enable preview features:

Some classes in the Java Platform API become value classes only if preview features are enabled; otherwise, they behave just as they did in JDK NN-1.

Programming with value objects

30 classes in java.* are declared as value classes. Examples include:

All instances of these classes are value objects. This includes the boxed primitives that are instances of Integer, Long, etc. The == operator compares value objects by their field values, so, e.g., Integer objects are == if they box the same primitive values:

% -> jshell --enable-preview
|  Welcome to JShell -- Version NN-internal
|  For an introduction type: /help intro

jshell> Integer x = 1996, y = 1996;
x ==> 1996
y ==> 1996

jshell> x == y
$3 ==> true

Similarly, two LocalDate objects are == if they have the same year, month, and day values:

jshell> LocalDate d1 = LocalDate.of(1996, 1, 23)
d1 ==> 1996-01-23

jshell> LocalDate d2 = d1.plusYears(30)
d2 ==> 2026-01-23

jshell> LocalDate d3 = d2.minusYears(30)
d3 ==> 1996-01-23

jshell> d1 == d3
$7 ==> true

The String class, due to some identity dependencies in its API and implementation, has not been made a value class. Instances of String are always identity objects. We can use the Objects.hasIdentity method, new in JDK NN, to observe whether an object is an identity object.

jshell> String s = "abcd"
s ==> "abcd"

jshell> Objects.hasIdentity(s)
$9 ==> true

jshell> Objects.hasIdentity(d1)
$10 ==> false

jshell> String t = "aabcd".substring(1)
t ==> "abcd"

jshell> s == t
$13 ==> false

In most respects, value objects work the way that objects have always worked in the language. They have fields and methods. They are handled by reference. Their types can be null.

However, a few identity-sensitive operations, such as synchronization, are not supported by value objects.

jshell> synchronized (d1) { d1.notify(); }
|  Error:
|  unexpected type
|    required: a type with identity
|    found:    java.time.LocalDate
|  synchronized (d1) { d1.notify(); }
|  ^--------------------------------^

jshell> Object o = d1
o ==> 1996-01-23

jshell> synchronized (o) { o.notify(); }
|  Exception java.lang.IdentityException: Cannot synchronize on
   an instance of value class java.time.LocalDate
|        at (#19:1)

The JVM has a lot of freedom to encode references to value objects at run time in ways that optimize memory footprint, locality, and garbage collection efficiency. For example, we saw the following array earlier, implemented with pointers to heap objects:

jshell> LocalDate[] dates = { d1, d1, d2, null, d3 }
dates ==> LocalDate[5] { 1996-01-23, 1996-01-23, 2026-01-23,
                         null, 1996-01-23 }

Now that LocalDate objects lack identity, the JVM could implement the array using "references" that encode the fields of each LocalDate directly. Each array element can be represented as a 64-bit word that indicates whether the reference is null, and if not, directly stores the year, month, and day field values of the value object:

+--------------+
| LocalDate[5] |
+--------------+
| 1|1996|01|23 |
| 1|1996|01|23 |
| 1|2026|01|23 |
| 0|0000|00|00 |
| 1|1996|01|23 |
+--------------+

The performance characteristics of this LocalDate array may be similar to those of an ordinary int array:

+----------+
| int[5]   |
+----------+
| 1996     |
| 2006     |
| 1996     |
| 1        |
| 23       |
+----------+

This optimization is just one example; some value classes, like LocalDateTime, are too large to take advantage of this particular technique. Still, the lack of identity enables the JVM to optimize references to value objects in many ways.

Declaring value classes

Developers can declare their own value classes by applying the value modifier to any class whose instances should be immutable and interchangeable:

When the value modifier is applied to a class, its fields are implicitly final. The class is also implicitly final, so cannot be extended. Because the class is final, its methods cannot be overridden.

There is no restriction on the types of fields in a value class. The fields may store references to other value objects, or to identity objects, e.g., strings.

Record classes are final and all their fields are final, so they are often good candidates to be value classes.

jshell> value record Point(int x, int y) {}
|  created record Point

jshell> Point p = new Point(17, 3)
p ==> Point[x=17, y=3]

jshell> Objects.hasIdentity(p)
$7 ==> false

jshell> new Point(17, 3) == p
$8 ==> true

Many classes have immutable and interchangeable instances but cannot be record classes because they are not transparent. (A record is transparent because the fields it uses to represent an instance are the same as the constructor arguments used to create the instance.) These non-transparent classes might use private fields to represent instances internally in a more efficient way than is exposed externally through public methods. For example, a class might represent a quantity of euros and cents with a single int field to save memory; it cannot be a value record, but it can still be a value class.

value class EURCurrency {
    private long cs;  // implicitly final
    private EURCurrency(long cs) { this.cs = cs; }

    public EURCurrency(long e, int c) { this(e, c, false); }
    public EURCurrency(long e, int c, boolean neg) {
        this(neg ? -e*100-c : e*100+c);
    }

    public long euros() { return Math.abs(cs)/100; }
    public int cents() { return (int) Math.abs(cs)%100; }
    public boolean negative() { return cs < 0; }
    public String toString() {
        var prefix = negative() ? "-€" : "€";
        return "%s%d,%d".formatted(prefix, euros(), cents());
    }
}

Comparing value objects

The purpose of the == operator in Java NN is to test whether two referenced objects are indistinguishable. If this is the case, the JVM can freely replace one reference with the other, and no code will be able to tell the difference.

For identity objects and null, the == operator works the same in JDK NN as it has since 1.0: it tests for references to the same object—at the same location in memory—or for matching null references.

When comparing two value objects, the == operator tests for references to instances of the same class with the same field values. That is, two value objects are indistinguishable if:

== and equals will often produce the same results for value objects. However, for some value classes, instances may be interchangeable (so equals) even if their field values are different (so not ==). Developers who want to test whether two value objects represent the same value should use the equals method, and class authors should define equals in a way that always returns true for interchangeable instances.

The LazySubstring value class, below, illustrates how == and equals may differ for some value objects. The class represents a substring of a string lazily, without allocating a new char[] in memory. The internal state of a LazySubstring instance is a source string and two coordinates, while the value represented by the instance is a character sequence, as produced by toString. Accordingly, two instances may model the same character sequence (so are equals) even though their internal state is different (so not ==).

value class LazySubstring {
    private String str;
    private int start, end;

    public LazySubstring(String s, int i, int j) {
        str = s; start = i; end = j;
    }

    public String toString() {
        return str.substring(start, end);
    }

    public boolean equals(Object o) {
        return o instanceof LazySubstring &&
            toString().equals(o.toString());
    }

    public int hashCode() {
        return Objects.hash(LazySubstring.class, toString());
    }
}

jshell> LazySubstring sub1 = new LazySubstring("ringing", 1, 4);
sub1 ==> ing

jshell> LazySubstring sub2 = new LazySubstring("ringing", 4, 7);
sub2 ==> ing

jshell> sub1.equals(sub2)
$3 ==> true

jshell> sub1 == sub2
$4 ==> false

The results of == and equals may also be different if two value objects' fields refer to two identity objects that are interchangeable according to equals, but that have different identities.

jshell> String r = "bringing".substring(1);
r ==> ringing

jshell> r == "ringing"
$6 ==> false

jshell> LazySubstring sub3 = new LazySubstring(r, 1, 4);
sub3 ==> ing

jshell> sub1.equals(sub3)
$8 ==> true

jshell> sub1 == sub3  // tests sub1.str == sub3.str
$9 ==> false

Another situation where == and equals may differ is where value objects have float or double fields. The primitive floating-point types support multiple encodings of NaN using different bit patterns. These NaN values are treated as interchangeable by most floating-point operations, but because each bit pattern is distinct, value objects that wrap different encodings of NaN are distinguishable by ==. The value class author must decide whether that distinction is meaningful for the equals method. For example, when a value record is used, the default behavior of equals treats all NaN encodings as interchangeable. (For more on the different kinds of equivalence between floating-point values, see the Double class specification.)

jshell> value record Length(float val) {}
|  created record Length

jshell> Length l1 = new Length(Float.intBitsToFloat(0x7ff80000))
l1 ==> Length[val=NaN]

jshell> Length l2 = new Length(Float.intBitsToFloat(0x7ff80001))
l2 ==> Length[val=NaN]

jshell> l1.equals(l2)
$13 ==> true

jshell> l1 == l2
$14 ==> false

jshell> Float.floatToRawIntBits(l1.val())
$15 ==> 2146959360

jshell> Float.floatToRawIntBits(l2.val())
$16 ==> 2146959361

Finally, note that == performs a "deep" comparison of nested references to other value objects. The number of comparisons is unbounded. In the following example, two deep nests of Box objects require a full traversal to determine whether the objects are statewise equivalent.

jshell> value record Box(Object val) {}
|  created record Box

jshell> var b1 = new Box(new Box(new Box(new Box(l1))))
b1 ==> Box[val=Box[val=Box[val=Box[val=Length[val=NaN]]]]]

jshell> var b2 = new Box(new Box(new Box(new Box(l2))))
b2 ==> Box[val=Box[val=Box[val=Box[val=Length[val=NaN]]]]]

jshell> b1.equals(b2)
$20 ==> true

jshell> b1 == b2
$21 ==> false

Constructors of value classes are constrained (discussed later) so that the recursive application of == to value objects will never cause an infinite loop. But deep comparisons may take a relatively long time, or even trigger a StackOverflowError.

Value classes and subclassing

Every value class belongs to a class hierarchy with java.lang.Object at its root, just like every identity class. There is no java.lang.Value superclass of all value classes.

Value classes can also implement interfaces. So variables declared with an interface type, or with the type Object, can store references to both value objects and identity objects.

jshell> Object o = LocalDate.of(1996, 1, 23)
o ==> 1996-01-23

jshell> Objects.hasIdentity(o)
$2 ==> false

jshell> Comparable<?> comp = 123
comp ==> 123

jshell> Objects.hasIdentity(comp)
$2 ==> false

jshell> comp = "abc"
comp ==> "abc"

jshell> Objects.hasIdentity(comp)
$4 ==> true

By default, a value class is implicitly final and cannot be extended. However, a value class may be declared abstract, allowing it to be extended by other classes and have its methods overridden. The fields of an abstract value class are implicitly final, as in a concrete value class. The methods of an abstract value class may be marked abstract, as in an abstract identity class.

Declaring an abstract value class is an indication that the class itself has no need for identity. Its subclasses may be value classes or identity classes. (The value modifier can be read here as "value-compatible".)

A value class can extend either java.lang.Object or an abstract value class, but not an identity class. (The class Object is unique in this respect: it is neither abstract nor a value class, and instances produced by new Object() have identity; yet it also permits extension by value classes.)

Many existing abstract classes, if they are designed to be publicly extensible, are good candidates to be abstract value classes. For example, the abstract class Number has no fields, nor any code that depends on identity-sensitive features, so it can be safely migrated to an abstract value class.

abstract value class Number implements Serializable {
    public abstract int intValue();
    public abstract long longValue();
    public byte byteValue() { return (byte) intValue(); }
    ...
}

Integer (a value class) and java.math.BigInteger (an identity class) both extend Number.

jshell> Number num = 123
num ==> 123

jshell> Objects.hasIdentity(num)
$6 ==> false

jshell> num = BigInteger.valueOf(123)
num ==> 123

jshell> Objects.hasIdentity(num)
$8 ==> true

An abstract value class can be sealed to limit who can extend the class.

sealed abstract value class UserID
        permits EmailID, PhoneID, UsernameID {
    ...
}

value class EmailID extends UserID {
    private String name, domain; ...
}
value class PhoneID extends UserID {
    private String digits; ...
}
value class UsernameID extends UserID {
    private String name; ...
}

Safe construction for value objects

Constructors initialize newly-created objects by setting the values of their fields. Because value objects do not have identity, they must only be observed by other code with their field values fully initialized, and this requires special care.

An object being constructed is "larval"—it has been created but is not yet fully-formed. Larval objects should always be handled carefully: if a larval object is shared with code outside the constructor, then domain-specific properties of the object may not yet hold, and the code may even observe the mutation of final fields.

Traditionally, a constructor begins the initialization process by invoking a superclass constructor, super(...). If this is not done explicitly, then the Java compiler inserts a super() call at the beginning of the constructor body. After the superclass returns, the subclass proceeds to set its declared instance fields and perform other initialization tasks. This pattern exposes a completely uninitialized subclass to any larval object leakage that occurs in a superclass constructor.

Flexible Constructor Bodies in Java 25 enable safer initialization whereby fields can be set and other code executed before the super(...) invocation. There is a two-phase initialization process: early construction before the super(...) invocation, and late construction afterwards.

During the early construction phase, larval object leakage is impossible: the constructor may set the fields of the larval object, but may not invoke instance methods or otherwise make use of this. Fields that are initialized in the early construction phase are therefore set before they can ever be read, even if a superclass leaks the larval object. Final fields, in particular, can never be observed to mutate.

In a value class, by default, all constructor code occurs in the early construction phase. The Java compiler inserts a super() call at the end of the constructor body, not the beginning. Attempts to invoke instance methods or otherwise use this will fail:

value class Name {
    String name;
    int length;

    Name(String n) {
        name = n;
        length = strLength();  // Error, invokes this.strLength()
    }

    private int strLength() {
        return name.length();
    }
}

Instance fields that are declared with initializer expressions are set at the start of the constructor, in the early construction phase. Instance initializer blocks (a rarely-used feature) are run in the late construction phase; they are not allowed to set the instance fields of value classes.

When a constructor has code that needs to work with this, an explicit super(...) or this(...) call can be used to mark the transition from the early to the late construction phase. But in a value class, all fields must be initialized before the call, and without referring to this:

value class Name {
    String name;
    int length;

    Name(String n) {
        name = n;
        length = strLength(name);  // OK, strLength is now static
        super();  // All fields must be set at this point
        System.out.println("Name: " + this);
    }

    private static int strLength(String n) {
        return n.length();
    }
}

Other construction enhancements

For the convenience of all classes, whether value classes or identity classes, this JEP enhances early access to a larval object's fields. In Java 27, a class's fields could only be set in the early construction phase, not read. In Java NN (with preview features enabled), fields can be read immediately after they are set, even in the early construction phase. As a result, both references to name in the constructor above are legal. It continues to be illegal in Java NN to refer to inherited fields, invoke instance methods, or share this with other code until the late construction phase.

In addition, for simplicity and improved performance, in Java NN (with preview features enabled), all record classes, whether value records or identity records, adopt the same safe construction rules as value classes. A record class constructor always runs in the early construction phase. The fields of a record must be set before they can ever be observed. Users of the record class can always expect component accesses to produce consistent results.

This change in the rules for identity record constructor declarations is not source compatible, but based on a survey of existing record class declarations, it is very rare for a record class to violate the new rules.

As an example, the following record class will fail to compile because its canonical constructor refers to this in the early construction phase:

record Node(String label, List<Node> edges) {
   public Node {
        nullCheck(label, this);  // Error with --enable-preview
        nullCheck(edges, this);  // Error with --enable-preview
    }

    static void nullCheck(Object arg, Object owner) {
        if (arg == null) {
            String msg = "null arg for " + owner.toString();
            throw new IllegalArgumentException(msg);
        }
    }
}

(Such examples are very rare because, as illustrated here, most attempts to use this in a record constructor are bugs—the fields of a Node are not yet set when this code calls toString().)

In cases where a record constructor needs legitimate access to this, an explicit super() can be inserted, but the record's fields must be set explicitly beforehand.

Inherited methods of java.lang.Object

Like any class, a value class inherits methods like equals, hashCode, and toString from java.lang.Object, unless the class author chooses to override them. These methods traditionally depend on identity, but when operating on a value object, they use the values of the object's fields instead. Specifically:

In a value record, as for all records, the default equals, hashCode, and toString behavior is to recursively apply the same operations to the record components.

A few other methods of Object interact with value objects:

Migrating to value classes

Value classes and records are useful tools for any class modeling simple immutable data.

As a general rule, if a class with immutable state does not need identity, it is probably appropriate to adopt the value modifier. This includes abstract classes, which often have no state at all and should not impose an unneccessary identity requirement on their subclasses.

(On the other hand, some abstract classes define an inherently mutable API, even if they declare no mutable state, and so should not be made value classes. And some concrete classes do not model data at all—for example, they may be designed to be instantiated only once.)

For final and abstract classes with only final fields, applying or removing the value keyword is a binary-compatible change.

However, migrating from an identity class to a value class carries some risks of source and behavioral incompatibility that class authors should consider:

Value classes and the Java Platform

The Java Platform API supports value classes and value objects in the following ways:

Run-time optimizations for value objects

At run time, the JVM can optimize value objects by encoding references to them in more compact forms than identity objects. Instead of allocating space in the heap for a value object, the JVM can flatten and scalarize the object.

When a reference is flattened or scalarized, it needs no independent object representation in the heap. This means it has no impact on garbage collection, and its data is always co-located in memory with the referencing object or call stack.

Reference flattening

As an example, the JVM could flatten an array of Integer references so that each array element holds a reference that encodes the underlying integer value directly, rather than pointing to the memory location of some Integer object. Each reference also flags whether the original Integer reference was null by prepending 0 (null) or 1 (non-null) to the integer value.

+--------------+
| Integer[5]   |
+--------------+
| 1|1996       |
| 1|2006       |
| 1|1996       |
| 0|0          |
| 0|0          |
+--------------+

Each int value takes up 32 bits, and each null flag requires at least one additional bit. Due to hardware constraints, the JVM will probably encode each flattened Integer reference as a 64-bit unit. An Integer array thus has a larger memory footprint than a plain int array, but a significantly smaller total footprint than an array of pointers to objects (the pointer itself is a 32- or 64-bit value, and each referenced object requires at least 64 bits just for its header). Even more significantly, all of the Integer data is stored directly inside the array, and can be processed without any extra memory loads.

As shown earlier, an array of LocalDate references can be flattened by prepending a null flag to the year-month-day triple of a LocalDate object (an int and two bytes). Like flattened Integer references, these flattened LocalDate references can fit in 64 bits.

+--------------+
| LocalDate[5] |
+--------------+
| 1|1996|01|23 |
| 1|1996|01|23 |
| 1|2026|01|23 |
| 0|0000|00|00 |
| 1|1996|01|23 |
+--------------+

Fields may also store flattened references. For example, a LocalDateTime object has two fields (a LocalDate and a LocalTime) and both can store a flattened reference.

+----------------------+
| LocalDateTime        |
+----------------------+
| date=1|2026|01|23    |
| time=1|09|00|00|0000 |
+----------------------+

Reference flattening must maintain the integrity of data. A flattened reference must always be read and written atomically, or it could become corrupted. On common platforms, this limits the size of mutable fields storing flattened references to no more than 64 bits.

For example, attempting to flatten a reference to a LocalDateTime object would embed fields from the underlying LocalDate and LocalTime, plus a null flag for each, plus a null flag for the LocalDateTime itself. The flattened reference would likely be too big to read and write atomically, so it could not be stored in a mutable field of type LocalDateTime, such as the lastClicked time of an identity class Button:

+--------------------------------------------+
| Button                                     |
+--------------------------------------------+
| lastClicked=1|1|2026|01|23|1|09|00|00|0000 |  // Not possible
| ...                                        |
+--------------------------------------------+

Instead, the JVM chooses (silently, at its discretion) a reference layout that is compatible with a mutable lastClicked field. Perhaps the field stores a pointer to a LocalDateTime object, whose own fields may store flattened references as shown earlier:

+----------------------+
| Button               |
+----------------------+
| lastClicked=87fa50a0 |------> +----------------------+
| ...                  |        | LocalDateTime        |
+----------------------+        +----------------------+
                                | date=1|2026|01|23    |
                                | time=1|09|00|00|0000 |
                                +----------------------+

On the other hand, no such atomicity limit would apply to the timestamp field of a value class Event, since value class fields can never be observed to mutate:

+------------------------------------------+
| Event                                    |
+------------------------------------------+
| timestamp=1|1|2026|01|23|1|09|00|00|0000 |  // Ok in a value class
| ...                                      |
+------------------------------------------+

In the future, further enhancements may enable more flattening of references to 64-bit and larger value objects. For example, additional language features may allow certain value classes to opt out of atomicity constraints; or perhaps 128-bit atomic mutable fields will eventually be viable on some platforms.

Reference scalarization

When the JVM sees a flattened reference in the field of an object in the heap, it needs to re-encode the reference in a form that it can readily work with. For code compiled by the JVM's just-in-time (JIT) compiler, this encoding can be a scalarized reference.

For example, consider the following code which reads a LocalDate from an array and invokes plusYears. A simplified version of plusYears is shown for reference.

LocalDate d = dates[0];
dates[0] = d.plusYears(30);
...
public LocalDate plusYears(long yearsToAdd) {
    int newYear = YEAR.checkValidIntValue(this.year + yearsToAdd);
    return new LocalDate(newYear, this.month, this.day);
}

In pseudo-code, the result of JIT compilation might look like the following, using the notation { ... } to indicate that multiple values are returned from a JIT-compiled method. (This is purely notational; there is no wrapper at run time.)

{ d_null, d_year, d_month, d_day } = dates[0];
dates[0] = plusYears(d_null, d_year, d_month, d_day, 30);

static { boolean, int, byte, byte }
    plusYears(boolean this_null, int this_year,
              byte this_month, byte this_day,
              long yearsToAdd) {
    if (this_null) throw new NullPointerException();
    int newYear = YEAR.checkValidIntValue(this_year + yearsToAdd);
    return { false, newYear, this_month, this_day };
}

Thanks to the JVM's optimizations, this code never touches a pointer to a heap-allocated LocalDate:

Unlike reference flattening, reference scalarization is not constrained by the size of the data. Local variables that are pushed and popped on the stack are not at risk of data races. Therefore, it is possible to routinely operate on a scalarized encoding of a LocalDateTime reference: three values and a null flag for the underlying LocalDate, four values and a null flag for the underlying LocalTime, and a null flag for the LocalDateTime itself.

JVMs have used similar techniques to scalarize references to identity objects in methods when the JVM is able to prove that an object's identity is never used. Scalarization of references to value objects is more predictable and far-reaching, even across method boundaries.

When flattening and scalarization can occur

Reference flattening and scalarization are optimizations, not language features. Programmers cannot directly control them. Like all optimizations, they occur at the discretion of the JVM. However, there are things programmers can do to make it more likely that the JVM can apply these optimizations.

First, flattening and scalarization rely on the JVM's knowledge that a variable only stores a specific value class: the date of a LocalDateTime is always a LocalDate reference. Flattening and scalarization cannot typically be applied to a variable declared with a supertype of a value class, such as Object.

For example, the following two arrays store the same Integer values when they are created, but because the second needs to be able to store arbitrary Object references in the future, it has to encode its elements as pointers to regular objects on the heap.

Integer[] ints = { 1996,2006,1996,null,null };  // flattenable
Object[] objs = { 1996,2006,1996,null,null };  // not flattenable

Future value objects written to the objs array will need to be converted to a regular heap object encoding.

Integer i = -1;
ints[3] = i;  // write a flattened reference
objs[3] = i;  // write a heap pointer

A field with a generic type T usually has erased type Object, and so will behave at runtime just like an Object-typed field.

record Box<T>(T field) {}  // field is not flattenable
var b = new Box<Integer>(i);  // field stores a heap pointer

These conversions between encodings do not have any semantic impact—the Integer objects referenced by objs and field are still value objects, and do not have identity. The JVM is simply encoding the same value object in different ways.

The same principles apply to method parameters: a parameter with type LocalDate is reliably scalarizable, while a parameter with type Object or T is not. (However, if the method call can be inlined, the JIT may be able to skip the assignment and heap allocation completely.)

A second factor that influences whether the JVM applies flattening and scalarization is the contents of a class file that uses value classes. When a class is compiled, the names of value classes mentioned by its field and method signatures get recorded in a new LoadableDescriptors class file attribute. This attribute authorizes the JVM to load the named value classes early enough to set up flattened fields and scalarized method parameters.

If a value class V is not listed by the LoadableDescriptors attribute in, e.g., C.class, then when C is loaded, the JVM may not know that V is a value class. If not, a field of type V may be encoded like any other field, storing pointers to objects in memory instead of flattened references. A method with a parameter of type V may not be JIT-compiled to accept scalarized calls, forcing callers to allocate objects on the heap and pass pointers to them.

In practice, this means that if a class V was migrated to become a value class, then for optimal performance, other classes that were compiled against an older version of V should be recompiled.

Future Work

Enhanced Primitive Boxing (Preview) will enhance the language's use of primitive types, taking advantage of the lighter-weight characteristics of boxing to value objects.

JVM class and method specialization (JEP 218, with revisions) will allow generic classes and methods to specialize field, array, and local variable layouts when parameterized by value class types.

Alternatives

As discussed, JVMs have long performed escape analysis to identify objects that never rely on identity throughout their lifespan and can be scalarized. These optimizations are somewhat unpredictable, and do not help with objects that escape the scope of the optimization, including storage in fields and arrays.

Programmers can achieve the performance gains described in this JEP without any new language features by modeling their data with primitives. But as noted in the "Motivation" section, this style of coding requires giving up valuable abstractions.

The C language and its relatives support flattened storage for structs and similar class-like abstractions. For example, the C# language has value types. Unlike value objects, instances of these abstractions have identity, meaning they support operations such as field mutation. As a result, the semantics of copying on assignment, invocation, etc., must be carefully specified, leading to a more complex user model and less flexibility for runtime implementations. We prefer an approach that leaves these low-level details to the discretion of JVM implementations.

Risks and Assumptions

The feature makes significant changes to the Java object model. Developers may be surprised by, or encounter bugs due to, changes in the behavior of operations such as == and synchronized. We expect such disruptions to be rare and tractable.

Some changes could potentially affect the performance of identity objects. The if_acmpeq test (==), for example, typically only costs one instruction cycle, but will now need an additional check to detect value objects. But the identity class case can be optimized as a fast path, and we believe we have minimized any performance regressions.

There is a security risk that == and identityHashCode can indirectly expose private field values. Further, two large trees of value objects can take unbounded time to compute ==. Developers need to understand these risks.

Dependencies

Strict Field Initialization in the JVM (Preview) provides the JVM mechanism necessary to require, through verification, that value class instance fields are initialized during the early construction phase.