Every Java developer has written a small class whose only job is to hold a few values together — a Point, a Money, an RGB Color. You give it final fields, an equals, a hashCode, and you move on. What you probably never think about is that the JVM quietly staples an extra, invisible property onto every one of those objects: an identity. That identity is why new Color(255,0,0) == new Color(255,0,0) is false even though the two colors are indistinguishable, and it is why an Integer[] is dramatically slower to scan than an int[]. For twenty-five years that cost was simply the price of using classes.
Value classes — the headline feature of Project Valhalla, specified in JEP 401 — let you opt out of that cost. You mark a class with the new value keyword, promise it has no need for identity, and in return the JVM is free to treat its instances like primitives: no allocation, no header, flattened straight into arrays and fields. This post starts from what “object identity” even means (if you have never thought about it, that is fine) and ends at reference-grade detail: scalarization, heap flattening, the changed == semantics, migration rules, and the sharp edges. Each section builds on the one before it, so you can read straight through.
Status (July 2026): Value Classes and Objects is a preview language and VM feature (JEP 401, owned by Dan Smith, reviewed by Brian Goetz). After more than a decade of design, the implementation was integrated into the OpenJDK mainline in July 2026, targeting JDK 28 (due March 2027) as its first preview. Early-access builds are available now at jdk.java.net/valhalla. Everything here must be compiled and run with
--enable-preview. The syntax is stable; low-level details may still move between previews.
First, what is “object identity”?
Identity is the property that makes an object this specific object and not merely a bundle of the same field values. Two objects can be bitwise identical in every field and still be two different objects, because each was created separately and lives at its own location. Identity is what == compares for objects, what lets you synchronized on an object, and what a WeakReference tracks.
For a mutable object that genuinely matters. If two parts of your program share a reference to the same ArrayList, an add in one place must be visible in the other — that is only meaningful because both references point to the same list. But for a simple, immutable value like a color or a date, identity is at best useless and at worst a trap. You can see the trap without any preview features at all, on any JDK, using Integer:
public class IdentityTrap {
public static void main(String[] args) {
Integer x = 127, y = 127;
Integer p = 128, q = 128;
System.out.println("127 == 127 : " + (x == y)); // ?
System.out.println("128 == 128 : " + (p == q)); // ?
System.out.println("128 .equals : " + p.equals(q));// ?
}
}
Running it (here on a stock JDK) prints:
127 == 127 : true
128 == 128 : false
128 .equals : true
The same expression — comparing two Integers that represent the same number — is true for 127 and false for 128. The reason is that the JDK caches boxed Integer values in the range −128..127, so 127 hands back the same cached object twice, while 128 creates a fresh object each time with its own identity. This is the single most-viewed Java question on Stack Overflow for a reason: identity leaking into a value comparison is deeply confusing. Value classes are designed to make this class of bug impossible.
Why identity also costs performance
Identity is not just a correctness nuisance; it has a runtime price. Because each object must be distinguishable from every other object, the JVM typically allocates it on the heap, gives it an object header, and hands your code a pointer to it. An array of such objects is therefore an array of pointers to boxes scattered across the heap. Iterating it means chasing pointers, and every chased pointer is a chance to miss the CPU cache.
You can feel that cost today by summing fifty million integers two ways — once through boxed Integer (autoboxing on every iteration) and once through a primitive int:
public class BoxingCost {
public static void main(String[] args) {
int size = 50_000_000;
long t0 = System.nanoTime();
Integer boxed = 0;
for (int i = 0; i < size; i++) boxed += i; // box + unbox each iteration
long t1 = System.nanoTime();
long prim = 0;
for (int i = 0; i < size; i++) prim += i; // pure primitive
long t2 = System.nanoTime();
System.out.printf("Boxed Integer loop: %5.1f ms%n", (t1 - t0) / 1e6);
System.out.printf("Primitive int loop : %5.1f ms%n", (t2 - t1) / 1e6);
}
}
A representative run:
Boxed Integer loop: 275.6 ms
Primitive int loop : 17.6 ms
The boxed loop is more than fifteen times slower — not because addition is slow, but because each boxed Integer is a heap allocation with identity, and the loop drowns in allocation and pointer-chasing. The primitive loop keeps everything in registers. The whole promise of value classes is to let your own types behave like the second loop while still reading like the first.
The idea: let a class opt out of identity
Trillions of Java objects are created every day, each one carrying a unique identity whether it needs one or not. JEP 401’s premise is simple: let the developer decide which classes need identity and which do not. A class that represents a simple domain value can renounce identity, and once it does, there is no longer any such thing as “two different Color objects that happen to be equal” — just as there are never two different int values that both represent 4.
The vocabulary is worth pinning down, because the rest of the post uses it precisely:
- A value object is an object with no identity. It is an instance of a value class. Two value objects are the same under
==when they are the same class with the same field values — whenever or however they were created. - An identity object is an ordinary object with identity, the only kind Java had before this JEP. Two identity objects are
==only when they occupy the same memory location.
By giving up identity, a value class opts into a better deal: the abstraction and safety of a class, with the memory behaviour of a primitive.
Declaring a value class
You add one modifier: value. Consider a currency amount stored as integer cents. It never mutates, never synchronizes, and two amounts of “$3.95” are interchangeable — a textbook value class:
value class Money implements Comparable<Money> {
private int cents; // implicitly final
private Money(int cents) { this.cents = cents; }
public Money(int dollars, int cents) {
this(dollars * 100 + (dollars < 0 ? -cents : cents));
}
public int dollars() { return cents / 100; }
public int cents() { return Math.abs(cents % 100); }
public Money plus(Money that) { return new Money(this.cents + that.cents); }
public int compareTo(Money that) { return Integer.compare(this.cents, that.cents); }
public String toString() {
return "$" + dollars() + "." + String.format("%02d", cents());
}
}
Nothing about using the class changes. You still write new Money(3, 95), the type can still be null, it can implement interfaces, and callers cannot tell it apart from an ordinary class — except for the identity-sensitive behaviours we are about to cover. If your value already happens to be a record, the change is even smaller: just prepend value.
value record Point(int x, int y) {} // final fields, equals/hashCode/toString, and no identity
Three rules define a value class, and they all follow from “no identity”:
- All instance fields are implicitly
final. Mutable state needs identity to make sense (“the same object, changed”), so value classes forbid it. - Instance methods must not be
synchronized, and you cannot lock on a value object — a monitor is a per-identity thing. - A value class extends only
Objector an abstract class with no fields, no instance initializers, and no synchronized methods. Abstract classes can themselves be value classes (java.lang.Numberis the canonical example).
The one genuinely new rule: construction
There is a subtle constraint that trips people up, so it earns its own section. In an ordinary class, final fields may be assigned anywhere in the constructor, and it is even possible (with poor code) to observe them before they are set:
class IdentityTest {
final int x, y;
int sum() { return x + y; }
IdentityTest(int x, int y) {
System.out.println(sum()); // 0 — both fields still zero
this.x = x;
System.out.println(sum()); // x — y still zero
this.y = y;
}
}
That “half-built object” window is only safe because the object has an identity to pin it down while it is being assembled. A value object has no identity, so the language forbids the window entirely: a value class must set all its fields before the object becomes observable — concretely, before the super() call and before any use of this. In practice this means field assignments come first:
value class ValueTest {
final int x, y;
int sum() { return x + y; }
ValueTest(int x, int y) {
this.x = x;
this.y = y;
System.out.println(sum()); // 3 — first observable point, fully built
}
}
A direct consequence: a value object can never appear in a cycle of its own fields. You cannot build an Item whose predecessor field points back at itself, because at the moment the fields are initialized the object is not yet referenceable. Any reference cycle must pass through at least one identity object.
How == changes for value objects
This is the behavioural change most likely to surprise you, so read it carefully. For identity objects, == asks “same location?” For value objects there is no meaningful location, so == instead asks “same value?” — same class, and all fields equal (primitives by bit pattern, references compared recursively with ==). The Valhalla team’s early-access build shows this cleanly in JShell, where Integer and LocalDate have already become value classes under preview:
jshell> Objects.hasIdentity(Integer.valueOf(123))
$1 ==> false
jshell> Objects.hasIdentity("abc")
$2 ==> true
jshell> LocalDate d1 = LocalDate.now()
d1 ==> 2026-07-15
jshell> LocalDate d3 = d1.plusDays(365).minusDays(365)
d3 ==> 2026-07-15
jshell> d1 == d3
$8 ==> true // same value → ==, even though "created" separately
Under today’s rules, that last comparison would almost certainly be false, because plusDays returns a freshly allocated LocalDate. As a value class, the two are simply the same value. Note the helper: java.util.Objects.hasIdentity(obj) tells you which world an object lives in, and its companion Objects.requireIdentity(obj) throws an IdentityException if you are handed a value object where you genuinely needed identity.
The takeaway is the one you were probably already taught: prefer equals over == for objects. Value classes make that advice load-bearing rather than stylistic.
equals vs ==: internal vs external state
If == now compares fields, do you still need equals? Often, yes — because a value’s internal state (the bits it stores) is not always the same as its external state (the value it represents). == compares internal state; equals is where you define what “the same value” really means.
The JEP’s Substring example makes the distinction vivid. It represents a slice of a string without copying characters, so two Substrings can represent the identical text while holding completely different fields:
value class Substring implements CharSequence {
private String str;
private int start, end;
Substring(String str, int start, int end) {
this.str = str; this.start = start; this.end = end;
}
public int length() { return end - start; }
public char charAt(int i) { return str.charAt(start + i); }
public String toString() { return str.substring(start, end); }
public boolean equals(Object o) {
return o instanceof Substring && toString().equals(o.toString());
}
}
var s1 = new Substring("ionization", 0, 3); // "ion"
var s2 = new Substring("ionization", 7, 10); // "ion"
// s1 == s2 → false : different str/start/end (internal state)
// s1.equals(s2) → true : same represented text (external state)
This also explains a question people often ask: why are not all value classes records, and not all records value classes? They control orthogonal things. A record opts out of writing your own internal state boilerplate; the value modifier opts out of identity. You can want either, both, or neither — a clean two-by-two you can keep in your head:
| Ordinary class | record |
|
|---|---|---|
| identity (default) | mutable entities, services, anything you lock on | immutable data carrier that still needs identity |
value |
value with custom internal encoding (e.g. Substring, Money) |
the sweet spot: value record Point(int x, int y) |
Under the hood, part 1: scalarization
Now the payoff. Because a value object has no identity to preserve, the JIT compiler is free to stop treating it as a boxed thing on the heap and instead pass its fields around directly — as if you had hand-written the primitive version. This is called scalarization: a reference to a value object is reduced to its “essence,” the raw field values (plus one bit to record whether the reference is null).
Consider a Color value class with a mix method. Conceptually the JIT rewrites the call so no Color object is ever created:
The JVM has always attempted something similar for identity objects via escape analysis, but that optimization is fragile: the moment an object might escape the compiler’s view, or its identity is used (==, synchronized, hashing), the whole thing unravels. Scalarization of value objects is far more predictable and reaches across method boundaries, because the JVM knows the object has no identity to protect in the first place.
Under the hood, part 2: heap flattening
Scalarization helps inside hot methods, but the bigger win is in memory. When value objects are stored in a field or an array, the JVM can flatten them: encode each object’s fields as a compact bit vector and store it inline, with no header and no separate heap box to point at. A Color[] stops being an array of pointers and becomes, effectively, an int[].
How much does this matter? Oracle’s Valhalla team published a measurement you can reproduce with the early-access build: fill an array of fifty million LocalDate objects (populated from an unsorted HashSet so they are realistically scattered) and sum their years. Without preview features, LocalDate is an ordinary identity class:
$ java DateTest.java
Attempt 1: 82.703
Attempt 5: 71.915
With --enable-preview, LocalDate becomes a value class, its instances flatten into the array, and the same loop runs roughly three times faster:
$ java --enable-preview DateTest.java
Attempt 1: 41.959
Attempt 5: 25.027
Nothing about the loop changed — only the array’s memory layout. Flattening removed the pointer-chase, so the CPU cache does the rest.
There is a hard limit worth knowing: flattened data must be readable and writable atomically, which on common hardware means roughly 64 bits for this first preview. A value class with two ints or a single double may be too wide to flatten atomically and will fall back to an ordinary heap encoding. Wider flattening (128-bit, and larger sizes that trade away atomicity) is planned for later JEPs.
What you get for free: migrated JDK classes
You do not have to write a single value class to benefit. When preview features are enabled, a set of long-standing “value-based” JDK classes become value classes automatically, because they were designed years ago in anticipation of exactly this. Most importantly, the primitive wrapper classes — Integer, Long, Short, Byte, Character, Boolean, Float, Double — and the abstract java.lang.Number lose their identity, along with value-based types such as Optional and the java.time classes like LocalDate.
The consequences are pleasant: boxing gets dramatically cheaper (an Integer[] can approach the density of an int[]), and the 128 == 128 trap from the opening section quietly disappears, because == on two Integers now compares their values. Code that synchronized on an Integer or a LocalDate — always a bug — will now fail loudly, which is the only behavioural break most programs will notice.
Migrating your own classes
Turning one of your classes into a value class is usually a one-word change, but it is a promise, so audit these first. A class is a safe candidate when it is already effectively a value: all fields final, no reliance on identity. Before adding value, confirm that:
- No one
synchronizedon its instances (the compiler will now reject it). - No code depends on
==meaning “same object” rather than “same value.” - It is not used as a
WeakReference/identityHashMapkey that relies on per-instance identity. - Constructors can set every field up front (no observing a half-built
this).
If all four hold, adding value is binary-compatible: existing compiled callers keep linking, and the only new compile error anyone can hit is an attempt to synchronize on the type. That low-friction migration path is deliberate — it is how the JDK itself migrates Integer and friends.
What is not in this preview
Value classes are the foundation of Project Valhalla, not the whole building. Keeping the scope clear will save you from expecting things that are not there yet:
- Null-restricted types (a non-nullable
Point!that flattens without a null flag, and larger flattened layouts) are a separate, later JEP. - Specialized generics — a
List<Point>that stores flattenedPoints instead of boxed references — are still in design. For now, generics over value types still box on the heap. - 128-bit and wider flattening is future work; this preview flattens up to roughly 64 bits atomically.
Even so, this preview is the hard part: it changes the Java object model itself, which is why it took the better part of a decade and, by the OpenJDK team’s own count, on the order of 197,000 lines of code to land.
Try it today
Download an early-access build from jdk.java.net/valhalla, then experiment in JShell — the fastest way to build intuition:
$ jshell --enable-preview
jshell> value record Point(int x, int y) {}
jshell> Objects.hasIdentity(new Point(1, 2))
$1 ==> false
jshell> new Point(1, 2) == new Point(1, 2)
$2 ==> true
For source files, compile and run with preview enabled: javac --release 28 --enable-preview Foo.java and java --enable-preview Foo. To see real performance effects, reach for JMH rather than wall-clock timing, and profile array-heavy, value-dense workloads — that is where flattening earns its keep. Sprinkling value across a codebase will not fix an unrelated bottleneck.
Risks and sharp edges
A few things to keep in your peripheral vision. Because == now recursively compares fields, comparing two large trees of value objects can be surprisingly expensive — and, in principle, an attacker-supplied deep structure is a denial-of-service vector, so bound the depth of anything you compare or hash. The same recursion means == and hashCode can indirectly reveal private field values; that is rarely a problem but worth knowing for security-sensitive types. And every existing if_acmpeq (an == on references) now carries a tiny extra check to detect value objects; the team has optimized the identity fast path, but it is a reminder that this is a genuine change to the object model, not a library add-on.
Quick reference
// Declaration
value class Money { /* final fields, no synchronized */ }
value record Point(int x, int y) {}
abstract value class Number { /* no fields, no init blocks */ }
// Rules
// - all instance fields implicitly final
// - no synchronized methods; cannot lock on a value object
// - fields must be set before super()/first use of this (no half-built object)
// - superclass must be Object or a fieldless abstract class
// Semantics
a == b // value objects: same class + same field values (recursive)
a.equals(b) // author-defined "same value"; still prefer this
Objects.hasIdentity(obj) // false for value objects
Objects.requireIdentity(obj) // throws IdentityException for value objects
// Optimizations (JVM, automatic)
// scalarization -> fields passed in registers across calls (no allocation)
// heap flattening -> value objects stored inline in fields/arrays (no header/pointer)
// first preview: ~64-bit atomic flattening
// Build
javac --release 28 --enable-preview Foo.java
java --enable-preview Foo
Closing thought
Value classes close a gap that has defined Java since 1995: the choice between the abstraction of objects and the performance of primitives. For twenty-five years you picked one. With value, you write an ordinary-looking class — methods, encapsulation, equals, interfaces — and the JVM is free to lay it out as flat, allocation-free data. Start by letting the migrated JDK classes speed up your boxing for free, then mark your own small immutable types value where a profiler says it pays. The mental model to carry forward is simple: identity is now something you opt into, not something you are charged for by default.
References and further reading
- JEP 401: Value Classes and Objects (Preview) — OpenJDK
- Value Classes and Objects: feature documentation — Project Valhalla
- Try Out JEP 401 Value Classes and Objects — Dan Smith, Inside.java (source of the LocalDate benchmark)
- Project Valhalla Early-Access Builds — jdk.java.net
- Project Valhalla — OpenJDK