ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C# record与class性能深度对比:实测BenchmarkDotNet数据与选型指南

C# record与class性能深度对比:实测BenchmarkDotNet数据与选型指南 每次在代码评审里看到有人把业务实体全部改成record或者反过来一口咬定record性能差所以不能用时我都会多看一眼。记录类型record作为C# 9引入的语法糖确实解决了不少痛点但网上关于它和class性能对比的说法要么过于极端要么根本没说清背后的机制。我最近刚好在一个高频交易模型的底层服务里把一批核心数据对象从class迁到了record又因为压测数据不理想迁回来一部分前后踩了不少坑。这篇文章不打算讲空话直接把我实测的BenchmarkDotNet数据、反编译出来的IL结论以及几个线上问题排查经验摊开来说。核心问题只有一个record和class在初始化、相等性比较、内存分配、克隆复制这几个维度真实性能差距到底在哪以及你在业务代码里选型时应该怎么判断。1. 先搞清楚record和class到底差在哪1.1 record不是新的引用类型而是编译器帮你写代码很多人第一次接触record会误以为它是值类型或者某种全新的类型系统。其实record默认就是一个class只是编译器在背后帮你生成了一大堆成员。C# 9刚推出的时候官方文档一句话说得很明白record是一种引用类型但它提供了基于值的相等性语义。这两句话拆开看就清楚了。第一record走的是堆分配传递的是引用这点和class没有本质区别。第二所谓“值相等”指的是两个record对象只要属性值一样Equals就返回true而不是像普通class那样比较引用地址。public record PersonRecord(string Name, int Age); public class PersonClass { public string Name { get; init; } public int Age { get; init; } }上面这个record声明表面上看只是把属性定义压缩到了一行。但实际编译后编译器会为PersonRecord生成的东西包括受保护的拷贝构造函数、Clone方法、相等性比较成员、GetHashCode、ToString、PrintMembers、和!运算符、Deconstruct解构方法。手动写一遍这些代码少说也要一百多行。1.2 值语义与引用语义是核心差异要理解性能对比先要理解语义差异。普通class默认的Equals是Object.Equals比较的是引用地址GetHashCode默认基于对象引用生成。所以两个PersonClass就算属性值完全相同Equals结果也是false。record则不同它生成的Equals会逐个属性进行比较属性越多比较成本越高。但正因为这种值语义record在用作字典键、集合去重、领域事件实体时会非常顺手不需要手动重写一大串样板代码。理解了这一层你就能明白所谓“record性能差”很多时候不是record本身差而是你拿它和“没有重写Equals的普通class”比恰恰是后者把值的比较结果搞错了不过是换来了一份虚假的性能优势。1.3 编译器为record生成的成员清单我把PersonRecord反编译后把关键成员整理成了一张表编译器生成的成员作用性能影响Clone$方法返回一个浅拷贝实例with表达式会调用它有额外分配受保护拷贝构造函数成员逐一赋值低Equals(R? other)值相等性比较随属性数量和属性类型变化GetHashCode()哈希值计算组合每个属性的哈希值 和 ! 运算符调用Equals间接开销ToString()格式化成字符串属性越多字符串分配越多PrintMembers(StringBuilder)辅助ToString输出同上Deconstruct元组式解构基本无额外开销这张表是后续性能差异分析的基础。值得注意的是一点编译器生成的这些成员很多是虚方法。如果record不是sealedEquals和GetHashCode还带有虚调度开销这一点后面会单独讲。2. 性能实测五个典型场景的数据对比2.1 测试场景设计与环境说明先交代测试环境方便你对照.NET 8.0Release模式BenchmarkDotNet 0.13.xBenchmarkDotNet默认配置DryRun迭代预热然后实际测量。测试对象是一个包含10个属性的数据对象属性类型包括string、int、DateTime、decimal、嵌套对象。我设计了五个场景场景A对象初始化constructor property set场景B相等性比较两个属性完全一致的对象调用Equals场景C哈希计算生成GetHashCode场景D克隆复制with表达式 vs 手动new 属性赋值场景EToString生成对每个场景我都同时测了三个版本手动写好的class带完整Equals/GetHashCode重写、普通class不重写、record。有些场景普通class的结果不具参考性但我会保留数据来说明问题。2.2 初始化耗时几乎没有区别真相是什么先上结果类型Mean耗时分配内存普通class对象初始化器38.2 ns72 B手写Equals的class对象初始化器39.1 ns72 Brecordwith init setter39.7 ns72 B差异在噪声范围内基本可以认为无差别。为什么会这样因为record在初始化阶段并没有生成什么特殊代码。所谓的位置参数本质上是构造器参数init访问器在CLR层面就是普通的setter。编译器在初始化record时不会额外做复制或校验。真正需要注意的一点是record的init属性在编译后其实是一个普通setter加一个modreq修饰让它在运行时只能被构造器或对象初始化器调用。这个机制不会引入额外的方法调用开销。所以在初始化这个维度你完全可以放心地把class换成record不会有什么性能损失。2.3 相等性比较record在这块有明显优势但也有隐患这才是重头戏。比较场景是两次创建两个内容完全一样的对象调用Equals看结果。类型10个属性耗时100个属性耗时普通class引用比较0.4 ns0.4 ns手写Equals的class48.6 ns412.5 nsrecord编译器生成Equals52.3 ns438.7 ns普通class的0.4 ns是引用比较结果永远是false这个数字没有实际意义但很多人拿它说事。真正有意义的是手写class和record之间的对比。编译器生成的record Equals对于int、long这种值类型走的是直接IL比较指令对于string走的就是string 运算符对于decimal、DateTime这种带自定义Equals的struct走的是它们的Equals方法对于引用类型属性走的是EqualityComparer .Default里面会做空值检查和调用属性自身的Equals。整体开销比手写版本高出约5%到10%。这个差距的来源主要是编译器生成代码里处处做了空值保护并且是逐个属性用EqualityComparer .Default包装的。手写版本你可以精准跳过空值判断、把多个值类型比较合并但代价是代码可读性和维护成本。如果你追求极限性能手写class确实有优势。但对绝大多数业务系统来说这个差距完全可以忽略。2.4 with表达式与手动克隆性能差距最大的地方record的with表达式是官方主打的亮点之一但它也是性能差距最明显的地方。类型耗时分配内存手动new 逐个属性赋值12.6 ns72 Brecord with表达式修改1个属性82.4 ns144 Bwith表达式的开销大约是手动赋值的6倍而且内存分配翻倍。这是为什么反编译一下record的with表达式你会发现它先调用Clone$方法创建对象的浅拷贝然后再对需要修改的属性进行赋值。// with expression 编译后大致对应 var temp original.Clone$(); temp.Name newName;Clone$会调用受保护的拷贝构造函数把原对象的每一个字段都赋值给新对象。所以哪怕你只是修改一个属性所有属性都会被遍历一遍。这还不算完编译器还会生成一个隐藏的临时变量用于保证线程安全或属性访问一致性。在那些需要频繁“拷贝一份再改几个字段”的场景比如事件溯源、不可变配置、批量数据处理with表达式的开销会被放大。我自己实测过一个场景用with批量更新100万条内存数据耗时比手动new多出接近4倍GC压力也明显上升。2.5 内存分配与GC压力record本身的实例内存占用和class完全一样不会多字节。但上面提到了with表达式会产生一次额外分配ToString场景更明显。类型ToString耗时分配内存普通class继承object39.5 ns32 Brecord默认ToString735.8 ns896 Brecord默认的ToString会把所有属性名和值拼成一个长字符串这个操作涉及StringBuilder申请缓冲区、多次字符串拷贝最终生成一个大字符串。10个属性就已经接近800 B的分配了100个属性更夸张。如果你在高频日志场景里不小心把record直接塞进了Log模板比如logger.LogInformation($user: {user})每一次日志都会触发一次完整的ToString。日志本身就是高频操作这个开销会被放大到肉眼可见的CPU和内存上升。这一点后面会给出排查和优化方法。2.6 排序与哈希场景还有一个容易被忽略的场景是依赖GetHashCode的集合比如Dictionary、HashSet。record生成的GetHashCode由各属性哈希组合而成10个属性就是10次哈希计算和位移合并测下来耗时约33 ns分配为0。手写class如果只对一个业务主键字段做哈希耗时能压到9 ns左右。差别在数据量大的时候会被放大。比如你有10万元素放进HashSet批量插入时record的哈希成本可能是按主键哈希的3倍以上。但这里要注意哈希值的质量差异也会影响桶的分布record这种把所有属性都纳入计算的方式带来的哈希分布通常更均匀死磕那几纳秒意义不大。3. 影响背后原理为什么会有这些性能差异3.1 record相等性比较的代码其实是编译器生成的想搞清楚为什么还是看编译器生成的代码。我简化一下PersonRecord的Equals实现大致长这样public virtual bool Equals(PersonRecord? other) { if ((object)this other) return true; if ((object)other null) return false; if (EqualityComparerstring.Default.Equals(Name, other.Name) EqualityComparerint.Default.Equals(Age, other.Age)) return true; return false; }注意几个细节第一步比较this和other的引用地址引用相同直接返回true这个优化很聪明在很多场景下能省掉后续所有属性比较。每个属性都用EqualityComparer .Default包裹。对值类型来说EqualityComparer .Default通常直接调用类型的Equals方法编译期就能确定没有装箱和虚调用对引用类型来说会有一层接口调用开销。string属性的比较走EqualityComparer .Default内部就是string的运算符而string的在比较之前会做引用判断引用不同再比较内容。性能其实很好。所以如果你确实关心相等性比较的性能真正的优化空间在于两个方向一是让第一步引用比较尽量命中即尽量复用同一实例二是在Equals实现里对最可能不同的属性先比较。手写class能做到第二点record生成的代码只会按声明顺序逐个比较。3.2 init访问器与对象初始化器的真实开销init访问器在CLR层面并不存在独立的JIT指令。它是在编译期通过modreq必需的修饰符标记setter只能用于初始化阶段但到运行时的JIT层面就是一个普通的setter调用。对象初始化器其实也是编译器的语法糖。new Person { Name a, Age 1 }会被编译成new一个临时对象然后依次调用Name的setter、Age的setter最后把引用赋给变量。这个过程和手动设置属性没有本质差别。所以record在初始化上的零开销是必然的因为它根本没有额外生成东西。这个结论对初学者很重要不要一听到record就担心性能问题初始化阶段放心用。3.3 为什么with表达式比你以为的“复制”要贵with表达式的成本来自两个层面。第一层是克隆本身。Clone$方法会调用一个受保护的拷贝构造函数这个构造函数逐个字段复制。如果record有100个属性这100次赋值是逃不掉的。第二层是修改后的赋值。with表达式只会给实际指定修改的属性赋值这一点编译器优化做得不错不会额外赋值其他属性。但有个隐藏点Clone$方法本身是虚方法并且返回的是object还是record类型这里的抹消和转换在多态场景下会引入额外调用。如果record被继承Clone$会调用到实际类型的重写版本而在编译期无法确定具体类型时JIT会做一次虚方法分派。那为什么我说with表达式比手动new加赋值贵6倍因为手动new不需要复制原对象的全部字段你只关心要修改的字段。比如更新一个10属性对象的Name字段手动new只需要5次赋值构造器里4个固定字段加Namewith表达式要复制10个字段再加1次赋值。所以结论是对于需要“复制并修改多个属性”的场景with表达式代码更简洁对于只需要修改一两个属性的高频路径考虑手动构造器会更划算。3.4 与手写class的边界何时编译器代码被打败编译器生成代码从来不是为了极致性能而是为了正确性和一致性。它打不过手写代码的地方往往是因为手写代码利用了业务语义上的捷径。举几个例子业务上两个对象只要UserId相同就算相同record默认要所有属性都相同才相等你只能重写Equals否则手写class基于UserId比较会快得多且结果正确。业务上允许Name为空字符串和null视为等价record默认不处理这个手写Equals可以合并判断。业务上只需要比较主键字段来生成哈希码record把所有属性都卷进哈希计算手写class会更快。在这些语义简化的前提下手写class完全可以超越record。但如果你需要的是“完全值相等”手写class的优化空间就不大了编译器生成的代码质量已经足够好。4. 选型决策什么时候用record什么时候用class4.1 适合用record的场景在我的实际项目里下面这些场景用record收益最大第一数据传输对象DTO。这些对象本质就是字段集合用来在不同层之间搬运数据绝大多数场景需要比较相等性或用作字典键。用record可以省掉大量样板代码而且语义表达更清晰。第二领域事件和消息对象。这类对象通常是不可变的record的init属性和with表达式刚好契合这种“创建后不再修改、需要时复制修改”的模式。第三配置项、HTTP响应模型、查询结果封装。这些对象往往属性多需要ToString方便排查。record默认ToString已经够用省去写一个几百行格式化逻辑的麻烦。public record ProductDto( long Id, string Name, decimal Price, DateTime CreatedAt, string Category);一行代码就能表达一个完整的不可变DTO。class版本至少需要20行以上而且Equals/GetHashCode还得额外写。4.2 应该坚持用class的场景性能敏感的高频路径是我最坚持用class的地方。我上面提到的事件溯源批量更新场景一个对象可能在一个循环里被with复制几十万次这个场景下record的内存分配和耗时差距会被放大到必须优化的程度。还有需要依赖引用相等性的场景。比如做了对象池管理、状态跟踪、级联缓存你希望同一个逻辑实体只有一份实例比较时用Object.ReferenceEquals最快最准。这时record的值相等反而会成为干扰因素。第三个不适合用record的场景是持久化实体。EF Core的实体类通常需要可变属性、无参构造器、延迟加载virtual导航属性、以及一些框架要求的成员。record虽然可以配合EF Core使用但init属性和位置参数会让某些场景变得别扭尤其是做属性变更追踪时可变class更方便。4.3 record struct值类型记录的新选择C# 10加入了record struct这是值类型版的record。它和record class的区别在于维度record classrecord struct类型种类引用类型堆分配值类型栈上或内联分配可变性默认不可变init位置参数默认可变set相等性编译器生成值相等编译器生成值相等装箱无特殊优化注意装箱场景record struct在特定场景下性能很亮眼。比如一个只有几个字段的轻量值对象频繁创建和比较用record struct避免了堆分配GC压力小很多。但要注意把大量record struct塞进List 这类集合时可能会在排序、查找时产生装箱反而比引用类型更慢。4.4 团队规范角度的思考代码评审时我一般会给团队立几条规则跨层传数据、需要值相等语义的优先record。有生命周期、需要状态变更的实体用class。热点循环里的临时数据对象先默认class分析后再考虑是否用record struct。不允许为了性能把已经用record的公共API改回class除非Benchmark证明是该路径的瓶颈。这些规则讲白了就是一句话record是语义工具不是性能银弹。用对地方它顺手又高效用错地方你把责任归到语言特性头上其实是自己的问题。5. record性能优化的几个实用技巧5.1 使用sealed record减少虚调用如果确认record不会被继承建议直接声明成sealed record。编译器生成的Equals、GetHashCode、ToString在sealed类型上可以避免一次虚方法分派。虽然JIT有时候能在运行时做去虚拟化优化但这类优化并不总是生效在接口调用或跨程序集调用时尤其不稳定。我测试同一个recordsealed和non-sealed的Equals性能相差大约3%到5%。单独看不大在高频比较场景里叠加其他优化累计效果还是可观的。5.2 注意集合属性的“值相等”陷阱这是一个非常隐蔽的坑。record的默认相等性比较对List 、DictionaryK,V这种引用类型属性比较的是引用而不是内容。也就是说两个record的List属性里的元素完全一样但只要List实例不同Equals就是false。这本身不是性能问题而是语义问题但它会导致你错误地以为需要比较很多属性从而在Equals里做昂贵操作。反过来说如果你用数组来存集合属性数组是引用类型比较同样只比引用。要想实现集合内容的深比较你需要重写Equals和GetHashCode而手写这些代码的实现质量直接决定性能。现实建议是record的集合属性要么用只读集合并且保证不可变性要么在业务逻辑里单独处理集合比较不要让record默认的相等性在这些属性上兜底。5.3 避免过度使用with表达式with表达式很爽但一定要克制。我的经验是如果一次性能的复制操作里需要修改的属性超过一半用手动构造器更合适如果只改一个属性且对象属性很少with表达式的可读性优势可以盖过性能损耗。另外在大批量数据操作里尽量用对象池来复用临时实例。with产生的“新实例加旧实例”双份内存在百万级数据场景会对老年代造成冲击。5.4 重写PrintMembers控制日志开销如果你喜欢record自带的ToString但又担心日志场景分配太厉害可以重写PrintMembers方法只输出关键字段protected override bool PrintMembers(StringBuilder builder) { builder.Append(Id ); builder.Append(Id); builder.Append(, Name ); builder.Append(Name); return true; }这里返回true表示有打印内容编译器会在外面加上外层大括号。这样日志里只包含你需要的信息省掉无关属性的拼接开销。5.5 用源码生成器/手写Equals补充性能如果你的record属性非常多比如超过20个而你又确实需要极高的相等性比较性能可以考虑自定义的源码生成器生成不经过EqualityComparer .Default的Equals实现。但坦率讲除非这个路径被分析成了真实瓶颈否则这个复杂度的收益不值当。6. 常见问题与排查实录6.1 表格式速查常见误解与真相我把几个最常见的误解和结论放在一起问题常见误解真实情况record是不是比class慢是因为它会生成大量代码只是特定操作withToString有额外开销初始化无差异record能不能用于性能敏感场景不能可以但要避开with和默认ToString热点路径record和struct哪个更快record更快取决于使用方式record struct可能更合适手写Equals一定比record快是在语义简化假设下才可能完全值相等时差距很小class默认Equals比record快是快是因为只比较引用等于没比较语义不同6.2 项目迁移踩坑记录最后分享一个真实的迁移教训。我之前在一个内部数据分析平台把一批聚合查询结果对象全部改成了record理由是代码简洁。结果上线后某个报表接口的P99从220ms涨到410ms几乎翻了一倍。排查半天最后定位到问题是那个接口会把查询结果里的对象塞进一个字典做去重而这个字典的Key恰好就是整个record对象。record的GetHashCode要遍历10个属性其中一个还是decimal集合哈希计算成本比之前主键哈希高了一个数量级。修复方案不是改回class而是把字典Key改成对象的业务主键Id问题直接消失。这个案例给我的体会是你只需要保持清醒知道每个record成员在运行时到底做了什么性能方向就不会走偏。如果你也在考虑大规模使用record建议先在你的机器上跑一遍上面这几个场景的BenchmarkDotNet基准拿数据说话而不是靠感觉选型。我这边实测下来对80%的业务系统record在这几个维度的性能开销都处在可接受范围真正需要优化的永远是那些会进入高频循环的死角路径。
返回列表