Project Valhalla's JEP 401 is integrated into JDK 28 as a preview. It needs --enable-preview at both compile and runtime, so nothing you ship today changes. But once this graduates, a class you already use constantly is going to stop having identity, and the compiler is not going to tell you.
What a value class actually changes
A value class in JEP 401 gets four behavioral changes:
- Fields are implicitly final.
-
==compares field values instead of object identity. - Construction must fully initialize every field before the constructor returns.
- Instance methods on a value class cannot be
synchronized.
Several existing JDK classes become value classes the moment the preview flag is on. Primitive wrappers, Integer, Long, and friends, are named examples. So is LocalDate. Backward compatibility is preserved with the flag off, so this is opt-in territory today. But Integer and LocalDate are about as core to a Java codebase as it gets, which is exactly why this matters more than a typical preview feature.
Why value classes exist at all
Identity has a cost. Every identity-bearing object carries a header, cannot be unboxed cleanly, and cannot be packed into arrays or CPU registers the way a primitive can. The JVM has to always go through a pointer.
For types where nobody actually relies on identity, Integer and LocalDate being the textbook cases, that cost buys nothing. Nobody writes code that depends on two LocalDate instances representing January 1st being the same object, only that they represent the same date. Removing identity where nobody needs it lets the JVM flatten these objects and inline them instead of always chasing a pointer. That is the performance case for Valhalla in one sentence.
The first thing that breaks: ==
// Before JDK 28 preview
LocalDate d1 = LocalDate.of(2026, 1, 1);
LocalDate d2 = LocalDate.of(2026, 1, 1);
System.out.println(d1 == d2);
// false, identity compare, two different objects
// JDK 28, --enable-preview, LocalDate is now a value class
LocalDate d1 = LocalDate.of(2026, 1, 1);
LocalDate d2 = LocalDate.of(2026, 1, 1);
System.out.println(d1 == d2);
// true, value compare, no identity left to compare
Boxed-primitive == has always been a footgun, thanks to the autoboxing integer cache (-128 to 127) making small values compare equal by accident while larger ones do not. This is a different, sharper version of the same footgun. It is not that identity comparison is unreliable anymore. There is no identity left to be unreliable. Any code that used == as an intentional identity check, a cache lookup, a pooling pattern, an object-identity-based Set, silently gets different results the moment the underlying type becomes a value class. No compile error. No exception. Just a different answer at a line nobody thought to re-read.
The second thing that breaks: synchronized
// Before: legal, LocalDate is an ordinary identity-bearing object
LocalDate d1 = LocalDate.of(2026, 1, 1);
synchronized (d1) {
// ...
}
// After: LocalDate is a value class, this is no longer valid
LocalDate d1 = LocalDate.of(2026, 1, 1);
synchronized (d1) {
// compile-time error, value classes cannot be used as monitors
}
This one at least fails loudly, at compile time, which is the better failure mode of the two. But it means any code locking on an instance of a type that becomes a value class needs to be found and fixed before upgrading, and today there is no tooling that flags "this class might become a value class someday" ahead of time.
The honest trade-off
Preview-only, opt-in, nothing breaks in production code today. That is the reassuring half. The other half: the classes affected are not obscure corners of the JDK, they are Integer and LocalDate, used in essentially every nontrivial Java codebase. And the failure mode on == specifically is silent. A compile-time break, like the synchronized case, gets caught in CI. A silent behavior change in an == comparison gets caught in production, if it gets caught at all.
The safe posture right now, while this is still behind a flag, is to audit for two patterns before JEP 401 graduates out of preview: == used intentionally for identity on boxed primitive types or LocalDate, and synchronized blocks or methods locking on instances of those same types. Neither pattern is common in well-written code, .equals() should be doing the comparison work already in most cases, but "uncommon" is not the same as "absent," and this is the kind of bug that survives code review because it used to be correct.
Found either pattern in your own codebase while reading this?
Top comments (0)