业务里用 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 调整。

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。