Thirty JDK classes, including Integer, Double, Optional and LocalDate, lose their identity in JDK 28 as soon as the JVM starts with --enable-preview. That is the most visible effect of JEP 401, which brings Valhalla's value classes in as a preview feature, and the unusual part is that it reaches code that hasn't changed a single line. On October 27, 2026 the OpenJDK Quality Group asked open source projects to start testing against JDK 28, both with and without preview features.
What JEP 401 brings, and why it hits code that never opted in
A value class is a class whose instances have no identity: two objects with the same field values cannot be told apart. It no longer matters which one you hold, only what it contains.
A preview feature usually only matters to people writing new code with it. Not this time: with preview enabled, the JDK itself turns these 30 classes into value classes, and everybody uses them:
| Package | Classes |
|---|---|
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 |
The new behavior applies whenever code runs on JDK 28 with preview enabled, even if it was compiled without preview or on an older Java release. A years-old JAR can behave differently purely because of the flag you start the JVM with. Without --enable-preview, which is the default, behavior should stay exactly as it is today.
What breaks: four identity-sensitive operations
The list is short, but every item is a pattern you will find in real codebases:
==compares values. For instances of these classes,==may returntruewhere it used to returnfalse. Tests or caches that relied on two equalLocalDateobjects being distinct will stop holding.- No monitor. You can't synchronize on a value object, and
waitandnotifyare unsupported: any monitor interaction is a compile error or anIdentityExceptionat runtime.synchronized (someInteger)is the classic case. - No weak references. The
java.lang.refAPIs andjava.util.WeakHashMapthrowIdentityExceptionfor value objects. A cache built onWeakHashMap<Long, …>stops working. - Truly immutable. Calling
setAccessibleon their fields ends in anIllegalAccessException, even with final field mutation enabled. That matters for serialization and mocking libraries that poke at these objects through reflection.
None of this comes out of nowhere. JDK 8 defined value-based classes, covering Optional and java.time, and warned against relying on their identity; JDK 16 extended the category to the primitive wrappers. JEP 401 turns that documentation warning into JVM behavior. If you took the advice back then, you'll barely notice. If you synchronize on an Integer, you'll find out on the first run.
Records change too when you compile with preview
The second front is at compile time. When you compile with JDK 28 and preview enabled, record constructor code runs by default in the early construction phase formalized by JEP 513 (flexible constructor bodies). Only field assignments are allowed there, so any statement that touches this, such as calling an instance method, becomes a compile error. Constructors already compiled without preview keep running exactly as compiled.
There are three ways out, depending on the case:
- Remove the offending statement.
- Rewrite it so it works in an early construction context, which is very close to a static one.
- Assign all fields, call
super()orthis(...)to end early construction, and run the formerly offending statements afterwards.
Compact constructors shouldn't contain such statements in the first place, because they expose the instance with every field still null or 0. If one does and the statement can't simply go, switch it to the non-compact form.
record Range(int from, int to) {
Range {
validate(); // error on JDK 28 with preview: uses `this` too early
}
void validate() {
if (from > to) throw new IllegalArgumentException();
}
}Here the fix is to check the parameters directly (if (from > to) …) instead of calling an instance method.
How to test it in CI
OpenJDK's suggestion is to run your suite twice against JDK 28: once with the default setup, to catch regressions that shouldn't exist, and once with preview enabled, to see what would break once value classes are final. In Maven, the preview profile needs the flag for both compilation and tests:
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<release>28</release>
<compilerArgs>
<arg>--enable-preview</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>--enable-preview</argLine>
</configuration>
</plugin>In Gradle, add --enable-preview to options.compilerArgs on JavaCompile tasks and to jvmArgs on Test tasks.
To validate the result:
- Look for
synchronizedon wrappers,Optionalorjava.timetypes, and forWeakHashMaporWeakReferencekeyed by them. - Review
==comparisons between objects of those classes: with preview on they may start returningtrue. - Check that your serialization, mocking and injection libraries don't call
setAccessibleon their fields. - Recompile with preview and fix the record constructors that fail.
- If something breaks that fits none of the above, it's a JDK bug, and OpenJDK wants it on the valhalla-dev mailing list.
What it means
Few JDK previews change the behavior of code nobody has touched; this one does it with types that show up in almost every Java application. Being a preview leaves room: nothing changes in production until you flip the flag. But the time to discover a synchronized on an Integer is now, in a separate CI job, not when the feature stops being a preview.
Sources
- Quality Outreach Heads-up - JDK 28: Value Classes, Inside Java, October 27, 2026.