JEP 401: Value Objects (Preview)

42 min read Original article ↗

Summary

Introduce value objects, which are immutable and lack object identity. Value objects are distinguished solely by the values of their fields, and can be represented by Java Virtual Machines in ways that improve performance. This is a preview language and VM feature.

Goals

  • Enable developers to opt in to a programming model for immutable data in which the == operator, and all other operations, distinguish objects by the values of their fields rather than their identities.

  • Support the compatible migration of existing classes that represent immutable data to this model. Migrate suitable existing classes in the Java Platform API, such as Integer and LocalDate, to have value object instances.

  • Do not ask developers to learn new semantics for memory management or variable storage. The Java language should continue to operate on just two kinds of data: primitives and object references.

  • Maximize the freedom of JVM implementors to represent immutable data in ways that improve memory footprint, locality, and garbage collection efficiency.

Non-Goals

  • It is not a goal to automatically treat instances of existing classes as value objects. Value objects do not uniformly work the same way as other objects, so class authors must explicitly choose to have value object instances.

  • It is not a goal to revise the == operator so that it can be used in place of the equals method. We redefine == only as much as necessary to cope with a new kind of identity-free object. The usual advice to compare objects in most contexts using the equals method still applies.

  • It is not a goal to introduce a struct feature, as found in the C and C# languages.

  • It is not a goal to change the treatment of primitive types. Primitives behave like value objects in many ways, but are a distinct concept.

  • It is not a goal to guarantee any particular optimization strategy or memory layout. This proposal enables many optimizations, but we will implement only some of them initially. Some optimizations, such as layouts that exclude null, will only become possible after future language and JVM enhancements.

Motivation

Many kinds of simple data values are immutable: complex numbers, pixel colors, times, dates, and so on. We usually model such values with classes that contain just enough logic to construct, validate, and transform instances, and that define the equals, hashCode, and toString methods so that equivalent instances can be used interchangeably.

As an example, the Platform API's LocalDate class models dates:

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

Intuitively, the essence of a LocalDate object rests in its year, month, and day values. But in the Java language, the essence of any object is its identity. Each time the LocalDate.of method invokes new LocalDate(...), the JVM creates a new object with a unique identity, 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 — that is, 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 may have different states in the future. Consider a text-editing application in which lines of text are represented by instances of a Line class. The sole field of the class is a list of characters, which is mutated when the user edits the line. Two Line objects might contain equivalent character lists, and thus also be equivalent, but that would be a coincidence; when the user changes one of the lines, the application will mutate the character list of that object but not the other, relying on identity to mutate the right one.

In other words, when objects are mutable, they cannot be interchangeable — yet 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.

Object identity is, in fact, actively confusing when objects are immutable and interchangeable. Most of us can 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.

Even worse, object identity can expose incidental implementation choices that result in surprising behavior. For example, the Integer class uses a cache to avoid creating unnecessary Integer objects with unique identities. There is, e.g., typically just a single Integer object representing the value 1. The cache is of fixed size, however, and does not extend to larger int values such as 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

We could avoid this sort of unexpected outcome 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, whether needed or not, is a performance impediment. It forces JVMs to allocate memory for each newly created object, thereby distinguishing that object from every other object already in the system, and access that memory whenever the object is used.

For example, suppose that 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 represented by a single block of memory containing int values:

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

The LocalDate array, by contrast, must be represented by a block of memory containing a sequence of pointers, each referencing another block of memory representing a LocalDate object:

+--------------+
| 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 represented 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.

To make matters worse, when a program iterates over the LocalDate array, it may dereference each pointer. Modern CPUs improve performance by caching small chunks of memory called cache lines. The various LocalDate objects could be allocated at memory addresses that are far apart if, e.g., they were created at different times, or they were moved by the garbage collector. The resulting poor reference locality could degrade performance by requiring every dereference to load a different cache line from memory.

Out of desperation, we might try to improve performance by writing code that creates as few objects as possible, thereby de-stressing the garbage collector and improving reference locality. For example, rather than use LocalDate objects we could model dates with int values counting the number of days since 1970-01-01. Unfortunately, this approach gives up the features of classes that make Java code so maintainable: meaningful names, private state, data validation by constructors, convenience methods, and so forth. It would be all too easy to forget — or for a colleague simply not to know — that int dates are relative to 1970-01-01 rather than some other date, leading to bugs that are difficult to diagnose.

Programming without identity

Trillions of Java objects are created every day, each one bearing a unique identity. We should enable developers to choose which objects in a program need identity, and which do not. The author of a class such as LocalDate, which represents simple immutable data, should be able to opt out of identity. Two LocalDate objects representing the date 1996-01-23 should be indistinguishable, just as two int values representing the number 4 are indistinguishable.

By opting out of identity, developers opt in to a programming model that enables 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

We introduce 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 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 enables us to find the object's fields. Traditionally, references are represented in a JVM as pointers to memory locations, thus encoding the unique identity of each object. Each invocation of the new operator allocates a fresh object, in a fresh block of memory, and returns a unique reference. And, traditionally, the == operator compares references by comparing pointers, 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 enables us to find the object's fields. In a JVM, however, it might not be represented by a pointer and thus does not encode the unique identity of the object. For a value class, invoking the new operator might not allocate a fresh object; it might, instead, return a reference to an existing object, or even a reference that embodies the object directly. The == operator compares references to value objects by comparing the objects' field values, so references to two objects are == if the objects have identical field values.

We can save memory and improve performance by using value objects for immutable data. Because a program cannot distinguish two value objects with identical field values, not even with the == operator, a JVM is able to change how a value object is laid out in memory without affecting the program. A JVM could, e.g., store the fields of a value object on the stack or even in CPU registers, rather than the heap.

Value objects are a preview language and VM feature, disabled by default

To use this feature in JDK 28, you must enable preview features:

  • Compile the program with javac --release 28 --enable-preview Main.java and run it with java --enable-preview Main; or,

  • When using the source code launcher, run the program with java --enable-preview Main.java; or,

  • When using jshell, start it with jshell --enable-preview.

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

For example, if your code refers to the LocalDate class and you compile with preview features disabled, the compiler uses the existing identity-object version of LocalDate and you do not need to run the program with preview features enabled. If you compile with preview features enabled, however, the compiler uses the new value-object version of LocalDate and you must run the program with preview features enabled. It is not possible to use the identity-object version of LocalDate when preview features are enabled.

Contents

Programming with value objects

In the Java Platform API, 30 classes are now declared as value classes. Examples include:

PackageClasses
java.lang Integer, Long, Float, Double, Byte, Short, Character, Boolean
java.util Optional, OptionalInt, OptionalLong, OptionalDouble
java.time LocalDate, LocalTime, LocalDateTime, ZonedDateTime, Duration

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

$ jshell --enable-preview
|  Welcome to JShell -- Version 28-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

We can use the new Objects.hasIdentity method to observe whether an object is an identity object:

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

The String class, due to some dependencies on object identity in its API and implementation, is not a value class, so instances of String are always identity objects:

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

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

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

jshell> s == t
$12 ==> 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, and their references can be null.

A few identity-sensitive operations, however, are not supported by value objects, including synchronization:

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)

JVM implementors have the 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, a JVM need not represent references to them using pointers; it can, rather, encode the fields of LocalDate objects directly in the references. Each element of the dates array 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, with lower memory footprint and better reference locality:

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

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

Declaring value classes

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

  • Immutable — All instance fields of the class are final, and the value represented by an instance will not change over time; and

  • Interchangeable — It is not necessary to distinguish between two separately-created instances that represent the same value.

When the value modifier is applied to a class, the fields of the class are implicitly final. The class itself is also implicitly final, so it 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)
$3 ==> false

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

Many classes have immutable and interchangeable instances, but they cannot be record classes because their fields do not correspond exactly to their constructor arguments; i.e., they are not transparent. Such classes might use private fields 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 long 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, boolean neg) {
        this(neg ? -e * 100 - c : e * 100 + c);
    }

    public EURCurrency(long e, int c) { this(e, c, false); }

    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

Traditionally, the purpose of the == operator was to test whether two referenced objects have the same identity.

After the introduction of value objects, the purpose of the == operator is to test whether two referenced objects are indistinguishable. Since identity objects are, by definition, distinguished by their identities, this means that the == operator works the same for identity objects in Java 28 as it has since Java 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:

  • They are instances of the same value class,

  • Their primitive-typed fields store the same bit patterns, and

  • Their reference-typed fields are indistinguishable, applying the == operator recursively.

When that is the case, a JVM can freely replace one reference with the other, and no code will be able to tell the difference.

The == operator and the equals method will often produce the same results for value objects. For some value classes, however, instances may be interchangeable (i.e., equals) even if their field values are different (i.e., not ==). To test whether two value objects represent the same value, use the equals method. When declaring a class, define equals in a way that always returns true for interchangeable instances.

The Substring value class, below, illustrates how == and equals may differ for some value objects. This class represents a substring of a string without allocating a new char[] in memory. The internal state of a Substring 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 represent the same character sequence (i.e., are equals) even though their internal state is different (i.e., not ==).

value class Substring {

    private String str;
    private int start, end;

    public Substring(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 Substring && toString().equals(o.toString());
    }

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

}

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

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

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

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

The results of the == operator and the equals method may also be different if the fields of two value objects refer to distinct identity objects that are interchangeable according to equals:

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

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

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

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

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

Another situation in which the == operator and equals may differ is when value objects have float or double fields. The primitive floating-point types support multiple NaN values. These NaN values are treated as interchangeable by most floating-point operations, but because each value is distinct, value objects that wrap different NaN values are distinguishable by the == operator. When declaring a value class, you must decide whether that distinction is meaningful for the equals method. For example, in a value record, the default behavior of equals treats all NaN values as interchangeable:

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)
$4 ==> true

jshell> l1 == l2
$5 ==> false

jshell> Integer.toHexString(Float.floatToRawIntBits(l1.val()))
$6 ==> "0x7ff80000"

jshell> Integer.toHexString(Float.floatToRawIntBits(l2.val()))
$7 ==> "0x7ff80001"

(For more on the different kinds of equivalence between floating-point values, see the specification of the Double class.)

Because the == operator compares the reference-typed fields of value objects recursively, applying it to two value objects may require an unbounded number of comparisons. In the following example, two deep nests of Box objects must be fully traversed to determine whether the objects are indistinguishable:

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)
$11 ==> true

jshell> b1 == b2
$12 ==> false

Constructors of value classes are constrained, as discussed below, so that the recursive application of the == operator to value objects will never cause an infinite loop. But deep comparisons may take a 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 implement interfaces. Thus variables declared with an interface type, or with the type Object, can store references to both value objects and identity objects:

jshell> Comparable<?> comp = LocalDate.of(1996, 1, 23)
comp ==> 1996-01-23

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. A value class may, however, 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 on an abstract value class can be read as meaning value-compatible.)

A value class can extend either java.lang.Object or an abstract value class, but not an identity class. (The Object class 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 safely be 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, which is a value class, and java.math.BigInteger, which is an identity class, both extend Number:

jshell> Number num = 123
num ==> 123

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

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

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

An abstract value class can be sealed in order to limit the classes that 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 of 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.

To ensure this is true, the fields of a value object must be set before the object is shared with other code. This also prevents any two value objects from referring to each other, either directly or indirectly, thereby ensuring that invoking the == operator never results in an infinite loop.

An object in the process of 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 invariant properties of the object may not yet hold, and that code may even observe the mutation of final fields.

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

Flexible constructor bodies, introduced in Java 25, enable safer initialization by allowing fields to be set and other code to be executed before the super(...) invocation. With this feature, there are two phases to the object 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 be mutated.

In a value class, constructor code always runs 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;

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

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

}

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.

If a value class constructor contains code that needs to work with this, an explicit super(...) or this(...) call can be used to mark the transition from the early construction phase to the late construction phase. Before super(...) can be called, however, all the class's fields must be assigned, without referring to this:

value class Name {

    String name;
    int length;

    private static int strLength(String n) {
        return n.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);
    }

}

Other construction enhancements

We loosen some of the restrictions on accessing the fields of all larval objects, whether they are value objects or identity objects. In Java 27, a new object's fields could be set in the early construction phase, but not read. In Java 28, with preview features enabled, these fields can be both written and read in the early construction phase. It continues to be illegal 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 28, 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's constructor always runs in the early construction phase. The fields of a record must be set before they can be observed, thus users of the class can always expect component accesses to produce consistent results.

This change is not source compatible for identity record constructor declarations, though we expect incompatibilities to be rare in practice. For example, this record class fails to compile because its canonical constructor refers to this in the early construction phase:

record Node(String label, List<Node> edges) {

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

   public Node {
        nullCheck(label, this);  // Error with --enable-preview
        nullCheck(edges, this);  // Error with --enable-preview
    }

}

Such incompatibilities will be 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(). A survey of existing record class declarations in a large source-code corpus confirms that it is rare for record classes to violate the new rules.

If a record constructor legitimately must access this then you can insert an explicit super() invocation, but you must explicitly set the record's fields before that, in the early construction phase.

Inherited methods of java.lang.Object

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

  • The inherited implementation of Object.equals uses the == operator to test whether the objects are indistinguishable. This might be the right equals behavior for a value class, but if it is not then the class should override the equals method.

  • The inherited implementation of Object.hashCode computes a hash from the object's field values. (This value can also be computed via System.identityHashCode — an unfortunate legacy name.) As usual, if a value class overrides equals then it should also override hashCode.

  • The inherited implementation of Object.toString returns a string of the form "ClassName@hashCode". Since value classes represent immutable data, they should override toString to more legibly convey the values represented by their instances.

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

A few other methods of Object interact with value objects:

  • For a Cloneable value class, the Object.clone method produces a value object that is indistinguishable from the original. The usual expectation that x.clone() != x is not meaningful for value objects. When declaring a value class that holds references to identity objects, consider overriding the clone method to create deep copies of those objects.

  • The wait and notify methods require that the object be locked in the current thread. Since it is impossible to synchronize on a value object, attempts to call these methods always fail with an IllegalMonitorStateException.

  • The finalize method of a value object is never invoked by the garbage collector. javac issues identity warnings for value classes that override finalize.

Migrating to value classes

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

As a general rule, if a class with immutable state does not require identity then 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 unnecessary identity requirement on their subclasses. (Some abstract classes define an inherently mutable API, even if they declare no mutable state, and so should not be made value classes. Some concrete classes do not model data at all — for example, they may be designed to be instantiated only once.)

If a class is either final or abstract, and has only final fields, then adding or removing the value keyword is a binary-compatible change.

Migrating an identity class to a value class does present source and behavioral incompatibility risks worth considering:

  • If the class has public constructors, existing clients may rely on them to create objects that are known to be distinguishable from every other object via the == operator. Changing the class to be a value class will invalidate that logic, possibly leading to run-time bugs.

    If this incompatibility is a serious concern, it may be appropriate to deprecate the public constructors and encourage the use of factory methods instead. As an example, in Java 9 we deprecated the constructors of Integer, Long, etc., recommending the use of the corresponding factory methods Integer.valueOf, Long.valueOf, etc., instead.

  • If existing clients synchronize on instances of the class then after migration they will fail, either with a compile-time error or an IdentityException at run time. This incompatibility is more likely to be a risk for classes with public constructors, since clients may rely on those constructors to create unique instances for locking.

  • If the equals and hashCode methods have not already been overridden, they will behave differently after migration. A good migration candidate will override these methods prior to migration so that their behavior does not depend on identity.

  • If the class encapsulates sensitive data, be cautious about the risk of exposing that data via the == operator or System.identityHashCode. A malicious user could use these operations to try to infer the data inside an instance. Value objects are not designed to protect sensitive data against such attacks.

  • If the class implements Serializable then it may require special handling, as discussed below. Migrating it to a value class may, moreover, break certain uses of the reflection and garbage collection APIs, also discussed below, or other specialized identity-sensitive code.

Value classes in the Java Platform

In the Java Platform API, 30 classes are now declared as value classes:

PackageClasses
java.lang Integer, Long, Float, Double, Byte, Short, Character, Boolean, Number, Record
java.util Optional, OptionalInt, OptionalLong, OptionalDouble
java.time LocalDate, LocalTime, LocalDateTime, ZonedDateTime, OffsetTime, OffsetDateTime, Duration, Instant, Period, Year, YearMonth, MonthDay
java.time.chrono MinguoDate, HijrahDate, JapaneseDate, ThaiBuddhistDate

To minimize compatibility risks, the specifications of these classes have long discouraged relying on the identities of their instances, and have long been specified as value-based. They have also discouraged or even prevented instance creation via constructors. Since Java 16, warnings for value-based classes have discouraged synchronizing on instances of these classes.

The vast majority of the Platform APIs work seamlessly with value objects. Methods that operate on Object or Object[] parameters accept value objects. Almost anywhere you need to provide an implementation of an interface, the implementation may be a value class. Generic types such as List<T> and Comparable<T> can be instantiated with value classes as the type arguments.

Additional changes in the Platform APIs further support value objects:

  • Two new methods in the java.util.Objects class, hasIdentity and requireIdentity, allow you to distinguish between identity objects and value objects.

  • A new constant in java.lang.reflect.AccessFlag, IDENTITY, tells when a class is an identity class.

    Whether a class is an identity class or a value class is recorded in its class file. Identity classes have the ACC_IDENTITY flag set; value classes do not. This flag supersedes the legacy ACC_SUPER flag. The JVM Specification has always recommended that compilers and tools set the ACC_SUPER flag in class files, so, by default, compilers and tools will continue to set the flag in new class files and thus generate identity classes.

  • Serialization of value records works automatically, but serialization of non-record value classes requires manual intervention. Value classes that implement Serializable must implement the writeReplace and readResolve methods so that a replacement object is serialized and deserialized in place of the value object itself. If these methods are not implemented, attempts to serialize or deserialize the value object will fail with an InvalidClassException.

    These methods must be implemented because value classes are compiled using strictly-initialized fields, and deserialization cannot safely initialize such fields. Value objects may only be created, and their fields initialized, by invoking a constructor. In the future, we expect to enhance the serialization mechanism so that serializable value classes can be serialized and deserialized automatically.

    javac issues serial warnings for value classes that will fail serialization at run time.

  • Using deep reflection, as embodied in the setAccessible and set methods of the java.lang.reflect.Field API, to mutate the fields of a value object is not supported. Libraries that modify final fields via deep reflection are incompatible with safe construction. They are not permitted to modify the fields of value objects even if the --enable-final-field-mutation option is given on the command line. Libraries must initialize instances of a value class using the class's constructors.

  • The garbage collection APIs in the java.lang.ref package and the java.util.WeakHashMap class cannot be used with value objects. Attempting to create Reference objects for value objects will cause an IdentityException to be thrown.

    Since JDK 25, javac has issued identity warnings when value-based classes are used with these APIs. As of JDK 28, javac also issues identity warnings when value classes are used with these APIs.

Run-time optimizations for value objects

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

  • Reference flattening: When a field of one object, or an element of an array, stores a reference to another object, a JVM can encode the other object's field values directly into the reference. When this is done, the reference is not a pointer to the other object in memory. The reference is said to be flattened.

  • Reference scalarization: When a method parameter or local variable stores a reference to an object, a JVM can encode the object's field values into additional local variables. When this is done, again, the reference is not a pointer to the object in memory. The reference is said to be scalarized.

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, a JVM could flatten an array of Integer references so that each array element holds a reference that directly encodes the underlying integer value instead of pointing to the memory location of an Integer object. Each reference also indicates whether the original Integer reference was null by prepending a 0 (null) or 1 (non-null) flag 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, a JVM will probably encode each flattened Integer reference as a 64-bit word. 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 Integer objects. (Each pointer 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, where it can be accessed without any additional 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 can also store flattened references. For example, a LocalDateTime object has two fields, a LocalDate and a LocalTime, and each 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 hardware architectures, this limits the size of mutable fields that store 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 objects, 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 a JVM could not store it 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 would choose — silently, at its discretion — a reference layout compatible with a mutable lastClicked field. Perhaps the field would store 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 |
                                +----------------------+

The fields of a value class, by contrast, do not have this atomicity limitation, since the fields of value objects can never be observed to be mutated. Thus, for example, the timestamp field of an Event value class could store a flattened reference to a LocalDateTime object:

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

Future enhancements may enable more flattening of references to 64-bit and even larger value objects. For example, additional language features may allow value classes to opt out of atomicity constraints, or perhaps 128-bit atomic mutable fields will become viable on some hardware architectures.

Reference scalarization

When a JVM loads a flattened reference from the field of an object in the heap, it must decode the reference into a form that it can readily work with. For code compiled by the JVM's just-in-time (JIT) compiler, this form can be a scalarized reference.

For example, consider this code fragment:

LocalDate d = dates[0];
dates[0] = d.plusYears(30);

The LocalDate.plusYears method itself might be declared:

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-compiling the plusYears method 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):

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

Then the result of JIT-compiling the fragment that updates the dates array might look like:

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

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

  • A flattened reference in dates[0] is converted to a scalarized reference when read,

  • A new scalarized reference is returned from plusYears, and

  • That reference is then converted to another flattened reference when written.

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. Thus it is possible to operate routinely on scalarized encodings of LocalDateTime references: 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 when they can 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 can flattening and scalarization be done?

Reference flattening and scalarization are optimizations, not language features. You cannot directly control them. Like all optimizations, they are done at the discretion of the JVM. There are, however, things you can do to make it more likely that a JVM can apply these optimizations.

First, flattening and scalarization require that a variable only stores references to instances of a specific value class; e.g., the date field of a LocalDateTime object always stores a LocalDate reference. Flattening and scalarization typically cannot 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 array can have arbitrary Object references stored in it in the future, a JVM must encode its elements as pointers to ordinary objects on the heap:

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

Value objects written to the objs array need to be converted to ordinary heap objects:

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 behaves at run time 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 simply encodes the same value object in different ways.

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

A second factor that influences whether a JVM applies flattening and scalarization is the content 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 are recorded in a new LoadableDescriptors class-file attribute. This attribute enables 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 in the LoadableDescriptors attribute of C.class, then when C is loaded, a JVM may not know that V is a value class. In practice, this means that if an existing class V is migrated to be a value class then, for optimal performance, classes that were compiled against older versions of V should be recompiled.

When reference flattening and scalarization are not possible, a JVM uses an ordinary reference to an object allocated in the heap. In non-optimal circumstances, this may cause a new object to be allocated upon each read of a field or invocation of a method. This behavior is most common during the warmup phase of a program, before a JVM’s JIT has generated optimized code.

Future Work

  • JEP 402, Enhanced Primitive Boxing, will improve the treatment of primitive types to take advantage of the lighter-weight characteristics of boxing to value objects.

  • JEP 218, Generics over Primitive Types (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, however, and do not help with objects that escape the scope of the optimization, such as storage in fields and arrays.

  • 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 implementors.

Risks and Assumptions

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

  • Some changes could affect the performance of identity objects. The if_acmpeq bytecode (==), for example, typically costs only 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 the == operator and the identityHashCode method can indirectly expose private field values. Further, the == operator can take unbounded time when comparing two large trees of value objects. Developers need to understand these risks.

Dependencies

JEP 539, Strict Field Initialization in the JVM, provides the mechanism to require, via bytecode verification, that value object fields be initialized during the early construction phase.