JDK-01 | 我为什么越来越喜欢用 Java 的 `record`

张开发
2026/4/5 1:00:33 15 分钟阅读

分享文章

JDK-01 | 我为什么越来越喜欢用 Java 的 `record`
这是我这个专栏的第 1 篇。先把版本信息放在前面record在 Java 14/15 是预览特性Java 16 正式可用。现在如果是生产环境我更建议直接在 JDK 17/21 上使用。如果这几年 Java 新特性里只能挑一个“看着不大实际挺改变写法”的东西出来我大概率会选record。第一次看到它的时候很多人的反应都差不多这不就是 Java 终于内置了一个少写 getter、equals、hashCode、toString的语法吗这么说不能算错但我一直觉得这个理解有点浅。record当然能帮我们少写代码但它真正有意思的地方不是“省事”而是它终于让 Java 可以更直接地表达一类很常见的模型: 这个类型存在的意义就是装一组数据而且它的语义就是由这组数据本身决定的。所以这篇文章我不打算只写“怎么用”。我更想把它聊完整一点它为什么会出现、到底解决了什么问题、适合用在哪、有哪些限制、哪些地方最容易踩坑。一、先说结论record到底是什么先看最简单的写法publicrecordUser(Longid,Stringname){}这就是一个record也就是记录类。它有两个组件idname别看代码只有一行编译器其实会帮我们把一整套基础能力都补上私有且final的字段访问器方法id()和name()构造器equals()hashCode()toString()也就是说这个类一旦写出来就已经是一个完整的数据载体了。UserusernewUser(1L,Alice);System.out.println(user.id());// 1System.out.println(user.name());// AliceSystem.out.println(user);// User[id1, nameAlice]这里有个细节挺值得注意。record默认生成的不是getId()、getName()这种 Java Bean 风格方法而是直接id()、name()。这其实已经在暗示它的设计思路了组件本身就是这个类型最核心的 API。二、为什么 Java 需要这玩意我自己平时写 Java真正写得最多的类其实并不是那种行为特别复杂的业务类而是各种“装数据的类”。比如DTOVO查询结果对象配置对象事件对象接口返回模型这类类有个很明显的共同点主要职责就是带数据状态通常在创建后就不怎么变相等性往往是由字段值决定的代码很容易写成大段重复样板在record之前这种类我们一般会写成这样publicfinalclassUser{privatefinalLongid;privatefinalStringname;publicUser(Longid,Stringname){this.idid;this.namename;}publicLonggetId(){returnid;}publicStringgetName(){returnname;}Overridepublicbooleanequals(Objecto){// 省略}OverridepublicinthashCode(){// 省略}OverridepublicStringtoString(){// 省略}}这类代码不是写不出来而是写多了真的会烦。你知道这段代码没什么技术难度但它偏偏又必须存在。过去我们通常有两种缓解方式让 IDE 自动生成用 Lombok 的Data或Value这当然有用但本质上都更像是“补丁”。语言本身并没有正式告诉你这个类本质上就是一个数据模型。record的意义就在这里。它不是单纯把代码缩短了而是把这种建模意图直接写进了语言里。三、它真的不只是“少写几行代码”这是我最想强调的一点。如果把record只理解成“精简版 POJO”其实会有点可惜。在我看来record真正表达的是一套很明确的语义这个类型的完整状态由头部声明的组件组成这个类型天然更接近不可变对象这个类型的相等性默认由组件值决定这个类型更像“值”而不是“实体身份”也就是说record不是在说“帮我生成点代码”而是在说这个类型就是一个值语义的数据载体。这个差别其实很大。前者只是省事后者是建模表达更清楚。四、它是什么时候进 JDK 的这个时间线顺手提一下因为很多人会把它当成“还挺新的试验特性”其实早不是了。Java 142020 年 3 月第一次以预览特性出现Java 152020 年 9 月继续预览Java 162021 年 3 月正式转正所以今天再聊record它已经是非常稳定的标准特性不是什么边缘语法。五、基本语法其实很直白1. 最基础的写法publicrecordPoint(intx,inty){}用起来也很直接PointpnewPoint(10,20);System.out.println(p.x());System.out.println(p.y());System.out.println(p);2. 支持泛型publicrecordPairK,V(Kkey,Vvalue){}所以它完全可以拿来做一些通用的小型值对象。3. 可以实现接口record不能继承普通类但实现接口没问题publicrecordUser(Longid,Stringname)implementsjava.io.Serializable{}如果是业务接口也一样publicinterfacePrintable{Stringprint();}publicrecordInvoice(Stringno,doubleamount)implementsPrintable{OverridepublicStringprint(){returnno: amount;}}4. 可以有自己的方法这一点很多人刚开始会误会觉得record就是纯字段集合不能有行为。其实完全不是。publicrecordRectangle(doublewidth,doubleheight){publicdoublearea(){returnwidth*height;}publicstaticRectanglesquare(doubleside){returnnewRectangle(side,side);}}我自己的理解是record不是不能有行为而是它的行为应该围绕这几个组件展开。它更像“有行为的值对象”不是“没有灵魂的结构体”。六、构造器这块值得单独说一下record的语法很短但它的构造器设计其实挺关键。1. 默认规范构造器如果你什么都不写publicrecordUser(Longid,Stringname){}编译器会给你生成一个和组件完全一致的构造器publicUser(Longid,Stringname){this.idid;this.namename;}这里的“一致”是严格一致不是差不多就行。参数的个数、顺序、类型都跟组件定义绑定在一起。2. 也可以自己写规范构造器如果你想做参数校验或者归一化处理也可以显式写出来publicrecordUser(Longid,Stringname){publicUser(Longid,Stringname){if(idnull){thrownewIllegalArgumentException(id cannot be null);}if(namenull||name.isBlank()){thrownewIllegalArgumentException(name cannot be blank);}this.idid;this.namename.trim();}}这种写法很适合做这些事非空校验参数合法性检查数据标准化3. 紧凑构造器通常更顺手这是我自己更常用的一种写法publicrecordUser(Longid,Stringname){publicUser{if(idnull){thrownewIllegalArgumentException(id cannot be null);}if(namenull||name.isBlank()){thrownewIllegalArgumentException(name cannot be blank);}namename.trim();}}这里最方便的地方在于你不用自己再写this.id id;和this.name name;编译器会帮你做掉。我挺喜欢这种写法因为它很符合record的气质我只关心这个对象应该满足什么约束字段赋值这种机械活交给编译器就行。4. 额外构造器也能写比如publicrecordPoint(intx,inty){publicPoint(){this(0,0);}}不过要注意额外构造器最后必须调用规范构造器。因为记录类的状态必须统一经过组件定义那条路不能偷偷绕开。七、很多人会误会的一点record不是绝对不可变更准确点说record是浅不可变不是深不可变。这话什么意思意思就是字段引用本身不能改但引用指向的对象未必不能改。比如publicrecordTags(ListStringvalues){}这么写看着挺像不可变对象但实际上不是ListStringlistnewArrayList();list.add(java);TagstagsnewTags(list);list.add(jdk);这时候tags.values()看到的内容其实已经变了。问题不在record问题在于List本身还是可变的。所以我一般会这么写publicrecordTags(ListStringvalues){publicTags{valuesList.copyOf(values);}}这样至少能保证对象内部拿到的是一个快照而不是外面随时还会被改掉的引用。数组这里尤其容易埋坑比如publicrecordBytes(byte[]data){}这类写法有两个问题数组内容本身可以改record自动生成的equals()比较的是数组引用不是数组内容所以newBytes(newbyte[]{1,2}).equals(newBytes(newbyte[]{1,2}))结果通常会是false。这也是为什么我一般不太建议把裸数组直接当成记录组件往外暴露。能换成不可变集合、字符串或者别的值对象通常会更稳一点。八、record能做什么不能做什么它能做的事record可以定义实例方法定义静态方法实现接口使用泛型定义嵌套记录类定义局部记录类添加注解覆盖访问器、equals、hashCode、toString比如局部记录类我自己就挺喜欢publicclassDemo{publicvoidprintStats(ListStringnames){recordStat(Stringname,intlength){}names.stream().map(name-newStat(name,name.length())).forEach(System.out::println);}}这种写法很适合那种“只在当前方法里临时存在一下”的小数据结构干净利落。它做不了的事record也有一些很明确的边界它隐式继承java.lang.Record它不能再继承别的类它自己是final它不能声明额外的实例字段它不适合 setter 风格的可变对象它不能把真正的状态偷偷藏在组件之外我反而觉得这些限制挺好。因为一旦你可以随意加隐藏状态record作为“透明数据载体”的语义就开始变味了。九、我一般会在什么场景下用record如果一个类型满足下面这些特点我通常会优先考虑record主要职责是携带数据创建出来之后状态基本不改相等性应该按字段值来算结构本身就是这个类型最重要的公开信息具体一点大概会是这些场景。1. DTO 或接口返回模型publicrecordUserResponse(Longid,Stringname,Stringemail){}这种类型大多数时候真的没必要写成一整个传统 POJO。2. 值对象publicrecordMoney(BigDecimalamount,Currencycurrency){}像金额、邮箱、坐标、时间区间这种东西本来就很适合值语义。3. 配置快照publicrecordDbConfig(Stringurl,Stringusername,Stringpassword){}配置对象通常是初始化完就基本不变的这和record很搭。4. 事件、命令、查询对象publicrecordOrderCreatedEvent(LongorderId,LonguserId,InstantcreatedAt){}这种对象本质上就是“一次发生的事实”天然就适合拿来做记录类。5. 封装多返回值publicrecordParseResult(booleansuccess,Stringvalue,StringerrorMessage){}我会明显更愿意看到这种写法而不是MapString, Object或者Object[]。十、哪些地方我通常不会用recordrecord很好用但真不是哪里都适合。1. JPA 实体这几乎是最典型的不适合场景。原因也比较现实ORM 往往依赖无参构造器需要代理需要延迟加载状态经常会变生命周期复杂所以数据库实体绝大多数时候还是普通类更靠谱。2. 强可变对象比如购物车工作流上下文UI 表单状态会持续迁移状态的会话对象这类对象的重点不是“值”而是“变化过程”。用record往往会很拧巴。3. 强依赖继承体系的模型因为record本身不能继承业务类所以如果你的设计强依赖继承层次那它就不合适。4. 身份比值更重要的对象比如线程连接会话文件句柄这类对象就算字段看起来一样也不应该被简单当成“值相等”。它们更强调身份语义所以也不适合往record上套。十一、它和 Lombok 到底是什么关系这个问题基本每次都会被提到。我的理解一直很简单有重叠但不是一回事。Lombok 更像是“用注解帮你生成代码”。record更像是“语言层面把一种类型语义直接定义出来”。这两个东西解决的问题有交集但层级不一样。比如 Lombok 的Value确实也能帮你搞出一个不可变对象但它更像是“生成了这些代码”。而record是在告诉整个 Java 语言和生态这个类就是一个记录类它的状态、构造、相等性、反射结构都有统一语义。如果一定要一句话总结我会这么说Lombok 偏“少写代码”record偏“把模型表达清楚”十二、为什么很多框架也开始喜欢record原因其实不复杂因为 JDK 不只是给了语法还给了反射层面的支持。比如你可以直接判断一个类是不是记录类System.out.println(User.class.isRecord());也可以拿到它的组件信息for(varcomponent:User.class.getRecordComponents()){System.out.println(component.getName());System.out.println(component.getType());}这意味着框架可以很清楚地知道这个类是不是record它有哪些组件每个组件的名称和类型是什么所以它很适合拿来做这些事情JSON 序列化和反序列化参数绑定配置注入元数据生成对象映射一个语言特性要真的好用光语法顺手其实还不够生态得能理解它。record在这点上算是比较完整的。十三、它和现代 Java 其他特性放在一起其实挺搭如果只把record当成“精简 DTO 语法”我会觉得有点浪费。它和后面这些特性其实很搭sealed class/sealed interfacepattern matchingrecord pattern比如sealedinterfaceExprpermitsNum,Add{}recordNum(intvalue)implementsExpr{}recordAdd(Exprleft,Exprright)implementsExpr{}这种写法已经很接近很多函数式语言里的那种代数数据类型表达方式了。尤其到 Java 21 之后record pattern 也正式可用了record的价值就不只是“少写 DTO”这么简单了。它已经开始参与更现代的建模方式。十四、如果团队准备开始用我会给这几个建议1. 只在“像值”的地方用它这句话看着像废话但真的很重要。如果一个对象的本质是身份、生命周期或者状态迁移那它通常不适合record。如果一个对象的本质是一组稳定事实那它大概率就挺合适。2. 在构造器里把校验和标准化做掉这是record很顺手的地方。publicrecordEmail(Stringvalue){publicEmail{if(valuenull||!value.contains()){thrownewIllegalArgumentException(invalid email);}valuevalue.trim().toLowerCase();}}这样对象一旦创建成功通常就已经是一个合法状态了。3. 对集合和数组别偷懒publicrecordRoles(ListStringvalues){publicRoles{valuesList.copyOf(values);}}很多人以为自己写的是不可变对象其实只是“字段没法重新赋值”而已。集合和数组该做防御性处理还是得做。4. 组件命名认真一点因为访问器就是组件名本身所以命名质量会直接暴露在 API 上。record User(String n)这种写法我基本不会考虑。record User(String name)才像样一点。5. 别为了追新把所有 POJO 都改掉我不太赞成那种“一上新特性就开始全量迁移”的做法。技术选型最好还是看语义匹不匹配而不是看这个特性新不新。十五、最后给一个我自己比较喜欢的例子这个例子不算花哨但挺像真实项目里会出现的代码importjava.math.BigDecimal;importjava.util.Currency;importjava.util.Objects;publicrecordMoney(BigDecimalamount,Currencycurrency){publicMoney{Objects.requireNonNull(amount,amount cannot be null);Objects.requireNonNull(currency,currency cannot be null);if(amount.signum()0){thrownewIllegalArgumentException(amount cannot be negative);}amountamount.stripTrailingZeros();}publicMoneyadd(Moneyother){Objects.requireNonNull(other,other cannot be null);if(!currency.equals(other.currency)){thrownewIllegalArgumentException(currency mismatch);}returnnewMoney(amount.add(other.amount),currency);}publicbooleanisZero(){returnamount.signum()0;}}我喜欢这个例子是因为它挺能说明record的理想用法它有明确的值语义它不是死板的裸数据它在构造阶段就保证自己合法它的行为始终围绕组件展开它整体还是不可变风格这也是我理解里比较舒服的record写法。不是把一堆字段机械塞进去而是把它当成一个真正的值对象去设计。十六、最后收个尾我现在越来越愿意用record不是因为它让我少写几个方法而是因为它让 Java 里那种“本来就应该是值对象”的东西终于能写得更自然了。所以我的标准一直很简单如果一个类型本质上是一组稳定的数据那我会优先考虑record如果一个类型强调可变状态、继承体系、实体生命周期或者对象身份那我还是会老老实实用普通类它并没有要替代所有类。它只是把原本就很常见、也很值得被明确表达的一类类终于表达得更顺手了。十七、下篇下一篇我会写JDK-02 | 我为什么越来越喜欢用 Java 的 Text Blocks。会继续按同一条线讲问题、场景、升级注意、落地差异。

更多文章