Jorge Turbica / blog · Blog ← All articles ES · Leer en español
Languages

JDK 28 preview strips identity from Integer and LocalDate: == changes, your code doesn’t

JEP 401 turns 30 JDK classes into value classes under --enable-preview: ==, synchronized and WeakHashMap change. How to test it in CI.

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:

PackageClasses
java.langInteger, Long, Float, Double, Byte, Short, Character, Boolean, Number, Record
java.utilOptional, OptionalInt, OptionalLong, OptionalDouble
java.timeLocalDate, LocalTime, LocalDateTime, ZonedDateTime, OffsetTime, OffsetDateTime, Duration, Instant, Period, Year, YearMonth, MonthDay
java.time.chronoMinguoDate, 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 return true where it used to return false. Tests or caches that relied on two equal LocalDate objects being distinct will stop holding.
  • No monitor. You can't synchronize on a value object, and wait and notify are unsupported: any monitor interaction is a compile error or an IdentityException at runtime. synchronized (someInteger) is the classic case.
  • No weak references. The java.lang.ref APIs and java.util.WeakHashMap throw IdentityException for value objects. A cache built on WeakHashMap<Long, …> stops working.
  • Truly immutable. Calling setAccessible on their fields ends in an IllegalAccessException, 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:

  1. Remove the offending statement.
  2. Rewrite it so it works in an early construction context, which is very close to a static one.
  3. Assign all fields, call super() or this(...) 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.

java
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:

xml
<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 synchronized on wrappers, Optional or java.time types, and for WeakHashMap or WeakReference keyed by them.
  • Review == comparisons between objects of those classes: with preview on they may start returning true.
  • Check that your serialization, mocking and injection libraries don't call setAccessible on 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

Jorge Turbica

Software Engineer and SRE. I write about the real challenges I run into in production.

See portfolio ↗