Java record 的规范构造器校验能否被反序列化或反射绕过?
结论
通过 Constructor::newInstance 调用构造器时,构造器中的校验会执行。标准 Java Serialization 反序列化 record 时也会调用规范构造器,不会跳过校验。
Unsafe.allocateInstance 可以绕过实例构造器。以 OpenJDK 21 的实现为例,它没有禁止分配 record 实例,因此题目中的 User 可以出现 name == null 的状态。
Jackson 默认反序列化 record 时通常会调用规范构造器,但具体路径取决于版本、注解和自定义配置。Java Serialization 的规范要求不能直接套用到所有第三方框架。
Unsafe 为什么能创建校验不通过的 record?
在 OpenJDK 21 中,sun.misc.Unsafe.allocateInstance 委托给内部 Unsafe,方法说明明确指出它不执行任何构造器。HotSpot 的分配路径会检查类能否实例化、完成必要的类初始化,然后分配对象,其中没有针对 record 的拒绝条件。因此,在这个实现中,record 不会仅因其类型而被拒绝。Unsafe 实现、HotSpot 分配实现
在已经取得可用 Unsafe 实例的前提下:
User user = (User) unsafe.allocateInstance(User.class);
System.out.println(user.name()); // 按上述实现,预期输出 null
构造器没有执行,name 保持引用字段的默认值 null,也不会触发 IllegalArgumentException。类初始化和实例初始化需要分开看:类初始化可能发生,但 User 的实例构造器没有运行。
这个结论只针对所引用的实现,不能保证其他 JVM 或未来 JDK 的行为相同。题目没有给出实际运行环境,因此这只是根据实现作出的推导,并非运行测试的结果。
这是否违反 Java Language Specification?
它破坏了示例自行定义的业务不变量:name 必须非空且不能全部为空白字符。
但不能因此认定 JVM 违反了 Java Language Specification。sun.misc.Unsafe 是 JDK 的非标准底层接口,正常 Java 构造路径的约束并不构成对这类接口的全面保证。
这里还涉及两种不同的“不变量”:构造器中的名字校验由应用自行定义;Record API 描述的复制不变量则要求,通过组件访问器取值并调用规范构造器得到的副本,应与原实例相等。用 new User(user.name()) 复制上述 Unsafe 分配的对象时,构造器会直接抛出异常,无法完成复制。Record API
普通反射能否跳过校验或修改字段?
通过 Constructor::newInstance 调用构造器时,构造器体照常执行。例如,在使用题目中 User 定义的方法内:
var constructor = User.class.getDeclaredConstructor(String.class);
try {
constructor.newInstance("");
} catch (java.lang.reflect.InvocationTargetException e) {
System.out.println(e.getCause());
// 原因是 IllegalArgumentException: Invalid name
}
反射会把构造器抛出的异常包装为 InvocationTargetException。调整访问权限不会删除构造器中的校验逻辑。Constructor.newInstance API
record 的组件字段还有额外保护:即使 setAccessible(true) 成功,也不能通过 Field.set 修改这些 final 字段。OpenJDK 21 的 sun.misc.Unsafe.objectFieldOffset(Field) 同样会拒绝 record 字段,抛出 UnsupportedOperationException。因此,即便分配出了字段为默认值的对象,也不能推断可以用常见的字段写入方式将其填充完整。Field API、Unsafe 字段偏移检查
Java Serialization 是否必须调用规范构造器?
必须。record 在标准 Java Serialization 中有专门的恢复规则,不能沿用普通 Serializable 类的构造器规则。
反序列化时,先恢复各个组件的值,再将这些值作为参数调用规范构造器。如果构造器抛出异常,反序列化会抛出 InvalidObjectException,并保留该异常作为原因。流中缺少的组件使用对应类型的默认值,然后照常接受构造器校验。Java Object Serialization Specification,第 3.1 节
题目中的 User 尚未实现 Serializable。如果要使用 ObjectOutputStream 和 ObjectInputStream,需要明确声明:
public record User(String name) implements java.io.Serializable {
public User {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Invalid name");
}
}
}
record 自己声明的 readObject、readObjectNoData 等方法会被忽略,不能用它们改成绕过规范构造器的恢复流程。writeReplace、readResolve 仍可参与对象替换;返回替换对象与恢复当前 record 实例是不同的步骤。序列化规范,第 1.13 节
Jackson 是否也使用 Unsafe?
不能因为某个框架对普通类使用过无构造器分配,就推断它对 record 也采用相同方式。
Jackson 2.12 引入了明确的 record 支持。以 Jackson Databind 2.18 的实现为例,它会识别 record,并查找规范构造器作为主要创建入口。对于题目中没有额外创建注解的 User,默认的 JSON 对象绑定会通过规范构造器完成。Jackson 2.12 发布说明、Jackson 创建入口选择实现
使用相应 Jackson 2.x 依赖时,可以在声明了 throws Exception 的方法中检查:
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
User valid = mapper.readValue("{\"name\":\"Alice\"}", User.class);
System.out.println(valid.name());
// 默认绑定路径会执行构造器校验,以下调用应反序列化失败。
mapper.readValue("{\"name\":\"\"}", User.class);
如果配置了 @JsonCreator 工厂方法或自定义反序列化器,应检查它实际返回对象的方式。普通工厂方法最终通过 new User(...) 创建实例时,仍然会执行规范构造器;自定义代码若主动使用底层分配接口,则需要单独分析。
排查时,先确认 JDK 和框架版本,再用 null、空字符串及纯空白字符串检查反序列化入口,同时查看异常原因链。保留规范构造器中的校验,并让自定义创建逻辑调用它,才能让各条正常创建路径使用同一套规则。
备注:内容仅供参考。
