Java 基础体系 · 第 41/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java 25 Record 完整指南:值对象、紧凑构造器、验证和序列化
record 是 Java 为“主要由数据组成的类型”提供的专用类声明。它不是把几个字段自动加上 getter 的语法糖,而是一种具有明确语义的类:组件(component)构成对象的公开状态,编译器据此生成访问器、构造器、equals、hashCode 和 toString。
在 Java 25 中,Record 已经是标准语言能力,并不是预览特性。理解 Record,需要同时回答几个问题:
- Record 的组件、字段、访问器和规范构造器分别是什么?
- 它为什么适合表示值对象,又为什么不自动提供深度不可变性?
- 紧凑构造器如何进行校验和规范化?
- 记录类型的继承、方法重写、泛型、数组和集合有哪些边界?
- Record 实现
Serializable后,序列化和反序列化到底如何工作? - 失败表现、兼容性和生产使用中的风险是什么?
一、Record 到底声明了什么
最简单的 Record:
public record Point(int x, int y) {
}
这条声明不是“只有两个字段的类”,而是定义了一个具有两个记录组件的记录类:
- 组件
x,类型为int - 组件
y,类型为int
编译器会为它提供等价于以下行为:
public final class Point extends java.lang.Record {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int x() {
return x;
}
public int y() {
return y;
}
// 基于 x 和 y 生成 equals
// 基于 x 和 y 生成 hashCode
// 基于 x 和 y 生成 toString
}
上面是用于理解的近似代码,不是 Record 的字面编译结果。实际实现还涉及 java.lang.Record、泛型签名、注解传播和编译器生成的方法。
1. 组件不是 JavaBean 属性
Record 的访问器名称是组件名本身:
Point point = new Point(10, 20);
System.out.println(point.x()); // 10
System.out.println(point.y()); // 20
它不会自动生成:
point.getX();
point.getY();
因此,依赖 JavaBean 约定的框架、表达式引擎或序列化工具,不能仅因为一个类型是 Record 就假设它具有 getX() 方法。
2. Record 是隐式 final 的类
记录类不能被继承:
public record Point(int x, int y) {
}
// 编译错误:不能继承 Point
// class ColoredPoint extends Point { }
Record 也不能显式继承其他类。它的直接父类固定为 java.lang.Record;但它可以实现接口:
public record UserId(String value) implements Comparable<UserId> {
@Override
public int compareTo(UserId other) {
return value.compareTo(other.value);
}
}
Record 不能声明实例字段,也不能声明实例初始化块:
public record Example(int value) {
// 编译错误:记录类不能声明额外的实例字段
// private final int cached;
// 编译错误:记录类不能声明实例初始化块
// { ... }
private static final int CONSTANT = 1; // 静态字段可以
}
限制的原因是:Record 的实例状态必须由头部列出的组件完整描述。若允许额外实例字段,equals、hashCode 和构造过程就可能无法反映对象的全部可观察状态。
二、Record 与值对象的关系
1. 什么是值对象
值对象(value object)通常具有以下语义:
- 对象的身份不重要,状态决定对象是否相等;
- 两个对象只要包含相同的值,就可以视为相等;
- 对象创建后状态不再改变;
- 对象通常不需要独立的生命周期或数据库身份。
例如,一个二维坐标:
Point p1 = new Point(3, 4);
Point p2 = new Point(3, 4);
System.out.println(p1.equals(p2)); // true
System.out.println(p1 == p2); // false
p1 和 p2 是两个不同的对象实例,但它们的组件值相同,因此生成的 equals 将它们视为相等。
这和实体对象不同:
class Order {
private final long id;
Order(long id) {
this.id = id;
}
}
一个订单实体即使其他属性相同,通常仍通过数据库 ID 或领域身份区分;而坐标、金额、日期范围、邮箱地址等类型更接近值对象。
2. Record 提供的是浅层值语义
Record 的值语义只作用于组件本身,并不自动递归冻结组件引用。
public record Basket(List<String> items) {
}
如果传入的是可变列表:
List<String> source = new ArrayList<>();
source.add("book");
Basket basket = new Basket(source);
source.add("pen");
System.out.println(basket.items()); // [book, pen]
Record 自身没有变化,但它内部引用的 List 发生了变化。因此,final 只保证引用不能重新指向其他列表,不保证列表内容不变。
正确的边界通常是复制并固定集合:
public record Basket(List<String> items) {
public Basket {
items = List.copyOf(items);
}
}
此时:
List<String> source = new ArrayList<>();
source.add("book");
Basket basket = new Basket(source);
source.add("pen");
System.out.println(basket.items()); // [book]
List.copyOf 还会拒绝 null 元素,并返回不可修改的列表。这里的不可修改是集合层面的保证;如果列表元素本身是可变对象,元素仍然可能改变。
3. 数组是 Record 中容易误判的边界
数组是可变对象,而且数组的 equals 和 hashCode 默认仍然是引用语义:
public record Packet(byte[] payload) {
}
Packet a = new Packet(new byte[] {1, 2});
Packet b = new Packet(new byte[] {1, 2});
System.out.println(a.equals(b)); // false
虽然两个数组内容相同,但 byte[] 的 equals 不会比较元素。
即使在构造器中复制数组,Record 自动生成的 equals 仍然不会自动进行深度比较。若要把数组作为值,需要显式定义语义:
import java.util.Arrays;
public record Packet(byte[] payload) {
public Packet {
payload = payload.clone();
}
@Override
public byte[] payload() {
return payload.clone();
}
@Override
public boolean equals(Object other) {
return other instanceof Packet p
&& Arrays.equals(payload, p.payload);
}
@Override
public int hashCode() {
return Arrays.hashCode(payload);
}
}
这里有两个独立问题:
- 构造时复制,防止调用者通过原数组修改内部状态;
- 访问时复制,防止调用者通过返回值修改内部状态;
- 重写
equals和hashCode,使数组内容具有值语义。
不能只完成其中一项。
三、Record 自动生成的方法
对于:
public record User(String name, int age) {
}
编译器会生成以下核心成员。
1. 规范构造器
构造器参数与组件一一对应:
User user = new User("Alice", 30);
这个构造器称为规范构造器(canonical constructor)。它负责为所有组件初始化对应的最终字段。
2. 访问器方法
访问器名称和组件名称相同:
user.name();
user.age();
可以覆盖访问器,但返回类型必须与组件类型兼容,并且访问器不能带参数:
public record Celsius(double value) {
@Override
public double value() {
return Math.round(value * 10.0) / 10.0;
}
}
不过,覆盖访问器会改变调用者看到的值,而生成的 equals 和 hashCode 仍然基于内部组件字段。为了避免“访问器返回的值”和“相等性使用的值”不一致,通常应在构造阶段完成规范化,而不是只在访问器中修改表现。
3. equals、hashCode 和 toString
Record 的 equals 和 hashCode 按组件进行比较和计算:
public record User(String name, int age) {
}
User a = new User("Alice", 30);
User b = new User("Alice", 30);
System.out.println(a.equals(b)); // true
System.out.println(a.hashCode() == b.hashCode()); // true
System.out.println(a); // User[name=Alice, age=30]
生成的 equals 还要求对象属于同一个记录类;它不是任意“字段结构相同”的类型都能相等。
Record 的自动方法不等于深度比较:
- 引用组件通常使用对象的
equals; - 数组组件仍有数组自身的引用比较问题;
- 嵌套的可变对象仍然可以改变。
四、紧凑构造器:Record 的核心校验机制
1. 什么是紧凑构造器
如果使用普通构造器,需要重复写参数列表和字段赋值:
public record Age(int value) {
public Age(int value) {
if (value < 0 || value > 150) {
throw new IllegalArgumentException("age out of range");
}
this.value = value;
}
}
Record 可以使用紧凑构造器:
public record Age(int value) {
public Age {
if (value < 0 || value > 150) {
throw new IllegalArgumentException("age out of range");
}
}
}
紧凑构造器省略了参数列表和显式字段赋值。编译器会在构造器主体正常结束后,隐式执行组件字段初始化:
this.value = value;
因此,紧凑构造器的执行逻辑可以理解为:
- 接收组件参数;
- 在构造器主体中校验或修改参数;
- 如果没有抛出异常,编译器将参数值写入对应的 Record 字段。
2. 参数规范化必须修改参数,而不是字段
紧凑构造器中可以重新赋值给参数:
public record Email(String value) {
public Email {
if (value == null) {
throw new NullPointerException("value");
}
value = value.trim().toLowerCase(java.util.Locale.ROOT);
if (!value.contains("@")) {
throw new IllegalArgumentException("invalid email");
}
}
}
这里的因果关系是:
调用者传入原始 value
↓
紧凑构造器校验
↓
value 被规范化
↓
隐式执行 this.value = value
所以:
Email email = new Email(" Alice@Example.COM ");
System.out.println(email.value()); // alice@example.com
紧凑构造器中不能直接给记录字段赋值:
public record Age(int value) {
public Age {
// 编译错误:不能显式给记录组件字段赋值
// this.value = value;
}
}
这不是遗漏,而是紧凑构造器语法的设计约束:字段赋值由编译器统一插入,开发者应通过修改参数控制最终值。
紧凑构造器也不能使用显式构造器调用:
public record Example(int value) {
public Example {
// 编译错误:
// this(0);
}
}
如果需要额外构造入口,可以定义重载构造器,并由它调用规范构造器:
public record Point(int x, int y) {
public Point {
if (x < 0 || y < 0) {
throw new IllegalArgumentException("coordinates must be non-negative");
}
}
public Point(int value) {
this(value, value);
}
}
调用链为:
new Point(5)
→ Point(int value)
→ this(5, 5)
→ 规范构造器校验
→ 初始化 x=5, y=5
3. 公共 Record 的规范构造器必须足够可见
如果 Record 声明为 public,规范构造器也必须能被需要的调用方访问。紧凑构造器可以显式声明访问级别:
public record UserId(String value) {
public UserId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("blank user id");
}
}
}
对于公开 API,通常应显式写出 public,让构造器契约清晰可见。
五、验证、规范化与不变量
1. 验证和规范化不是同一件事
验证判断输入是否允许:
if (amount < 0) {
throw new IllegalArgumentException("amount must not be negative");
}
规范化把多个等价输入转换为唯一表示:
currency = currency.toUpperCase(Locale.ROOT);
完整值对象通常按以下顺序处理:
- 检查不能接受的
null; - 检查格式和范围;
- 规范化表示;
- 复制可变输入;
- 让隐式赋值完成最终状态建立。
例如金额:
import java.math.BigDecimal;
import java.util.Currency;
import java.util.Objects;
public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(currency, "currency");
if (amount.scale() > 2) {
throw new IllegalArgumentException("amount supports at most 2 decimals");
}
amount = amount.setScale(2);
}
}
这里要注意:BigDecimal 的 equals 同时比较数值和 scale:
new BigDecimal("1.0").equals(new BigDecimal("1.00")); // false
如果业务把它们视为同一金额,就必须在构造阶段统一 scale;否则 Money 的相等性会继承 BigDecimal 的精确语义。
2. 构造失败时不会产生半初始化的合法对象
如果紧凑构造器抛出异常,规范构造器就不会完成,调用者不会得到一个“部分初始化”的 Record:
public record Port(int value) {
public Port {
if (value < 1 || value > 65535) {
throw new IllegalArgumentException("invalid port");
}
}
}
try {
new Port(70000);
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage()); // invalid port
}
Record 的校验应放在对象边界处,因为对象一旦构造成功,其他方法就可以依赖不变量:
public record DateRange(java.time.LocalDate start,
java.time.LocalDate end) {
public DateRange {
Objects.requireNonNull(start, "start");
Objects.requireNonNull(end, "end");
if (start.isAfter(end)) {
throw new IllegalArgumentException("start must not be after end");
}
}
}
之后 start <= end 就是这个类型成立的前提,而不是每个调用方都必须重复检查的条件。
3. 不能把校验误认为安全边界
Record 构造器校验的是对象状态,不是所有外部安全问题的解决方案。例如:
- 校验字符串非空,不等于防止 SQL 注入;
- 校验路径格式,不等于阻止路径穿越;
- 校验反序列化对象字段,不等于允许任意类被反序列化;
- 校验金额非负,不等于完成权限和业务授权。
Record 构造器适合维护类型不变量;权限、协议、输入源可信度和资源访问仍需在对应边界处理。
六、Record 的继承、接口和方法设计
Record 不能继承已有实现类,但可以实现接口:
public interface Named {
String name();
}
public record Product(String name, long price) implements Named {
}
因为 Record 已经自动提供了 name(),它满足接口契约。
也可以在 Record 中定义领域行为:
public record Rectangle(double width, double height) {
public Rectangle {
if (!(width > 0) || !(height > 0)) {
throw new IllegalArgumentException("dimensions must be positive");
}
}
public double area() {
return width * height;
}
public boolean isSquare() {
return Double.compare(width, height) == 0;
}
}
Record 不是只能承载数据的“哑 DTO”。它可以包含:
- 实例方法;
- 静态方法;
- 重写接口方法;
- 重载构造器;
- 静态字段;
- 嵌套类型。
但它不能通过继承复用另一个类的实例状态。若多个类型共享复杂行为,应优先考虑接口、组合或独立服务,而不是试图让 Record 模拟可继承实体类。
七、泛型 Record 与类型安全
Record 可以是泛型类型:
public record Pair<A, B>(A first, B second) {
}
使用时:
Pair<String, Integer> result = new Pair<>("status", 200);
System.out.println(result.first()); // status
System.out.println(result.second()); // 200
泛型只在编译期提供类型约束,运行时仍受类型擦除影响。Record 的 equals、访问器和构造器也按照擦除后的 JVM 类型工作。
可以在紧凑构造器中校验泛型组件的引用:
import java.util.Objects;
public record NonNullPair<A, B>(A first, B second) {
public NonNullPair {
Objects.requireNonNull(first, "first");
Objects.requireNonNull(second, "second");
}
}
但不能在运行时直接检查 A 的具体参数化类型,例如不能可靠地写出“必须是 List<String>”这种检查,因为 String 类型参数会被擦除。应在调用边界或使用显式类型令牌时完成此类检查。
八、Record Pattern:读取组件的前置工具
Java 25 中,Record 可以与模式匹配配合使用。Record pattern 用于按组件结构解构对象:
public record Point(int x, int y) {
}
static String quadrant(Point point) {
return switch (point) {
case Point(int x, int y) when x > 0 && y > 0 -> "第一象限";
case Point(int x, int y) when x < 0 && y > 0 -> "第二象限";
case Point(int x, int y) when x < 0 && y < 0 -> "第三象限";
case Point(int x, int y) when x > 0 && y < 0 -> "第四象限";
default -> "坐标轴上或原点";
};
}
处理过程可以分为两步:
Point(int x, int y)检查对象是否是该 Record,并提取两个组件;when条件对提取出的局部变量进行进一步判断。
这与手动调用 point.x() 和 point.y() 的结果类似,但在嵌套数据结构中更清晰:
public record Address(String city) {
}
public record User(String name, Address address) {
}
static String cityOf(User user) {
return switch (user) {
case User(String name, Address(String city)) -> name + "@" + city;
};
}
Record pattern 依赖 Record 的组件定义,因此组件顺序和类型属于结构契约。修改 Record 组件会同时影响构造器、访问器、模式匹配、序列化形式和调用方编译结果。
九、Record 的序列化条件
Record 不会因为声明为 record 就自动支持 Java 原生序列化。必须显式实现 java.io.Serializable:
import java.io.Serializable;
public record UserSnapshot(String username, int loginCount)
implements Serializable {
}
实现 Serializable 是一个标记接口,不要求实现方法,但它会让对象进入 Java Object Serialization 机制。
一个端到端示例:
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
public class RecordSerializationDemo {
public record UserSnapshot(String username, int loginCount)
implements Serializable {
private static final long serialVersionUID = 1L;
public UserSnapshot {
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("username is blank");
}
if (loginCount < 0) {
throw new IllegalArgumentException("loginCount is negative");
}
}
}
public static void main(String[] args)
throws IOException, ClassNotFoundException {
UserSnapshot original = new UserSnapshot("alice", 3);
byte[] bytes;
try (ByteArrayOutputStream buffer = new ByteArrayOutputStream();
ObjectOutputStream output = new ObjectOutputStream(buffer)) {
output.writeObject(original);
output.flush();
bytes = buffer.toByteArray();
}
UserSnapshot restored;
try (ObjectInputStream input = new ObjectInputStream(
new ByteArrayInputStream(bytes))) {
restored = (UserSnapshot) input.readObject();
}
System.out.println(restored); // UserSnapshot[username=alice, loginCount=3]
System.out.println(original.equals(restored)); // true
}
}
在 Java 25 环境中编译运行:
javac RecordSerializationDemo.java
java RecordSerializationDemo
预期输出:
UserSnapshot[username=alice, loginCount=3]
true
如果 UserSnapshot 的组件类型没有实现 Serializable,写出时会失败。例如:
public record Holder(Object value) implements Serializable {
}
当 value 实际引用的对象不可序列化时,writeObject 通常会抛出 NotSerializableException。
十、Record 的反序列化为什么仍会执行规范构造器
普通可序列化类的反序列化有一套特殊机制,通常不会像普通 new 一样执行该类的构造器。Record 是重要例外:反序列化 Record 时,会使用它的规范构造器创建实例。
因此,前面的验证仍然有效:
public record UserSnapshot(String username, int loginCount)
implements Serializable {
public UserSnapshot {
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("username is blank");
}
if (loginCount < 0) {
throw new IllegalArgumentException("loginCount is negative");
}
}
}
数据流可以概括为:
ObjectInputStream 读取序列化数据
↓
根据 Record 的组件恢复 username、loginCount
↓
调用规范构造器
↓
执行校验和规范化
↓
构造成功:得到 Record
构造失败:反序列化失败
这带来一个重要结果:Record 的规范构造器是反序列化后的不变量入口。若序列化数据已过期、不符合当前规则或被篡改,构造器可以拒绝它。
但是,构造器校验不能替代反序列化过滤。ObjectInputStream 仍可能在执行 Record 构造器之前处理类型描述、对象图和嵌套对象。来自不可信来源的数据不应直接使用原生 Java 反序列化;应配置 ObjectInputFilter,限制允许的类、数组长度、深度和引用数量,或者改用明确的数据格式和解析器。
十一、Record 序列化的特殊规则
1. 序列化状态由 Record 组件决定
Record 的实例状态由组件定义。与普通可序列化类相比,不能依赖额外的可序列化实例字段来保存隐藏状态,因为 Record 本身不允许额外实例字段。
例如:
public record Session(String userId, long expiresAt)
implements Serializable {
private static final long serialVersionUID = 1L;
}
序列化数据的核心状态就是 userId 和 expiresAt。静态字段不是每个实例的状态,不会随对象实例序列化。
2. 自定义 writeObject 和 readObject 不能改变 Record 的基本恢复方式
Record 序列化有专门规则。普通类中常见的以下实例方法不能用来改变 Record 的组件恢复过程:
private void writeObject(ObjectOutputStream out) { }
private void readObject(ObjectInputStream in) { }
private void readObjectNoData() { }
Record 不能依赖这些方法绕过规范构造器,或自行恢复一套与组件不同的实例状态。
writeReplace 和 readResolve 属于替换机制,可以参与对象替换,但它们不应被误解为“修改 Record 内部字段的反序列化钩子”。如果确实需要完全自定义序列化协议,应考虑使用序列化代理、显式 DTO 或其他格式,而不是试图把普通可序列化类的技巧直接搬到 Record 上。
3. serialVersionUID 仍有作用,但不能解决所有兼容性
可以声明:
private static final long serialVersionUID = 1L;
它用于控制 Java 原生序列化的版本兼容判断。但它不能解决以下问题:
- 删除或重命名组件后的业务语义变化;
- 组件类型变化;
- 旧数据不满足新构造器不变量;
- 嵌套组件类不再兼容;
- 反序列化数据来自不可信来源。
Record 头部本身是 API 契约的一部分:
public record User(String name, int age) {
}
若改为:
public record User(String name, int age, String department) {
}
调用方的构造代码会直接编译失败,序列化结构也会发生变化。Record 适合在边界清晰、演进策略明确的数据模型中使用,不适合无规划地替代所有长期持久化实体。
十二、序列化与不变量的完整失败路径
下面的 Record 将邮箱规范化,并在反序列化时继续检查:
import java.io.Serializable;
import java.util.Locale;
import java.util.Objects;
public record EmailAddress(String value) implements Serializable {
private static final long serialVersionUID = 1L;
public EmailAddress {
Objects.requireNonNull(value, "value");
value = value.trim().toLowerCase(Locale.ROOT);
if (!value.contains("@")) {
throw new IllegalArgumentException("invalid email");
}
}
}
正常构造:
EmailAddress email = new EmailAddress(" Alice@Example.COM ");
System.out.println(email.value()); // alice@example.com
如果读取的序列化数据包含不符合当前规则的值,规范构造器抛出异常,ObjectInputStream.readObject() 不会返回一个不合法的 EmailAddress。调用方应将反序列化失败视为输入失败,而不是继续使用一个可能不完整的对象:
try (ObjectInputStream input = new ObjectInputStream(source)) {
Object value = input.readObject();
if (!(value instanceof EmailAddress email)) {
throw new IOException("unexpected serialized type");
}
use(email);
} catch (ClassNotFoundException | IOException e) {
// 记录数据来源、协议版本和失败原因
// 不应把失败对象继续传递到业务逻辑
}
实际异常可能经过序列化层包装,因此诊断时应检查异常链,而不是只读取最外层消息。
十三、常见误解与失败表现
误解一:Record 自动保证深度不可变
错误:
public record Config(Map<String, List<String>> values) {
}
这只保证 Config.values 引用不能被 Record 自身重新赋值。调用者仍可能修改 Map、List 以及其中元素。
如果值对象要求不可变,应在构造边界复制整个结构:
public record Config(Map<String, List<String>> values) {
public Config {
values = values.entrySet().stream()
.collect(java.util.stream.Collectors.toUnmodifiableMap(
Map.Entry::getKey,
entry -> List.copyOf(entry.getValue())
));
}
}
还要确认 key 和 value 元素自身是否可变。
误解二:Record 的 equals 自动对数组做深度比较
错误:
new Packet(new byte[] {1}).equals(new Packet(new byte[] {1}));
结果通常是 false,因为数组继承的是 Object.equals 语义。需要使用 Arrays.equals 和 Arrays.hashCode,或者把数组转换成适合值语义的不可变表示。
误解三:紧凑构造器中的参数只是普通局部变量
参数虽然可以修改,但它们会决定隐式字段赋值:
public record Name(String value) {
public Name {
value = value.trim();
}
}
这里最终字段保存的是修剪后的值,而不是原始参数值。若开发者以为修改参数不会影响字段,就会得到错误的规范化结果。
误解四:实现 Serializable 就意味着可以安全传输
Serializable 只表示对象可以进入 Java 原生序列化机制,不表示:
- 数据可信;
- 类版本自动兼容;
- 反序列化没有资源风险;
- 构造器能修复所有攻击;
- 其他 JVM 或其他语言可以读取。
生产系统若需要跨服务、跨语言或长期保存,通常应使用明确的协议格式,并显式定义字段兼容、默认值和版本迁移规则。
误解五:Record 适合所有实体类
Record 的组件一旦声明,就同时影响:
- 构造器;
- 访问器;
- 相等性;
hashCode;- 模式匹配;
- 序列化结构;
- 调用方源代码。
数据库实体通常需要可变生命周期、延迟加载、代理、独立身份或框架要求的无参构造器,这些特征与 Record 的设计目标并不天然一致。Record 更适合不可变请求、响应、配置片段、坐标、范围、标识和值对象。
十四、Record 与普通类的选择依据
可以用以下问题判断一个类型是否适合 Record:
适合 Record 的情况
public record Coordinate(double latitude, double longitude) {
}
这个类型的主要语义就是两个坐标值,通常不需要继承、隐藏实例状态或延迟修改。
也适合:
public record SearchPage<T>(
java.util.List<T> items,
int page,
int pageSize,
long total
) {
public SearchPage {
items = java.util.List.copyOf(items);
if (page < 0) {
throw new IllegalArgumentException("page must not be negative");
}
if (pageSize <= 0) {
throw new IllegalArgumentException("pageSize must be positive");
}
if (total < 0) {
throw new IllegalArgumentException("total must not be negative");
}
}
}
它表达的是一个固定的数据快照,并且可以在构造处集中维护约束。
更适合普通类的情况
如果类型需要以下能力,普通类往往更合适:
- 可变状态;
- 子类扩展;
- 额外的实例缓存字段;
- 与框架强绑定的构造和属性约定;
- 复杂的持久化生命周期;
- 与组件值不同的身份相等性;
- 需要隐藏大部分内部状态。
这不是 Record 功能不足,而是它刻意要求“对象状态由组件公开描述”。
十五、从 Java 25 编译和检查 Record
使用 Java 25 工具链时,可以直接编译:
javac --release 25 UserId.java
java UserId
检查生成的成员和 Record 元数据:
javap -p UserId.class
通常可以看到:
- 私有 final 组件字段;
- 规范构造器;
value()等访问器;equals;hashCode;toString;- 继承自
java.lang.Record。
运行环境也必须满足版本要求:
java -version
javac -version
编译器使用 Java 25,而运行时低于支持 Record 的版本时,程序不能正常运行。若库的公共 API 使用 Record,调用方不仅需要能解析类文件,还需要理解对应的构造器、访问器和序列化契约。
十六、核心结论
Record 的核心不是“少写几行 getter 和构造器”,而是把以下契约绑定到一个类型上:
组件声明
↓
规范构造器和对象不变量
↓
访问器
↓
基于组件的相等性和哈希值
↓
模式匹配结构
↓
可选的序列化状态
紧凑构造器通过“校验参数、规范化参数、让编译器隐式赋值”的方式建立合法状态。Record 适合值对象,但它只提供浅层不可变结构;集合、数组和嵌套对象仍然需要显式处理。实现 Serializable 后,Record 的反序列化会经过规范构造器,但这不等于原生序列化天然安全或长期兼容。
因此,一个可靠的 Java 25 Record 通常应满足明确条件:
- 组件能够描述对象的全部实例状态;
- 相等性确实应由组件值决定;
- 构造器能够建立并维护不变量;
- 可变组件已被复制或转换为合适的不可变表示;
- 数组等特殊组件的
equals和hashCode语义已经确认; - 序列化需求、版本演进和数据来源风险已经单独设计。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java 25 可见性与包:访问控制、封装、模块边界和依赖方向
- 下一篇:Java 25 sealed 类型与模式匹配:封闭层次、switch 穷尽和建模
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论