Skip to content
Published on

What Java Value Objects (JEP 401) Change — What You Gain and Lose by Giving Up Identity

Share
Authors

Introduction — July 31, 00:45 UTC, 220,000 Lines

At 00:45 UTC on July 31, 2026, a single commit landed on the OpenJDK mainline. Its title is two lines.

8389219: Implement JEP 401: Value Objects (Preview)
8389220: Implement JEP 539: Strict Field Initialization in the JVM (Preview)

The stats on the commit are 208,011 lines added and 13,161 removed across 1,888 files. The original pull request, #31120, was opened by David Simms on May 11, and OpenJDK's Skara bot squashed it into a single commit as usual. A few hours later, the Hacker News thread climbed to 177 points.

JEP 401 itself was created on August 13, 2020, and its roots — Project Valhalla — go back to 2014. It's rare for a JEP to have both Effort and Duration listed as XL. As of this writing its status is Targeted, Release 28, with the document last updated on July 30, 2026. JEP 539, merged alongside it, is a dependent JEP that enforces at the bytecode-verification level that a value object's fields must be initialized during its initial construction phase — it's meaningless without 401, and 401 doesn't hold together without it.

It's worth tempering the excitement a notch here. This is a preview feature, off by default, and JDK 28's GA is still a long way off. Still, it's worth examining closely because it touches the Java object model itself. Changes to the meaning of the == operator and the synchronized keyword are rare in Java's history.

What It Precisely Means to Have No Identity

The JEP's example is clean. An object created with LocalDate.of(1996, 1, 23), and another object obtained by adding 30 years to it and then subtracting 30 years, hold the same year, month, and day. equals returns true. But == is false. The two objects have different identities.

For a mutable object, identity is essential — you need to distinguish two objects that happen to have the same state now but may diverge later. If two lines in a text editor happen to hold the same string, editing one must not also change the other. But for immutable data, the situation is reversed. There's no real difference between two LocalDate instances representing 1996-01-23. There's no reason to distinguish them, yet distinction is being forced anyway.

That forced distinction costs two things: fresh memory has to be allocated every time, and a pointer has to be chased every time that memory is used. The JEP illustrates the memory layout of a LocalDate array with a diagram — to hold five 48-bit pieces of data, it ends up using five pointers and five heap objects (each carrying a header of at least 64 bits). And if those objects happen to sit far apart, every traversal fragments the cache line.

In the JDK 28 preview, 30 classes in the platform API are declared as value classes: all the boxed types (Integer, Long, Float, Double, Byte, Short, Character, Boolean), plus Number, Record, four classes in the Optional family, twelve in java.time, and four calendar-date classes in java.time.chrono. So turning on the preview makes this happen.

// jshell --enable-preview
Integer x = 1996, y = 1996;
x == y                       // true   (false without preview — outside the Integer cache range)

LocalDate d1 = LocalDate.of(1996, 1, 23);
LocalDate d3 = d1.plusYears(30).minusYears(30);
d1 == d3                     // true

Objects.hasIdentity(d1)      // false  (new method)

String is not a value class. There are still places where its API and implementation depend on identity, so strings remain identity objects.

The new definition of == is this: two value objects are == if (1) they are instances of the same value class, (2) the bit patterns of their primitive-typed fields match, and (3) their reference-typed fields are indistinguishable when == is applied to them recursively. The behavior of == on identity objects is unchanged since Java 1.0.

One thing to watch for: == and equals can now diverge even on value objects. The JEP's example is a value class that represents a substring using the original string plus two coordinates. Two instances can represent the same character sequence but hold different internal state, so equals is true while == is false. The same goes for floating point — two value objects holding NaNs with different bit patterns are equal by equals but not by ==. Don't jump to "it's a value object, so == is fine to use." equals is still the default.

Where the Performance Comes From — Flattening and Scalarization

The JEP splits the optimization into two branches, and the distinction matters in practice.

Reference flattening applies to fields and array elements on the heap. When an object's field points to a value object, instead of a pointer, the value object's fields are encoded directly inside the reference. For an Integer array, each element becomes a 64-bit word holding a 1-bit null flag and a 32-bit int. That's clearly smaller than an array of pointers, and, more importantly, there's no extra memory load.

Reference scalarization applies to method parameters and local variables. The JIT breaks a single LocalDate reference into four local values: a null flag, an int, a byte, and a byte. The JEP's pseudocode makes this vivid — once the plusYears method is compiled, it never touches a LocalDate pointer at all.

The decisive difference between the two optimizations is the size limit. A flattened reference always has to be read and written atomically, or it can tear — the first half written by one thread could get mixed with the second half written by another, and a date that never existed could be observed. Since 64 bits is the size at which atomic access is guaranteed on common hardware, a flattened reference stored in a mutable field is, in practice, capped at 64 bits.

That's why LocalDateTime can't be flattened into a mutable field: its internal LocalDate and LocalTime fields, each with its own null flag, plus its own null flag, add up to more than 64 bits. The JVM quietly falls back to a pointer layout in this case. Fields of a value class, on the other hand, have no such limit — since a value object's fields can never be observed to change, there's no room for tearing in the first place. The very same LocalDateTime field can end up as a pointer if the containing class is an identity class, or flattened if it's a value class.

Scalarization has no such limit. Values living on the stack and in registers are never exposed to a data race.

The JEP also spells out when flattening and scalarization happen. These are optimizations, not language features, so you can't control them directly — but there are ways to raise the odds.

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

record Box<T>(T field) { }      // field is erased to Object — flattening not possible
var b = new Box<Integer>(1996); // stores a heap pointer

The variable has to be declared as a specific value-class type. A spot declared with a supertype or a generic type parameter is effectively Object due to erasure, and it's not eligible for optimization. This is exactly why the remaining pieces of Valhalla — JEP 218, generics over primitive types — are still needed.

One more detail. When a class is compiled, the names of value classes appearing in field and method signatures get recorded in a new class-file attribute called LoadableDescriptors, and the JVM uses this to load those value classes early enough to prepare flattened fields and scalarized parameters. In other words, when an existing class migrates to a value class, code that references it needs to be recompiled to get the best performance. It still works without recompiling — it just keeps the heap allocation around.

Records, and the Relationship with Primitives

Records are already final with all-final fields, which makes them a natural candidate for value classes. The syntax simply overlaps.

value record Point(int x, int y) { }

Point p = new Point(17, 3);
Objects.hasIdentity(p);      // false
new Point(17, 3) == p;       // true

That said, not every value class can be a record. A record has to be a transparent class where constructor arguments map exactly to fields, and plenty of classes choose a different internal representation to save memory. The JEP's example is a currency class that stores euros and cents packed into a single long — it can't be a value record, but it can be a value class.

The relationship with primitive types is nailed down in the JEP's Non-Goals. Changing how primitive types are treated is explicitly not a goal. Value objects behave like primitives in many respects, but they're a separate concept. The Java language still deals with exactly two kinds of data — primitives and object references. Introducing something like C's or C#'s struct is also an explicit non-goal. C#'s value types have instances with identity and mutable fields, which forces a detailed specification of copy semantics on assignment and call, and that makes the user-facing model that much more complex. Java chose to leave that low-level decision to JVM implementers instead.

The class hierarchy is worth laying out too. A value class can implement interfaces, can be sealed, and, if declared abstract, becomes an extensible "value-compatible" superclass. A value class can only extend Object or an abstract value class — it can't extend an identity class. There's no common superclass like java.lang.Value.

The "four buckets" framing (ordinary objects / identity-free objects / atomic values / tearable values) that came up often in the Hacker News discussion originates from the Valhalla design notes, but only the first two made it into this merge. The opt-out syntax that explicitly permits tearing to escape the atomicity constraint isn't part of this PR, and the JEP defers it as "future enhancement."

What Breaks

Once a class becomes a value class, operations that depended on identity change behavior or fail outright. Here's what the JEP text says.

OperationResult on a value object
==Recursively compares field values instead of identity. Two Integer 1996's are now ==
synchronizedCompile error if the static type is a value class; a runtime IdentityException if routed through an Object-typed reference
wait / notifyAlways IllegalMonitorStateException, since no lock can be acquired
System.identityHashCodeComputes the hash from field values, not identity (the name is now legacy only)
finalizeNever called by the GC. Overriding it triggers an identity warning from javac
java.lang.ref family, WeakHashMapIdentityException when constructing a Reference
SerializationValue records work automatically. Other value classes throw InvalidClassException without writeReplace/readResolve
Mutating a final field via deep reflectionNot supported. Not possible even with --enable-final-field-mutation
cloneReturns a value object indistinguishable from the original. Expecting x.clone() != x no longer makes sense

Three spots on this table are where it hurts most in practice.

First, the weak-reference family — the Reference subclasses in java.lang.ref, plus WeakHashMap. If you have a weak cache keyed on value objects, it now throws, full stop. That said, javac has been issuing identity warnings for value-based classes since JDK 25, and from JDK 28 it issues the same warning for value classes too. If you've been watching those warnings, you already have your list.

Second, serialization. Value classes compile down to strictly-initialized fields, and deserialization fills in fields without going through a constructor, which means it can't be safely initialized that way. So a non-record value class that implements Serializable has to implement writeReplace and readResolve itself to substitute a stand-in object. Skip it, and you get a runtime InvalidClassException, plus a serial warning from javac.

Third, libraries that mutate final fields via deep reflection. Some serialization frameworks, ORMs, mocking libraries, and DI containers rely on exactly this. For value objects, it's blocked with no exceptions — you have to go through the constructor. This narrowing of the path to mutating final fields is a story that already started in JDK 26's final-field-mutation warning — JEP 500, and JEP 401 shows where it ends up.

Two more things quietly noted in the JEP's Risks and Assumptions section are worth remembering for anyone designing a library. Because == recursively compares a tree of value objects, deep nesting can make it slow, or even trigger a StackOverflowError. And because == and identityHashCode can indirectly expose private field values, it's safer not to turn a class holding sensitive data into a value class.

What Library Authors Should Do Now

JDK 28 is still a way off, but there are things worth doing now that pay off for free later.

1. Pick your candidates. There are two criteria: are instances immutable (are all instance fields final, and does the value they represent never change over time), and are they interchangeable (is there no need to distinguish two instances representing the same value)? Field-less abstract classes are good candidates too — there's no reason to force an identity requirement onto subclasses. Number became exactly this kind of abstract value class.

2. Override equals and hashCode first. If you make sure ahead of the migration that neither depends on identity, behavior won't change once the class becomes a value class. Migrate without overriding them, and the meaning of the inherited implementation shifts under you.

3. Clean up public constructors. Clients might be calling new to create "an instance distinguishable from every other object" to use as a lock or as a marker for == comparisons. The path the JEP recommends is the same one taken with the Integer constructor back in Java 9 — deprecate the constructor and steer people toward factory methods.

4. Audit what gets synchronized on. Check whether your own code locks on instances of a value-candidate class, and whether your documentation is steering clients to do the same. Java has had synchronization warnings for value-based classes since Java 16, so if you've had build warnings turned on, the list is already there.

5. Binary compatibility is nothing to worry about. If a class is final or abstract and all its fields are final, adding or removing the value keyword is a binary-compatible change. The list above covers the entire source- and behavior-compatibility risk.

To actually try this out right now, grab a JDK 28 EA build and turn on the preview.

javac --release 28 --enable-preview Main.java
java  --enable-preview Main

# single-file source launcher
java --enable-preview Main.java

# jshell
jshell --enable-preview

One rule about the preview is worth pointing out. Those 30 platform-API classes are value classes only when the preview is on. Compile without the preview and the compiler uses the existing identity version of LocalDate; turn the preview on and it uses the value version. There's no way to get the identity version while the preview is on. In other words, this feature can't be "partially" turned on.

The Weight of the Word "Preview" — Timeline and What's Left

Let's get the dates exactly right. On the JDK 28 project page, the targeting review for JEP 401 closed on July 30, 2026, and as of that date the JEP document's status is Targeted / Release 28. The merge happened early the next morning. The same page also lists JEP 539 (review closed July 30) and JEP 535 (making Shenandoah's generational mode the default, review closing August 3) alongside it.

Meanwhile, JDK 27 has already entered Rampdown Phase Two, with GA set for September 15, 2026. Given Java's six-month cadence, JDK 28 GA lands in March 2027. And since preview features conventionally ship as a preview for at least one release beyond that, the point at which you can use value classes without --enable-preview is later still. How many preview rounds it'll take hasn't been decided yet — that part remains unconfirmed.

And what merged this time isn't all of Valhalla. The JEP names two items explicitly as Future Work.

  • JEP 402, Enhanced Primitive Boxing — exploits, at the language level, the fact that boxing has gotten lighter now that it's backed by value objects.
  • JEP 218, Generics over Primitive Types (revised) — lets a generic class specialize the layout of fields, arrays, and local variables when parameterized with a value class. This is the piece that lifts the "generic fields don't flatten" constraint from the earlier section.

Beyond that, there's still the tearing opt-out mentioned earlier, null-excluding layouts, and automatic serialization for value classes. So "Valhalla is done" isn't accurate. Twelve years in, the first piece has landed on the mainline.

Conclusion — Changes to the Object Model Don't Arrive Quietly

To sum up.

  • A value class trades away identity in exchange for giving the JVM freedom over memory layout. == becomes a field-value comparison, and synchronized stops being possible.
  • The performance comes from flattening (fields and arrays on the heap) and scalarization (local variables and parameters on the stack). The former hits a real 64-bit wall because of atomicity; the latter doesn't.
  • To get the optimization, a variable has to be declared as a concrete value-class type — a spot typed as Object or a generic type parameter doesn't qualify. And code that uses a migrated value class needs to be recompiled.
  • What breaks: the java.lang.ref family, WeakHashMap, serialization for non-record value classes, mutating final fields via deep reflection, and clients that synchronize on your objects. The identity warnings accumulated since Java 16 are exactly that list.
  • The status is Targeted / JDK 28 preview, with JDK 28 GA slated for March 2027. The point where it's on by default comes after that.

There's no production impact right now. But if there's code somewhere that puts a LocalDate into a WeakHashMap as a key, it's likely already emitting a warning. Now is the time to go read it.

References