业务里用 IdentityHashMap<LocalDate, …> 按对象身份缓存日期时,在 JDK 28 打开 --enable-preview 后会踩坑:默认放入两个等值 LocalDate 得 size=2,预览下变成 size=1,后写入覆盖先写入。对包装类或 LocalDate 做 synchronized、WeakHashMap 放日期键,预览下直接抛 IdentityException。这是 JEP 401 值类预览改掉身份语义,不是 Compact Object Headers 那种对象头宽度变化。
谁会撞上
OpenJDK Quality Outreach 在 2026-10-27 发了 Heads-up: JDK 28 Value Classes:预览合入后,约 30 个平台类在打开 --enable-preview 时变成值类。包装类、Optional*、java.time 一批日期时间类都在名单里。
旧代码没改、甚至旧 javac 编过,只要运行时开了预览,身份相关写法就会变。下面对照跑在官方 Early-Access 构建 28-ea+21-1707、Spring Boot 4.1.2 上。探针用 --release 28 编一次,运行时只切换是否带 --enable-preview。
openjdk version "28-ea" 2027-03-23
OpenJDK Runtime Environment (build 28-ea+21-1707)
OpenJDK 64-Bit Server VM (build 28-ea+21-1707, mixed mode, sharing)
Spring Boot 4.1.2
JEP 401 状态是 Integrated,面向 JDK 28;当前仍是官方 EA,不是 GA。值类相关的严格字段初始化,之前在 JDK 28 EA 的 STRICT_INIT 里单独测过;本文只盯身份兼容断点。
现场:同日键从两条变成一条
典型写法是「按实例区分」的日期缓存:
LocalDate a = LocalDate.of(2026, 7, 31);
LocalDate b = LocalDate.of(2026, 7, 31);
Map<LocalDate, String> cache = new IdentityHashMap<>();
cache.put(a, "first");
cache.put(b, "second");
System.out.println("size=" + cache.size());
System.out.println("get(a)=" + cache.get(a));
Spring Boot 4.1.2 启动日志摘录:
# 默认(预览关闭)
boot.java.vm.version=28-ea+21-1707
LocalDate.isValue=false
LocalDate===false
Objects.hasIdentity(a)=true
IdentityHashMap.size=2
IdentityHashMap.get(a)=first
IdentityHashMap.get(b)=second
# --enable-preview
boot.java.vm.version=28-ea+21-1707
LocalDate.isValue=true
LocalDate===true
Objects.hasIdentity(a)=false
IdentityHashMap.size=1
IdentityHashMap.get(a)=second
IdentityHashMap.get(b)=second
默认 size=2、get(a)=first。预览 size=1、get(a)=second。等值键互相覆盖,缓存条目少了一半语义。
怎么确认已经踩中
JDK 28 上可以直接读开关位。预览下 LocalDate.class.isValue() 为 true,Objects.hasIdentity(a) 为 false,a == b 为 true。
同一套探针(编译时未开预览)的对照:
# 默认
LocalDate===false
Integer1000===false
Integer42===true
Optional===false
sync.Integer=ok
WeakHashMap.put.LocalDate=ok size=1
IdentityHashMap.size=2
# --enable-preview
LocalDate===true
Integer1000===true
Integer42===true
Optional===true
sync.Integer=java.lang.IdentityException: Cannot synchronize on an instance of value class java.lang.Integer
sync.Optional=java.lang.IdentityException: Cannot synchronize on an instance of value class java.util.Optional
sync.LocalDate=java.lang.IdentityException: Cannot synchronize on an instance of value class java.time.LocalDate
WeakHashMap.put.LocalDate=java.lang.IdentityException: java.time.LocalDate is not an identity class
WeakReference.LocalDate=java.lang.IdentityException: java.time.LocalDate is not an identity class
IdentityHashMap.size=1
Integer.valueOf(1000) 默认因超出常用缓存,== 为 false;预览变成 true。缓存区间内的 42 两种模式都是 true。
连带断点:同步、弱引用、反射
预览下对 Integer / Optional / LocalDate 做 synchronized,统一抛 IdentityException。WeakHashMap.put(LocalDate) 与 new WeakReference<>(LocalDate) 同样失败。
反射改 Integer.value,加上 --add-opens java.base/java.lang=ALL-UNNAMED 后:
# 默认
setAccessible=ok
setInt=ok value=99
# --enable-preview
setAccessible=ok
setInt=java.lang.IllegalAccessException: Can not set final int field java.lang.Integer.value to (int)99
默认还能改掉(会有 final field mutation 警告)。预览下 setAccessible 仍成功,setInt 直接 IllegalAccessException。
开预览编译时,record 紧凑构造里碰 this 也会挂:
record BadRecord(String name) {
BadRecord {
if (name == null) throw new IllegalArgumentException(this.toString());
}
}
error: reference to this may only appear after an explicit constructor invocation
if (name == null) throw new IllegalArgumentException(this.toString());
^
Outreach 说明:开预览编译时,record 构造默认走 early construction,不能在构造早期引用 this。
升级时怎么选
对照数字直接决定改法:
依赖「两个等值 LocalDate 在 IdentityHashMap 里各占一条」的代码,默认 size=2、预览 size=1。预览下 hasIdentity=false,实例区分保不住。若键语义其实是等值,换成普通 HashMap;若必须按身份区分,就不能再用这些变成值类的平台类型当键。
对 Integer / Optional / LocalDate 做同步,预览下抛 IdentityException。改成同步专用锁对象,别锁包装值或日期值。
WeakHashMap / WeakReference 挂日期键,预览下同样 IdentityException。弱引用键需要仍有身份的类型。
靠反射改 Integer.value 的代码,预览下 setInt 失败。opens 不够,这条路在预览模式走不通。
是否打开 --enable-preview,先用上述探针看 isValue / hasIdentity / IdentityHashMap.size。当前构建是官方 EA 28-ea+21-1707,不是 GA;预览语义仍可能随后续 EA 调整。
一起交流
分享你的思考,让讨论更进一步。