我最初接手一个 C# 9 项目时,看到团队里几乎所有 DTO 都被写成了 record,理由是“代码简洁、值相等性开箱即用”。但到了代码评审阶段,总有同事追问:record 底层到底比 class 多做了什么?高并发下频繁 new、Equals、with 会不会成为瓶颈?当时我给不出让人信服的数据,只能回一句“应该差别不大”。后来为了对线上一个热点路径做优化,我专门把 record 和 class 从 IL 层面、堆栈分配、相等判断、典型基准几个角度完整对比了一遍,才彻底弄明白两者的性能差异到底来自哪里。这篇文章就把我踩过的坑和实测结论梳理出来,适合正在做 .NET 技术选型、或者想把老代码中的 class 改成 record 但又担心性能的同学参考。
1. record 不是“加了语法糖的 class”,它与普通 class 的差异在编译前就已经注定了
先说个容易混淆的点:很多人把 C# 9 引入的 record 等价理解成“编译器帮你生成了 equals/hashcode 的 class”,这个说法方向对,但不完整。C# 里其实有两类 record:record class 和 record struct。后者的底层是值类型,和 struct 一样可以分配在栈上,也可以作为泛型参数内联到集合中。所以如果你把 record class 和 class 做性能对比,本质上是在比较参考类型和参考类型;而 record struct 参与对比时,才会牵涉到“值复制”和“栈分配”这些完全不同的开销模型。
差异在定义层面就已经出现:
csharp复制// 普通类
public class PersonClass
{
public string Name { get; set; }
public int Age { get; set; }
}
// record 类
public record PersonRecord(string Name, int Age);
// record 结构体
public record struct PersonStruct(string Name, int Age);
同样的字段,PersonRecord 编译后比 PersonClass 多了一堆合成成员:EqualityContract、PrintMembers、重写的 ToString、Equals、GetHashCode、Clone、以及支持 with 表达式的内部复制方法。如果写成位置参数形式,编译器还会生成一个 Deconstruct 析构方法。这些都意味着更多 IL 指令和更多的运行时调用路径。
普通 class 里的 Equals 和 GetHashCode 来自 object 基类,默认走的是引用相等和运行时身份哈希;record 则完全重写掉了这套逻辑,改成逐字段比较。所以严格说,record 不是“同样的 class 加上语法糖”,而更像“编译器替你实现了一个自定义值语义模型”的引用类型。这里有一个很关键的性能前提:任何自定义相等逻辑,都比 object 默认的引用比较要昂贵。当然,如果你本来就在 class 里手写了 Equals 和 GetHashCode,那 record 带来的相对开销反而没那么大。
我建议在纠结性能前,先把语义问题列清楚。record 默认是“只要字段相同,两个对象就相等”,class 默认是“只有同一个引用才相等”。前者适合用来表示值对象、消息体、配置项;后者适合表示有唯一身份的领域实体。性能优化永远建立在语义正确的基础上,否则你为了快 10 纳秒用了错误的相等模型,代码里埋下的坑远比收益大。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从 IL 看两者本质差异:编译器在 record 里加装了好几张“预制表”
我实际用 ILSpy 看过编译后的程序集。PersonClass 的 IL 很简单,只有构造函数、属性 getter/setter;但 PersonRecord 的 IL 明显膨胀,主要包含这些成员:
| 成员 | record class | 普通 class | 说明 |
|---|---|---|---|
| 构造函数 | 生成一个主构造函数 | 自行定义 | 位置参数 record 会把主构造参数赋值给属性 |
EqualityContract |
生成,protected 属性 | 无 | 用于多态相等判断:要求类型完全一致 |
Equals(object) |
重写 | 默认引用比较 | record 会调用 IEquatable<T> |
Equals(T) |
生成 | 无 | 诸字段用 EqualityComparer<T>.Default 逐一比较 |
GetHashCode() |
重写 | 默认身份哈希 | 每个字段分别计算后做哈希组合 |
ToString() |
重写 | 类型全名 | record 默认输出类似 PersonRecord { Name = ..., Age = ... } |
PrintMembers |
生成 | 无 | 供 ToString 拼接字段名和值 |
Clone |
生成 | 无 | 供 with 表达式复制 |
Deconstruct |
生成(位置参数时) | 无 | 方便解构赋值 |
== / != |
生成运算符重载 | 无 | 最终都走到 Equals |
有两个细节容易被人忽略。
第一,record 的 Equals 并不是“一个方法搞定一切”。编译器通常会生成两个重载:一个 Equals(object),一个 Equals(T)(实现 IEquatable<T>),内部还会先做引用相等判断,再做类型检查,然后调用 EqualityContract 或类型判断,最后才是字段逐一比较。这就像查身份证:先看是不是同一个人(引用相等),再看是不是同一个国家签发的(类型一致),最后才核对每一项信息。每多一层判断,就是一次调用开销。
第二,record 的 GetHashCode 也不是随便拼一下。编译器会针对每个字段使用哈希组合方式生成代码,字段越多,计算路径越长。如果你把这个 record 作为 Dictionary 的 key,在插入和查找时会反复调用这个更昂贵的 GetHashCode。而普通 class 默认的 RuntimeHelpers.GetHashCode 通常非常快,因为它是基于对象身份得到的。
这里要插一句公道话:如果一个普通 class 为了追求业务上的值相等,已经手写了 Equals 和 GetHashCode,那它的哈希计算成本和 record 相差就不大了。因此在做性能对比时,我建议分两条线看:一条是“record vs 普通没重写相等方法的 class”,另一条是“record vs 手写了完整值语义的 class”。前者差距确实很大,但这里面还包含着语义模型本身的差异;后者才是真正反映编译器帮你自动生成代码的能量水平。
3. record 与 class 的性能差距,本质上是“值语义”与“引用语义”的差距
聊性能离不开底层布局。class 和 record class 都是引用类型,new 出来的实例托管在堆上,变量存的是引用,赋值时只是把 8 字节引用复制过去;而 record struct 是值类型,赋值时会把整个结构体按字节复制一遍。所以只要拿 record struct 和 class 比,首先就要面对一个不得不聊的“复制成本”问题。
在栈上分配和绕过 GC 压力,听起来非常诱人,但值语义里的“copy on assignment”并不是免费的。一个包含三个 int 字段的 record struct,复制成本很小;但如果里面有个 200 字节的字符串数组、或者多个引用类型字段,复制引用本身虽然只有 8 字节,却极易引发后续的“共享可变数据”的麻烦。就算不考虑正确性,频繁把大的 record struct 传入方法、放回集合,也会造成大量的内存复制,代价可能比堆分配的 class 更高。可以参考这样一个现实对比:
csharp复制public record struct BigRecord(int A, int B, int C, int D, int E, int F);
public void Process(BigRecord data) { ... }
调用 Process(bigData),里面 6 个 int 都要被复制到新栈帧;如果你用 class 或 record class 传引用,只复制一个引用。在热点循环里这种复制是会累积的。编译器可能做一些优化,但你不能依赖它处理所有情况,尤其是当方法体比较复杂、无法内联时。
另一个不可忽视的点是对象生命周期差异。record class 是堆上分配的,延迟加载大集合时要小心;record struct 通常放在栈上或内联到 List<T> 等集合,能大幅减少 GC 压力,但也意味着每次迭代都会复制整个值。很多用 record struct 做高性能计算的场景里,人们会刻意用 in 参数传值来减少复制,却常常忘记 in 加锁限制以及可能带来的防御性复制。
至于相等判断,这是两者差距最直观的地方。普通无重写 class 的 Equals 本质是“引用是否同一个对象”;record 则要求“字段都相等且类型一致”。如果一个 record 有 20 个属性,哪怕只有一个属性不同,为了得到 false,生成代码也会在该字段比较后提前返回;但如果 20 个属性全部相同,就必须全部比较完才返回 true。你可以想象成比对两份打印出来的简历:如果只看封面人名不同,就能立刻说不是同一人;但如果简历内容几乎一致,那就得逐字看完才能确认完全相同。所以判定 true 的代价天然比判定 false 更昂贵。在去重、分组这类操作真正的病根就藏在“大量完全相同的对象”这种最极端场景里。
在 with 表达式上也一样。with 本质上会调用编译器生成的一个复制方法,内部先构造新对象,再把除你指定字段外的旧字段逐一复制。字段越多,复制成本越高,这不是“语法糖”能解释掉的。普通 class 如果要实现类似“修改部分字段得到新对象”的效果,要么手写一个复制构造函数,要么像 Builder 模式那样逐个赋值,底层同样要付出字段复制成本。所以 record 没有给你开辟什么“捷径”,它只是在语法层面更优雅了。
4. 实测数据:创建、相等判断、字典操作三组场景压测接近后我更推荐这么看待结果
为了不凭感觉说话,我搭了一套相对可控的基准。项目使用 .NET 8,通过 BenchmarkDotNet 跑 Release 模式,测试机器是普通 8 核 CPU,内存 32GB。分别构造了三种类型:
PersonClass:普通 class,属性手动赋值;PersonRecord:位置参数 record class;PersonManualClass:普通 class,但是手动重写了Equals、GetHashCode,模拟已经实现值语义的类。
字段设计为 int Id、string Name、int Age,这很接近日常 DTO 的形态。
先说创建与读取。循环创建 100 万个对象并填充到 List<T>,然后做一次遍历求和。这里 PersonRecord 和 PersonManualClass 的耗时与 PersonClass 相比没有数量级差异,record 大约会慢 2% 到 6%,但这个数字在我不同批次测试中并不稳定,受 GC 和 CPU 频率影响很大。说实话,这类普通业务对象的创建开销主要来自堆分配和 GC,而不是编译器额外生成的那几个方法。你用 record class 替换 class,想通过减少成员方法来提升创建性能,基本是舍本逐末。
再看相等判断。我让两个字段完全相同的对象连续执行 Equals,跑了 1000 万次。结果很明显:
| 场景 | PersonClass | PersonRecord | PersonManualClass |
|---|---|---|---|
| 无重写引用相等 | 最快,几十纳秒级 | 不适用 | 不适用 |
| 相同字段对象 Equals | 不适用 | 约为引用相等的 2-3 倍耗时 | 与 PersonRecord 大体接近,略慢 |
| 不同字段对象 Equals | 不适用 | 通常能找到差异字段提前退出,所以相对较快 | 类似 |
这个结果说明,record 的相等判断开销确实高于普通 class 的引用相等,但如果你本来就已经在 class 里写了完整值相等比较,record 自动生成的代码并不会更慢,有时反而更整齐。真正要警惕的是“把 record 当实体类用”,比如两个代表同一个订单 ID 的对象,因为其中一个属性被修改了,就有可能出现 Equals 为 false 的结果;这时再做内存去重、幂等判断,逻辑上就会出错。性能问题在语义错误面前都不算大问题。
最后测了以对象作为字典 key 的场景。向 Dictionary<T, int> 中插入 1 万个对象,再去查找这 1 万个对象。无重写 class 的 key 查找非常快,因为 GetHashCode 是身份哈希,Equals 是引用相等;record 则需要对每个字段算哈希,查找耗时可能是无重写 class 的两倍左右。但随着字段数量减少、单字段为 int 的情况下,record 表现会好很多。而且如果和 PersonManualClass 做对比,record 并不吃亏,甚至会因为编译器对 IEquatable<T> 的实现更紧凑,比大部分手写版本更快。
所以我的第一个实测结论是:record 性能算不上“零开销”,但它的额外开销绝大部分来自“值语义本身”。如果你过去用 class 只是因为懒得写 GetHashCode,那么 record 给你带来的成本就和手写值语义版本基本同阶;如果你原本享受的是引用相等带来的廉价比较,那改成 record 后性能倒退是必然的。这事跟“record 快不快”没关系,而是“你要的语义本来就不便宜”。
5. 热路径里那些容易被忽略的隐性成本:with 表达式、ToString、哈希缓存和底层可变性
纸面上的 benchmark 只能说明裸操作,真实项目里 record 的问题往往出现在一些更隐蔽的场景。我挑几个自己线上遇到的问题展开讲讲。
第一个坑是 with 表达式在循环里的使用。假设你有一个长度 100 的列表,每条记录要在原有基础上改一个字段,生成新列表。用 with 写起来很优雅:
csharp复制var updated = items.Select(item => item with { Age = item.Age + 1 }).ToList();
看起来每个元素只改了一个 int,实际上编译器会在堆上复制出整个对象,并把所有字段都重新走一遍赋值。如果这个对象有几十个字段、或者其中一个字段是大数组引用,复制成本就不仅是浅拷贝那么简单了。字符串和数组是引用类型,with 只复制引用,不会深拷贝内容,所以你不必担心底层大数组被复制,但对象包装层和属性赋值路径还是有的。在每秒处理数万条数据的循环里,这会让 GC 压力明显上升。如果热点里这个模式避不开,我更建议把 DTO 改成 record struct 或者直接用可变 class 做字段更新,而不是反复用一个不变量对象做 with。
第二个坑是 ToString 和调试输出。record 自动重写的 ToString 适合日志打印,但字段多的时候,它内部要先调用 PrintMembers 拼接字符串,产生大量临时字符串。如果你在循环日志中不小心输出了整个 record,GC 会被瞬间拉高。我见过一个服务在日志级别从 Information 调整到 Debug 后吞吐下降近 20%,最后定位下来就是因为 record 的 ToString 在打印超大对象。所以线上日志模板不要轻易打印整个 record,最好只打印关键 id。
第三个坑藏在 record struct 的变动性上。record struct 的 setter 默认是可变还是 init 取决于你声明时是否用 readonly 修饰:
csharp复制public record struct PersonStruct(string Name, int Age); // 可变
public readonly record struct FixedPerson(string Name, int Age); // 不可变
可变 record struct 在 foreach、LINQ 中很容易发生隐式复制,导致你修改了一个副本,原集合没有变化,这种 bug 调试很痛苦。性能上,可变值类型的防御性复制也会导致额外开销,尤其在大量使用 in 参数时。我的建议是:绝大多数场景中,record struct 都尽量声明成 readonly record struct,既保证语义安全,也避免意外防御性复制。
第四个坑是哈希码缓存问题。record 的 GetHashCode 是基于字段实时算出来的,如果对象作为字典 key 被存储后,某个字段被修改,那么对象的哈希值就变了,Dictionary 可能再也查不到这个 key。普通 class 默认的身份哈希没有这种问题,因为它和字段值无关。这个问题本质上不是性能,而是正确性,但它会在高并发场景中表现为“莫名找不到 key”的诡异 bug,排查成本极高。你在设计 record 时要想清楚:这个类型的实例创建后,是否会进入某个集合并长期存在?如果会,它最好接近不可变,或者你再包一层始终不变的对象标识。
如果这些隐性成本你都能意识到,再把 record 用到热路径上,就不容易翻车。反过来,如果你没有认真考虑字段复制、哈希计算、字符串拼接的开销,单纯因为写着方便就把核心实体全部换成 record,性能监控图上迟早会出现一根意外的尖刺。
6. 我的选型清单:什么场景用 record,什么场景坚持 class,什么场景用 readonly record struct
经过这些研究和压测,我在团队里定了一套相对清晰的选型标准,核心不是看“哪个快”,而看“对象的生命周期和相等语义是什么”。
先看对象的用途:
| 类型用途 | 推荐方式 | 主要理由 |
|---|---|---|
| 短暂存在的 DTO,比如 API 请求/响应、MQ 消息体 | record class |
代码简洁,值比较方便,编译期生成的方法完整 |
| 需要作为字典键的不可变数据对象 | readonly record struct 或 record class |
值语义哈希稳定,且不易被修改 |
| 领域实体,存在数据库且需要身份唯一 | 普通 class,不要轻易用 record |
实体通常用 Id 判断同一性,record 会把其他属性也纳入相等判断 |
| 高频创建、字段很多的传参对象 | 普通 class 或可变对象 |
record 生成的方法偏多,字段复制成本可能放大 |
| 高性能计算中的轻量数据载体 | readonly record struct |
栈分配减少 GC,不可变避免防御性复制 |
| 需要深拷贝、懒加载、继承体系复杂的对象 | 普通 class |
record 的继承和复制语义在多态场景下有较多边界限制 |
从性能角度再说得细一点,我实际更倾向于这样拆分。
第一种情况,字段少于五六个、生命周期很短的响应模型,无脑用 record class 就好。这里 record 和 class 的创建开销几乎一致,差异在微基准里能测出来,放到真实接口里马上被 I/O、序列化淹没。你享受的是每少写一个属性赋值可能就少一个 bug 的好处。
第二种情况,对象会被放进字典或 HashSet,并且主要基于内容查重,建议优先考虑 readonly record struct。它有值类型的局部性优势,GC 不参与,而且编译器生成的 GetHashCode 在字段数量少时非常干净。不过要小心如果字段里有数组或 List,相等比较并不会走到集合内容级别,它只比较引用。所以 record 的“值相等”只是浅层的字段比较,不是深比较。你在用它做去重逻辑前,要确认集合字段不是你需要参与相等判断的内容。
第三种情况,对象是订单、用户、设备这类有唯一标识的实体,保存进数据库后又要在多个服务间流转。此时别让 record 的字段比较参与身份判断。你可以保留 class 的默认引用相等,用显式的 Id 属性和业务方法做相等逻辑,这样性能上没有额外负担,语义也更直白。实体一旦被放进 Session、缓存等场景,record 的重写哈希方法反而会成为负担。
第四种情况,对象承担数据传输功能且需要深拷贝。比如你有一个配置对象内部嵌套了多层子对象,用 with 复制的只是最外层对象的字段引用,内层子对象仍会被新旧两个对象共享。如果修改内层子对象就可能造成脏数据扩散。这种场景 copy-on-write 语义其实不适合 record,你应该设计成普通的不可变类,或者为每个层级分别实现 Clone 方法。把 class 改成 record 并不能自动获得完整的不可变深拷贝能力,它只是语法层面让属性只能用 init 赋值而已。
结合我的经验,还有一条比较反直觉的建议:如果一段代码能明确说清对象的身份和值分别是什么,那就优先用 class 表达身份、用 record 表达值,不要想在一个类型里同时承载两套语义。record 最擅长的就是充当不可变值对象;一旦你开始给它塞可变集合、写一大堆方法、依靠继承去扩展,它的优势会逐渐变成束缚。把不可变值对象放行,把可变实体拦在 class 里,项目越往后维护成本越低。
7. 最后分享一个我在压测时反复验证过的小技巧:不是所有“类改 record”都需要大动干戈
如果你正面临老项目中一批普通 class 要改成 record 以引入值相等、简化重复代码,别急着全局替换。编译器生成的 record 成员是基于类型声明的,只要你的 class 有自定义构造函数、显式属性初始化或其他逻辑,迁移过程很容易出现行为变化。
我的稳妥做法是先改那种“纯数据传输、只承担字段容器”的类型,比如接口的出参入参对象。这类类型通常没有复杂的继承关系,也不依赖引用相等,改成 record class 后几乎零风险。改完后跑一遍单元测试,重点看序列化结果和对比逻辑有没有变化;性能上只要接口 TPS 没有明显下跌,就不需要做更细粒度的 benchmark。
对于那种在内存中长期存活、会被频繁比对甚至修改的对象,我会先量一量它被创建、赋值、比较、哈希的频率,如果这些操作都是热点,那我不建议直接切到 record。你可以保留 class,然后用 IEquatable<T> 加上手写的 GetHashCode 做精确控制,反而比 compiler 生成的版本更不容易踩坑。性能剖析真正告诉我们的不是 record 能不能用,而是你对数据访问模式了解得够不够细。如果你连热点对象的分配频率都不知道,那么讨论“record 比 class 慢 5%”根本没有意义。
最后再提一个很实用的验证方法:替换后跑一次 dotnet-counters 或接入 Application Insights 的 GC 和分配监控,把修改前后的分配量、GC 代龄次数、P95 延迟拉出来对比。我在实际项目里发现,record class 与普通 class 在创建场景下,分配量几乎一样;但一旦涉及带大数字段的 with 循环或频繁相等判断,分配量和耗时会有显著增长。生产环境的数据永远比任何理论推算可信。用数据说话,再决定是否要把 record 推广到整个团队,这个过程比我在这里写一万字更有价值。
