Java 基础体系 · 第 41/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 25 Record 完整指南:值对象、紧凑构造器、验证和序列化

record 是 Java 为“主要由数据组成的类型”提供的专用类声明。它不是把几个字段自动加上 getter 的语法糖,而是一种具有明确语义的类:组件(component)构成对象的公开状态,编译器据此生成访问器、构造器、equalshashCodetoString

在 Java 25 中,Record 已经是标准语言能力,并不是预览特性。理解 Record,需要同时回答几个问题:

  1. Record 的组件、字段、访问器和规范构造器分别是什么?
  2. 它为什么适合表示值对象,又为什么不自动提供深度不可变性?
  3. 紧凑构造器如何进行校验和规范化?
  4. 记录类型的继承、方法重写、泛型、数组和集合有哪些边界?
  5. Record 实现 Serializable 后,序列化和反序列化到底如何工作?
  6. 失败表现、兼容性和生产使用中的风险是什么?

一、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 的实例状态必须由头部列出的组件完整描述。若允许额外实例字段,equalshashCode 和构造过程就可能无法反映对象的全部可观察状态。


二、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

p1p2 是两个不同的对象实例,但它们的组件值相同,因此生成的 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 中容易误判的边界

数组是可变对象,而且数组的 equalshashCode 默认仍然是引用语义:

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);
    }
}

这里有两个独立问题:

  1. 构造时复制,防止调用者通过原数组修改内部状态;
  2. 访问时复制,防止调用者通过返回值修改内部状态;
  3. 重写 equalshashCode,使数组内容具有值语义。

不能只完成其中一项。


三、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;
    }
}

不过,覆盖访问器会改变调用者看到的值,而生成的 equalshashCode 仍然基于内部组件字段。为了避免“访问器返回的值”和“相等性使用的值”不一致,通常应在构造阶段完成规范化,而不是只在访问器中修改表现。

3. equalshashCodetoString

Record 的 equalshashCode 按组件进行比较和计算:

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;

因此,紧凑构造器的执行逻辑可以理解为:

  1. 接收组件参数;
  2. 在构造器主体中校验或修改参数;
  3. 如果没有抛出异常,编译器将参数值写入对应的 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);

完整值对象通常按以下顺序处理:

  1. 检查不能接受的 null
  2. 检查格式和范围;
  3. 规范化表示;
  4. 复制可变输入;
  5. 让隐式赋值完成最终状态建立。

例如金额:

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);
    }
}

这里要注意:BigDecimalequals 同时比较数值和 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 -> "坐标轴上或原点";
    };
}

处理过程可以分为两步:

  1. Point(int x, int y) 检查对象是否是该 Record,并提取两个组件;
  2. 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;
}

序列化数据的核心状态就是 userIdexpiresAt。静态字段不是每个实例的状态,不会随对象实例序列化。

2. 自定义 writeObjectreadObject 不能改变 Record 的基本恢复方式

Record 序列化有专门规则。普通类中常见的以下实例方法不能用来改变 Record 的组件恢复过程:

private void writeObject(ObjectOutputStream out) { }
private void readObject(ObjectInputStream in) { }
private void readObjectNoData() { }

Record 不能依赖这些方法绕过规范构造器,或自行恢复一套与组件不同的实例状态。

writeReplacereadResolve 属于替换机制,可以参与对象替换,但它们不应被误解为“修改 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.equalsArrays.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 通常应满足明确条件:

  • 组件能够描述对象的全部实例状态;
  • 相等性确实应由组件值决定;
  • 构造器能够建立并维护不变量;
  • 可变组件已被复制或转换为合适的不可变表示;
  • 数组等特殊组件的 equalshashCode 语义已经确认;
  • 序列化需求、版本演进和数据来源风险已经单独设计。

系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。