Record类型详解:不可变数据对象从POJO到Record的演进
Record类型详解:不可变数据对象从POJO到Record的演进
适读人群:Java中高级开发者、关注现代Java特性的后端工程师 | 阅读时长:约15分钟 | 文章类型:特性详解+迁移指南
开篇故事
做代码Review的时候,我看到这样一个POJO类:
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point)) return false;
Point point = (Point) o;
return x == point.x && y == point.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
@Override
public String toString() {
return "Point[x=" + x + ", y=" + y + "]";
}
}20行代码,表达了一件很简单的事:一个有x和y的不可变坐标点。
写这个类的同事说:"说实话,每次写这种类都很烦。"
我说:"你知道Java 16正式引入了Record类型吗?"
他说:"不知道。"
我打了一行:
record Point(int x, int y) {}他沉默了两秒,说:"就这一行?"
是的,就这一行。这两个版本在语义上等价,都是不可变的、有equals/hashCode/toString的数据对象。今天把Record类型讲透。
一、Record的基本语法
什么是Record
Record(记录类型)是Java 14预览、Java 16正式引入的一种新类型,专门用来表达不可变的数据载体。
record Person(String name, int age) {}这一行代码,编译器会自动生成:
- 两个private final字段:
name和age - 一个包含所有字段的构造函数(canonical constructor)
- 所有字段的访问方法:
name()和age()(注意:不是getName()而是name()) equals():比较所有字段hashCode():基于所有字段toString():格式为Person[name=张三, age=28]
访问方法命名
Record的访问方法名和字段名相同(没有get前缀),这是和普通POJO的区别:
Person person = new Person("张三", 28);
System.out.println(person.name()); // 张三(不是getName())
System.out.println(person.age()); // 28(不是getAge())二、核心原理深挖
Record的语义限制
Record的设计哲学是"只做数据载体",所以有一些限制:
- 所有字段默认是final:不能修改
- 不能继承其他类(隐式继承java.lang.Record)
- 不能声明实例字段(只能有record组件,也就是声明里的字段)
- 不能是抽象的(always final)
- 可以实现接口
- 可以有静态字段和方法
- 可以有实例方法(但只是计算,不能有状态)
Record的内部结构
Record vs 普通类 vs Lombok
// 普通POJO(20行)
public class PointPOJO { /* ... 20行 */ }
// Lombok(简洁但需要依赖)
@Value // @Value = @Getter + @FieldDefaults(final) + @AllArgsConstructor + @EqualsAndHashCode + @ToString
public class PointLombok {
int x;
int y;
}
// Record(Java 16+,语言内置,最简洁)
record PointRecord(int x, int y) {}Record比Lombok的优势:不需要额外依赖,是语言内置特性,IDE和编译器直接支持,反射也能正确识别。
三、完整代码实现
代码一:Record的各种特性演示
package com.laozhang.java16.record;
import java.util.Objects;
/**
* Java Record类型完整特性演示
*/
public class RecordFeaturesDemo {
// ===== 1. 最简单的Record =====
record Point(int x, int y) {}
// ===== 2. 带自定义校验的紧凑构造器 =====
record Range(int min, int max) {
// 紧凑构造器(compact constructor):不写参数列表,直接写校验逻辑
Range {
if (min > max) {
throw new IllegalArgumentException(
"min(" + min + ") must be <= max(" + max + ")");
}
}
}
// ===== 3. 带自定义方法的Record =====
record Money(long amount, String currency) {
// 自定义实例方法
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("货币不同,无法相加");
}
return new Money(this.amount + other.amount, this.currency);
}
public boolean isPositive() {
return amount > 0;
}
// 自定义toString
@Override
public String toString() {
return String.format("%s %.2f", currency, amount / 100.0);
}
}
// ===== 4. 实现接口的Record =====
interface Describable {
String describe();
}
record Product(String id, String name, long priceInCents) implements Describable {
// 常量(静态字段)
private static final long MAX_PRICE = 1_000_000_00L; // 100万元
// 校验
Product {
Objects.requireNonNull(id, "id不能为null");
Objects.requireNonNull(name, "name不能为null");
if (priceInCents < 0) throw new IllegalArgumentException("价格不能为负");
}
@Override
public String describe() {
return String.format("商品[%s] %s - ¥%.2f", id, name, priceInCents / 100.0);
}
// 静态工厂方法
public static Product free(String id, String name) {
return new Product(id, name, 0);
}
}
// ===== 5. 泛型Record =====
record Pair<A, B>(A first, B second) {
public static <A, B> Pair<A, B> of(A first, B second) {
return new Pair<>(first, second);
}
}
// ===== 6. 嵌套Record =====
record Address(String city, String street) {}
record Person(String name, int age, Address address) {}
// ===== 7. 覆盖自动生成的方法 =====
record Temperature(double value, String unit) {
// 覆盖equals:忽略小数点后两位以外的差异
@Override
public boolean equals(Object o) {
if (!(o instanceof Temperature t)) return false;
return unit.equals(t.unit) &&
Math.abs(value - t.value) < 0.01; // 精度比较
}
@Override
public int hashCode() {
// 与equals保持一致:四舍五入到小数点后2位参与hashCode
return Objects.hash(Math.round(value * 100), unit);
}
}
public static void main(String[] args) {
System.out.println("=== 1. 基本Point ===");
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
System.out.println(p1); // Point[x=1, y=2]
System.out.println(p1.x()); // 1(不是getX())
System.out.println(p1.equals(p2)); // true(自动生成equals)
System.out.println(p1 == p2); // false(不同对象)
System.out.println("\n=== 2. Range校验 ===");
Range r = new Range(1, 10);
System.out.println(r);
try {
new Range(10, 1); // min > max,抛异常
} catch (IllegalArgumentException e) {
System.out.println("校验失败: " + e.getMessage());
}
System.out.println("\n=== 3. Money计算 ===");
Money m1 = new Money(1000, "CNY"); // 10元
Money m2 = new Money(500, "CNY"); // 5元
System.out.println(m1.add(m2)); // CNY 15.00
System.out.println(m1.isPositive()); // true
System.out.println("\n=== 4. Product with interface ===");
Product product = new Product("P001", "手机", 299900); // 2999元
System.out.println(product.describe());
Product freeProduct = Product.free("P002", "赠品");
System.out.println(freeProduct.describe());
System.out.println("\n=== 5. 泛型Pair ===");
Pair<String, Integer> pair = Pair.of("age", 28);
System.out.println(pair); // Pair[first=age, second=28]
System.out.println("\n=== 6. 嵌套Record ===");
Person person = new Person("张三", 28, new Address("北京", "朝阳区"));
System.out.println(person);
System.out.println(person.address().city()); // 北京
System.out.println("\n=== 7. 覆盖equals ===");
Temperature t1 = new Temperature(36.6, "C");
Temperature t2 = new Temperature(36.601, "C"); // 差异在0.01以内
System.out.println(t1.equals(t2)); // true(自定义精度比较)
}
}代码二:POJO迁移到Record的实战指南
package com.laozhang.java16.record;
import java.time.LocalDateTime;
import java.util.List;
/**
* 从POJO迁移到Record的实战指南
* 包含Spring MVC、MyBatis、Jackson的集成示例
*/
public class RecordMigrationGuide {
// ===== 场景1:API响应DTO(最适合用Record)=====
// 旧写法(POJO,需要很多样板代码):
// public class UserResponseDTO { private Long id; private String name; ... getters, setters... }
// 新写法(Record,简洁)
record UserResponseDTO(Long id, String name, String email, LocalDateTime createdAt) {}
// Jackson默认支持Record(Jackson 2.12+),直接序列化/反序列化
// Spring Boot 2.5+也支持Record作为@RequestBody
// ===== 场景2:查询参数(Record作为查询条件)=====
record UserQueryRequest(String keyword, Integer pageNum, Integer pageSize) {
// 提供默认值
UserQueryRequest {
if (pageNum == null || pageNum < 1) throw new IllegalArgumentException("pageNum从1开始");
if (pageSize == null) pageSize = 10;
}
}
// ===== 场景3:值对象(领域模型中的不可变值)=====
record OrderId(String value) {
OrderId {
if (value == null || !value.matches("^ORD-\\d{10}$")) {
throw new IllegalArgumentException("订单ID格式不正确: " + value);
}
}
public static OrderId generate() {
return new OrderId("ORD-" + System.currentTimeMillis());
}
}
record Price(long amountInCents, String currency) {
public static Price of(double yuan, String currency) {
return new Price((long)(yuan * 100), currency);
}
public double toYuan() { return amountInCents / 100.0; }
}
// ===== 场景4:MyBatis映射(Record作为查询结果)=====
// MyBatis 3.5.5+ 支持Record,需要有全参构造器(Record自带)
// @Select("SELECT id, name, email FROM user WHERE id = #{id}")
record UserRecord(Long id, String name, String email) {}
// ===== 场景5:不适合用Record的情况 =====
// 以下情况不适合用Record,继续用POJO:
// 1. 需要可变状态(需要setter)
// 2. 需要继承其他类(Record不能继承非java.lang.Record的类)
// 3. 字段很多(构造函数参数多,可读性差)
// 4. ORM实体(JPA/Hibernate需要无参构造器和setter)
// JPA实体:不适合用Record
// @Entity
// class UserEntity { // 不要换成record!JPA需要无参构造器和setter
// @Id Long id;
// String name;
// // getter/setter
// }
// ===== 场景6:switch中的Record模式匹配(Java 21+)=====
// Java 21的模式匹配支持Record解构
sealed interface Shape permits RecordMigrationGuide.Circle, RecordMigrationGuide.Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
public static double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
};
}
public static void main(String[] args) {
System.out.println("=== API响应DTO ===");
UserResponseDTO user = new UserResponseDTO(1L, "张三", "zhang@example.com",
LocalDateTime.now());
System.out.println(user);
System.out.println("\n=== 值对象 ===");
OrderId orderId = OrderId.generate();
System.out.println("订单ID: " + orderId.value());
Price price = Price.of(99.99, "CNY");
System.out.println("价格: ¥" + price.toYuan());
System.out.println("\n=== 模式匹配(Java 21+)===");
Shape circle = new Circle(5.0);
Shape rect = new Rectangle(3.0, 4.0);
System.out.println("圆面积: " + String.format("%.2f", area(circle)));
System.out.println("矩形面积: " + String.format("%.2f", area(rect)));
System.out.println("\n=== Record的不可变性验证 ===");
UserResponseDTO dto = new UserResponseDTO(1L, "张三", "zhang@test.com", LocalDateTime.now());
// dto.id = 2L; // 编译错误!字段是final
// dto.setName("李四"); // 没有setter!
System.out.println("Record是不可变的,没有setter方法");
System.out.println("\n=== 查询参数校验 ===");
try {
new UserQueryRequest("张", 0, 10); // pageNum从0开始,校验失败
} catch (IllegalArgumentException e) {
System.out.println("校验失败: " + e.getMessage());
}
UserQueryRequest query = new UserQueryRequest("张", 1, null); // pageSize用默认值
System.out.println("查询参数: " + query);
}
}四、踩坑实录
坑1:Jackson反序列化Record失败,需要无参构造器
报错现象:
com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
No serializer found for class ... and no properties discovered to create BeanSerializer
(to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)或者反序列化失败:
com.fasterxml.jackson.databind.exc.MismatchedInputException:
Cannot construct instance of `UserDTO` (no Creators, like default constructor, exist)根本原因:
Jackson老版本(2.12以前)不支持Record,需要无参构造器才能反序列化。
具体解法:
<!-- 升级Jackson到2.12+ -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.0</version>
</dependency>或者,如果不能升级Jackson,在Record上添加@JsonCreator:
record UserDTO(Long id, String name) {
@JsonCreator
UserDTO(@JsonProperty("id") Long id, @JsonProperty("name") String name) {
this(id, name); // 调用canonical constructor
}
}坑2:Record的访问方法名不是get开头,框架不识别
报错现象:
Thymeleaf、Freemarker等模板引擎,或者一些期望JavaBean规范(getXxx()命名)的框架,无法正确获取Record的字段值。
根本原因:
Record的访问方法是name()而不是getName(),不符合JavaBean规范。
具体解法:
// 如果必须支持JavaBean规范,给Record加上get方法
record UserDTO(Long id, String name) {
// 为了兼容JavaBean规范,额外提供get方法
public Long getId() { return id; }
public String getName() { return name; }
}
// 或者继续用普通POJO,Record不是万能的坑3:在Record里修改传入的可变对象引用,破坏不可变性
报错现象:
Record里存的List被外部修改了,导致Record的"不可变性"实际上被破坏。
触发代码:
record Order(String id, List<String> items) {}
List<String> items = new ArrayList<>();
items.add("P001");
Order order = new Order("ORD001", items);
items.add("P002"); // 修改了外部List
System.out.println(order.items().size()); // 2,被修改了!根本原因:
Record的字段是final(引用不可变),但如果字段是可变对象(如ArrayList),引用本身不变,但对象内容可以改变。
具体解法:
record Order(String id, List<String> items) {
Order {
// 在紧凑构造器里做防御性拷贝
items = List.copyOf(items); // 创建不可变副本
}
}五、总结与延伸
Record是Java近年来最实用的语言特性之一。它解决了一个真实存在的痛点:Java里的数据载体类代码太繁琐了。
什么时候用Record:
- API的请求/响应DTO
- 领域模型中的值对象(Value Object)
- 多字段的返回值(替代返回Map或数组)
- 函数式风格的数据流转(Stream中间处理结果)
- 配置项、查询条件
什么时候不用Record:
- JPA/Hibernate实体类(需要可变性和无参构造器)
- 需要字段可变的场景
- 需要继承其他具体类的场景
- 需要兼容不支持Record的框架/库
从Java版本的角度:
- Java 14/15:预览特性(
--enable-preview) - Java 16:正式GA,可以放心用
- Java 17 LTS:官方推荐的LTS版本,Record完全稳定
如果你的项目还在用Java 8或11,考虑升级到17。Record、sealed class、switch表达式、text block……这些特性加在一起,能让代码简洁度提升不少。
这22篇文章(341-362期)覆盖了Java基础到进阶的核心知识点,每一个都是实际工程中会遇到的问题。希望能帮你把这些基础打得更扎实。
