大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
Record 与不可变
这一课你将能做到
- 用一行声明定义带校验的 record DTO,并解释编译器自动生成了哪些成员
- 处理 record 的不可变边界:紧凑构造器校验、可变组件防御性拷贝、with 风格更新
- 判断什么场景该用 record、什么场景必须退回 class(JPA Entity、可变状态)
为什么要学
传统 DTO 一眼望去 80 行:字段、getter/setter、构造器、equals、hashCode、toString——全是机器能生成的噪音。record 把它压成一行,还顺手给了不可变保证:字段天然 final,作 Map key、跨线程传递都不会被半路篡改。Spring Boot 新代码里 record 已经是 Controller 入参出参、MyBatis 查询投影的默认选择。
面试角度:「record 的本质是什么」「record 能被继承吗」「record 和 Lombok @Value 的区别」「为什么 JPA Entity 不用 record」是 JDK 17+ 迁移必考题,本课按能背的标准给出答案。
先建立图像
一行声明:
public record LessonView(String slug, String title, int orderIndex) {}
编译器眼中它等价于:
public final class LessonView extends java.lang.Record {
private final String slug;
private final String title;
private final int orderIndex;
public LessonView(String slug, String title, int orderIndex) { /* 全参构造 */ }
public String slug() { return slug; } // 访问器不带 get 前缀
public String title() { return title; }
public int orderIndex() { return orderIndex; }
@Override public boolean equals(Object o) { /* 全组件参与判等 */ }
@Override public int hashCode() { /* 全组件参与 */ }
@Override public String toString() { /* LessonView[slug=..., title=..., orderIndex=...] */ }
}
注意三点:隐式 final(不能被继承)、访问器名与组件同名、没有 setter——不可变是一等公民。
跟着做
步骤 1:完整示例——紧凑构造器、静态工厂、with 风格
新建 RecordDemo.java,完整可运行:
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class RecordDemo {
/** DTO:一行声明 + 紧凑构造器校验 */
record LessonView(String slug, String title, int orderIndex) {
LessonView { // 紧凑构造器:在字段赋值前执行,直接改写/校验参数
if (slug == null || slug.isBlank())
throw new IllegalArgumentException("slug 不能为空");
if (orderIndex < 0)
throw new IllegalArgumentException("orderIndex 不能为负");
title = (title == null || title.isBlank()) ? slug : title;
}
/** 静态工厂:语义化创建入口 */
static LessonView draft(String slug) {
return new LessonView(slug, slug, 0);
}
/** 不可变对象的「修改」= 返回新实例 */
LessonView withOrder(int newIndex) {
return new LessonView(slug, title, newIndex);
}
}
public static void main(String[] args) {
LessonView v = new LessonView("jva-records", "Record 与不可变", 6);
System.out.println(v.slug()); // jva-records
System.out.println(v.withOrder(7)); // LessonView[slug=jva-records, title=Record 与不可变, orderIndex=7]
// 全组件参与判等 ⇒ 值语义
System.out.println(v.equals(new LessonView("jva-records", "Record 与不可变", 6))); // true
// 值相等 ⇒ 可安全作 Map key
Map<LessonView, String> cache = new HashMap<>();
cache.put(new LessonView("jva-oop", "OOP 进阶", 4), "cached");
System.out.println(cache.get(new LessonView("jva-oop", "OOP 进阶", 4))); // cached
System.out.println(LessonView.draft("jva-new"));
}
}
运行:java RecordDemo.java。
步骤 2:可变组件的防御性拷贝
record 组件如果是 List 这类可变类型,字段引用仍可被外部改动——必须在紧凑构造器里拷贝成不可变结构。把以下内容加入 RecordDemo(局部 record,JDK 16+ 支持):
public static void main(String[] args) {
record Tagged(String slug, List<String> tags) {
Tagged {
tags = List.copyOf(tags); // 拷贝为不可变列表,外部改不动
}
}
List<String> mutable = new ArrayList<>(List.of("java"));
Tagged t = new Tagged("x", mutable);
mutable.add("hack");
System.out.println(t.tags()); // [java],未被污染
}
(与 main 合并时注意:局部 record 声明放在原 main 语句之前即可。)
步骤 3(选做):JUnit 验证校验逻辑
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class LessonViewTest {
@Test
void blankSlugShouldBeRejected() {
assertThrows(IllegalArgumentException.class,
() -> new LessonView(" ", "t", 1));
}
@Test
void negativeOrderShouldBeRejected() {
assertThrows(IllegalArgumentException.class,
() -> new LessonView("s", "t", -1));
}
@Test
void withOrderShouldReturnNewInstance() {
LessonView v = LessonView.draft("jva-records");
assertNotSame(v, v.withOrder(9));
}
}
讲清楚要点
- record 的本质是「命名的元组」:隐式 final 类,继承
java.lang.Record,组件即 private final 字段,自动生成全参构造器、同名访问器、全组件参与的 equals/hashCode/toString。 - 紧凑构造器不是普通构造器:不写参数列表与赋值语句,编译器自动完成字段赋值;你在其中做校验、参数改写(如
title = ...直接给参数重新赋值)。 - 浅不可变:record 只保证组件引用不变,不保证引用对象不可变;List/数组组件必须
List.copyOf防御性拷贝,否则不可变性是假的。 - 值语义带来工程红利:全组件参与 equals/hashCode ⇒ 可安全作 Map key、进 Set、跨线程传递,无需担心哈希值漂移。
- record 与 JavaBean 不兼容:没有
getXxx()与无参构造器,老反射框架(部分老版本 Jackson、JSP EL)可能不适配;Jackson 2.12+ 与 Spring Boot 2.6+ 均已原生支持。 - 三类场景退回 class:需要可变状态(Entity 的懒加载代理)、需要继承层次、行为远多于数据。DTO、命令对象、领域事件、查询投影优先 record。
你可能会懵的地方
- record 能被继承吗?能实现接口吗?
不能被继承(隐式 final),且已继承
java.lang.Record,无法再 extends 别的类;可以实现接口、也可以实现接口的 default 方法。 - record 和 Lombok @Value 有什么区别? record 是语言级特性:访问器不带 get 前缀、语义上有独立的类型标识、IDE/工具链无插件依赖;@Value 是注解处理器在编译期生成代码,语义上仍是普通类。新代码优先 record,Lombok 用于 Entity/Builder 等场景互补。
- 为什么 JPA Entity 不用 record? JPA 需要无参构造器(反射实例化)、可变字段(懒加载代理、脏检查更新),record 全部不满足。Entity 用 class,查询投影/DTO 用 record,是标准搭配。
- record 能有静态字段和静态方法吗? 可以。实例字段只允许组件(防止偷偷加可变状态),但静态字段、静态工厂、实例方法都随意。
- 组件名能是 status 这种「敏感词」吗?
组件名不能与 Object 方法冲突(如叫
hashcode、tostring会编译错);status、class这类要注意,class是关键字不可用。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| record 组件是 List 却不拷贝 | 外部改 List,不可变性被击穿 | 紧凑构造器里 List.copyOf |
| 试图给 record 加 setter | 破坏不可变契约 | 「修改」用 with 风格返回新实例 |
| 给 record 声明实例字段 | 编译报错 | 需要更多状态就加组件,或改用 class |
| 把 record 当 JPA Entity | 无无参构造、final 字段,代理失效 | Entity 用 class,record 做 DTO/投影 |
| 访问器写成 getSlug() | 方法不存在,编译错 | record 访问器是 slug() |
卡点急救
- 紧凑构造器里赋值没生效:必须给「参数变量」重新赋值(如
title = ...),编译器会用最终参数值赋给字段;给this.title赋值反而编译不过。 - Jackson 序列化 record 报错:升级 Jackson/Spring Boot(2.6+ 原生支持),或检查是否还挂着老版本 jackson-databind。
- 两个「看起来相同」的 record equals 为 false:组件类型不同(int vs Integer)或某个组件没参与判等(自定义了 equals);record 的 equals 由编译器生成,默认全组件参与。
面试常问
- record 的本质是什么?自动生成了什么? 可背版:隐式 final 的不可变数据载体,继承 java.lang.Record;编译器生成 private final 组件字段、全参构造器、与组件同名的访问器(无 get 前缀)、全组件参与的 equals/hashCode/toString;支持紧凑构造器做参数校验与改写。
- record 和普通不可变 class 的取舍? record 一行声明、值语义、无样板,适合纯数据;需要继承、可变状态、复杂封装(如内部缓存字段)时用 class + final 字段手写。核心判断:这个类型是「数据」还是「行为主体」。
- 为什么 record 适合做 Map 的 key / 缓存键? 组件全 final,hashCode 在生命周期内稳定,equals 按全组件值判等;普通可变类作 key 时,字段一改哈希漂移,条目就「找不回来」了——这正是面试常考的「为什么 String 适合做 key」的同款逻辑。
自测三问
- record 自动生成的六个成员分别是什么?访问器命名有什么特点?
- 紧凑构造器与普通构造器的区别?参数改写怎么写?
- 为什么说「record 组件是 List 时不可变性是假的」?如何修复?
小结一句话
Record 用一行声明换来不可变、值语义、零样板;纯数据用 record,有行为有状态留给 class。