Java Record 实战:用 record 关键字写出不可变数据类

Java Record 是 Java 16 正式转正的数据载体语法。当你只想用类来“装一组不可变数据”时,传统写法要手写构造器、getter、equals、hashCode、toString,样板代码又长又易错。Record 用一行 record Point(int x, int y) {} 自动生成这些成员,让“不可变数据类”成为 Java 的一等公民。

一、为什么需要 Record

一个“点坐标”“一笔交易”“一个配置项”这类纯数据对象,在 Java 里往往要写一大段样板:私有字段、全套 getter、构造器、equals、hashCode、toString。写起来枯燥,equals/hashCode 一旦手写错还会引发隐蔽 bug。本质上我们只关心“它有哪些数据”,而不是“怎么管这些数据”。

Java 16 把 Record 正式转正(从 JDK 14 起预览),专门解决“不可变数据载体”这件事。声明即契约,编译器替你生成全部样板。它和 Java 注解处理器驱动的 Lombok @Data 思路相似,但 Record 是语言原生能力,零依赖、零注解处理器。

二、Record 基础语法与自动生成成员

声明一个 Record 极其简单,组件(component)写在括号里:

// 声明即定义:x、y 是组件
public record Point(int x, int y) {
    // 无需手写任何字段与方法
}

编译器会为它自动生成:每个组件对应的私有 final 字段、与组件同名且无 get 前缀的访问器、一个全参数公有构造器,以及基于所有组件的 equals、hashCode 和按组件顺序的 toString。注意访问器叫 x()、y(),而不是 JavaBean 风格的 getX()。

2.1 自动生成的成员对照

成员生成规则
字段每个组件对应一个 private final 字段
访问器与组件同名方法 x(),无 get 前缀
构造器全参数公有构造器 Point(int x, int y)
equals/hashCode基于全部组件,语义清晰稳定
toString形如 Point[x=1, y=2]

三、紧凑构造器做参数校验

Record 的构造器有一个特殊形态——紧凑构造器(compact constructor)。它没有参数列表,直接作用于隐式参数,最适合做参数校验与规范化:

public record Range(int lo, int hi) {
    // 紧凑构造器:无参数列表,直接校验组件
    public Range {
        if (lo > hi) {
            throw new IllegalArgumentException("lo 不能大于 hi: " + lo + "," + hi);
        }
    }
}

注意:紧凑构造器里不能重新声明组件字段,也不能给 this 赋值,只能做前置检查,或把规范化后的值赋给同名的局部变量(Java 会把局部变量值写回组件)。这种方式保证了“对象一旦创建,约束必然成立”。

四、自定义构造器与委托

当需要一个额外入口(例如从“元”构造“分”)时,自定义构造器必须首先显式委托给规范构造器,这正是 Java 保证不可变性的关键约束:

public record Money(long cents) {
    // 自定义构造器:先委托规范构造器
    public Money(double yuan) {
        this(Math.round(yuan * 100));
    }
}

Money m = new Money(19.9);   // 内部转为 1990 分
System.out.println(m.cents()); // 1990

五、不可变性的边界

Record 的组件字段都是 final,创建后引用不能改。但这不等于“完全不可变”:如果某个组件是 List、数组或其他可变对象,容器内部的元素仍然可以被修改。

public record Bag(List<Integer> items) {}

var bag = new Bag(new ArrayList<>(List.of(1, 2)));
bag.items().add(3);          // 危险!外部仍能修改内部列表
System.out.println(bag.items()); // [1, 2, 3]

对策:在构造器里做防御性拷贝,或只放不可变类型。例如用 List.copyOf(items) 把入参转成不可变列表,再赋值给组件,彻底堵住后门。这也是《Effective Java》里反复强调的“可变内部状态要拷贝”原则在 Record 上的具体落地。

六、实现接口、泛型与嵌套

Record 可以实现接口、携带泛型,也能在内部嵌套,非常适合表达结构化数据:

// 泛型 + 实现接口
public record Pair<T>(T first, T second) implements Comparable<Pair<T>> {
    public int compareTo(Pair<T> o) {
        return ((Comparable<T>) first).compareTo(o.first);
    }
}

// 嵌套 Record 表达订单与明细
public record Order(long id, List<Item> items) {
    public record Item(long sku, int qty) {}
}

这里的泛型机制与 Java 泛型通配符的类型擦除 一脉相承;而嵌套 Record 常用于把“子结构”声明为静态成员,避免零散的 POJO 满天飞,也让 JSON 序列化时的层级结构一目了然。

七、与 JSON 框架、反射的配合

主流 JSON 库早已支持 Record:Jackson 2.12+、Gson 都能直接反序列化。Record 天然契合序列化场景(DTO、配置、接口返回值):

ObjectMapper mapper = new ObjectMapper();
Point p = mapper.readValue("{"x":3,"y":4}", Point.class);
String json = mapper.writeValueAsString(p);  // {"x":3,"y":4}

通过 Java 反射 读取组件元数据也很方便,常用于通用序列化、校验或测试框架:

for (var c : Point.class.getRecordComponents()) {
    System.out.println(c.getName() + " -> " + c.getType());
}
// 输出:x -> int
//       y -> int

八、Record 与 Lombok @Data 的差异

两者都能减少样板,但定位不同。Record 是“不可变数据载体”的官方答案,Lombok 是“可定制样板生成器”:

维度Java RecordLombok @Data
不可变性字段 final,天然不可变默认可变,需 @Value 才不可变
依赖语言原生,零依赖需引入注解处理器
equals/hashCode基于所有组件,语义固定可定制,但易踩坑
继承隐式 final,不能继承/被继承普通类,可继承
适用场景纯数据载体、DTO需要行为/可变性/混合的实体

九、常见坑与排障

坑对策
想给 Record 加可变 setter不要加;换普通类或新建 Record
组件过多(>5 个)拆成更小的值对象,提升可读性
组件是可变容器(List/数组)构造器里 List.copyOf 防御性拷贝
老版本 JSON 库不识别升级 Jackson/Gson 或注册 Record 模块
与 JPA Entity 混用不建议;Entity 需要可变与无参构造

十、落地建议:何时该用 Record

结合 Java 函数式编程的不可变思想,Record 在以下场景是首选:

  • 优先用 Record:方法返回值、DTO、配置项、事件/消息载体、模式匹配(Java 21 switch 模式)的判别联合。
  • 用普通类:JPA Entity、需要 setter 可变状态、含复杂业务行为的领域对象。
  • 组合使用:Record 与 Stream 收集、嵌套结构配合极佳,能把“数据形状”表达得非常干净。

十一、Record 与 sealed:表达判别联合

Java 17 的 sealed 接口与 Record 是表达“判别联合(discriminated union)/代数数据类型”的黄金组合。例如一个表达式树或消息类型,用 sealed interface 约束子类型,再用 Record 实现每个分支,配合 Java 21 的 switch 模式匹配就能写出穷尽且安全的分支处理:

public sealed interface Expr permits Expr.Num, Expr.Add, Expr.Mul {
    record Num(int v) implements Expr {}
    record Add(Expr l, Expr r) implements Expr {}
    record Mul(Expr l, Expr r) implements Expr {}
}

// Java 21:switch 模式匹配,编译器保证 case 穷尽
int eval(Expr e) {
    return switch (e) {
        case Expr.Num n -> n.v();
        case Expr.Add a -> eval(a.l()) + eval(a.r());
        case Expr.Mul m -> eval(m.l()) * eval(m.r());
    };
}

这种写法把“可能的值”写进了类型系统,编译器保证 switch 的 case 穷尽;将来新增一个分支却忘了处理,会直接在编译期报错,而不是运行时遗漏。它比用 Object 加 instanceof 判断安全得多,也更易于阅读。

十二、性能与内存:Record 真的更省吗

Record 在字节码层面就是一个普通的 final 类,没有任何隐藏开销;它的 equals、hashCode 是编译器生成的普通方法,运行性能与手写版本等价。内存占用上,Record 并不会比等价的普通 POJO 更小——它省下的是“写样板的人肉成本”,而不是对象的字节数。

真正的收益在正确性与可维护性:不可变 + 值语义让对象在并发、缓存、集合键(HashMap key)场景下更安全,不会因为被意外修改而破坏 equals/hashCode 契约。如果你关心对象头、字段对齐与内存布局,应当去看 JVM 对象布局工具(JOL),而不是指望 Record 本身带来压缩。

十三、30 秒上手清单

  • 声明:record T(Type a, Type b) {},编译器自动生成全部样板。
  • 访问器叫 a() 不是 getA();想校验就用紧凑构造器。
  • 不变性有边界:组件是 List/数组时要防御性拷贝。
  • DTO/返回值用 Record,可变实体用普通类。
  • 序列化直接交给 Jackson/Gson,无需额外配置(新版)。

借助 Docker Compose 起一个本地 Java 21 环境,把上面的片段跑一遍,你就能直观感受到 Record 如何把“样板地狱”变成“一行声明”,并结合 sealed 与模式匹配写出既安全又优雅的代码。

上一篇 Logrotate 实战:日志自动轮转与切割压缩
下一篇 DNS 解析故障排查实战:超时、污染与本地缓存
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]