您现在的位置是:网站首页> 编程资料编程资料
.NET使用结构体替代类提升性能优化的技巧_实用技巧_
2023-05-24
378人已围观
简介 .NET使用结构体替代类提升性能优化的技巧_实用技巧_
前言
我们知道在C#和Java明显的一个区别就是C#可以自定义值类型,也就是今天的主角struct,我们有了更加方便的class为什么微软还加入了struct呢?这其实就是今天要谈到的一个优化性能的Tips使用结构体替代类。
那么使用结构体替代类有什么好处呢?在什么样的场景需要使用结构体来替代类呢?今天的文章为大家一一解答。
注意:本文全部都以x64位平台为例
现实的案例
举一个现实系统的例子,大家都知道机票购票的流程,开始选择起抵城市和机场(这是航线),然后根据自己的需要日期和时间,挑一个自己喜欢的航班和舱位,然后付款。

内存占用
那么全国大约49航司,8000多个航线,平均每个航线有20个航班,每个航班平均有10组舱位价格(经济舱、头等还有不同的折扣权益),一般OTA(Online Travel Agency:在线旅游平台)允许预订一年内的机票。也就是说平台可能有8000*20*10*365=~5亿的价格数据(以上数据均来源网络,实际中的数据量不方便透露)。
OTA平台为了能让你更快的搜索想要的航班,会将热门的航线价格数据从数据库拿出来缓存在内存中(内存比单独网络和磁盘传输快的多得多,详情见下图),就取20%也大约有1亿数据在内存中。
| 操作 | 速度 |
|---|---|
| 执行指令 | 1/1,000,000,000 秒 = 1 纳秒 |
| 从一级缓存读取数据 | 0.5 纳秒 |
| 分支预测失败 | 5 纳秒 |
| 从二级缓存读取数据 | 7 纳秒 |
| 使用Mutex加锁和解锁 | 25 纳秒 |
| 从主存(RAM内存)中读取数据 | 100 纳秒 |
| 在1Gbps速率的网络上发送2Kbyte的数据 | 20,000 纳秒 |
| 从内存中读取1MB的数据 | 250,000 纳秒 |
| 磁头移动到新的位置(代指机械硬盘) | 8,000,000 纳秒 |
| 从磁盘中读取1MB的数据 | 20,000,000 纳秒 |
| 发送一个数据包从美国到欧洲然后回来 | 150 毫秒 = 150,000,000 纳秒 |
假设我们有如下一个类,类里面有这些属性(现实中要复杂的多,而且会分航线、日期等各个维度存储,而且不同航班有不同的售卖规则,这里演示方便忽略),那么这1亿数据缓存在内存中需要多少空间呢?
public class FlightPriceClass { /// /// 航司二字码 如 中国国际航空股份有限公司:CA /// public string Airline { get; set; } /// /// 起始机场三字码 如 上海虹桥国际机场:SHA /// public string Start { get; set; } /// /// 抵达机场三字码 如 北京首都国际机场:PEK /// public string End { get; set; } /// /// 航班号 如 CA0001 /// public string FlightNo { get; set; } /// /// 舱位代码 如 Y /// public string Cabin { get; set; } /// /// 价格 单位:元 /// public decimal Price { get; set; } /// /// 起飞日期 如 2017-01-01 /// public DateOnly DepDate { get; set; } /// /// 起飞时间 如 08:00 /// public TimeOnly DepTime { get; set; } /// /// 抵达日期 如 2017-01-01 /// public DateOnly ArrDate { get; set; } /// /// 抵达时间 如 08:00 /// public TimeOnly ArrTime { get; set; } }我们可以写一个Benchmark,来看看100W的数据需要多少空间,然后在推导出1亿的数据
// 随机预先生成100W的数据 避免计算逻辑导致结果不准确 public static readonly FlightPriceClass[] FlightPrices = Enumerable.Range(0, 100_0000 ).Select(index => new FlightPriceClass { Airline = $"C{(char)(index % 26 + 'A')}", Start = $"SH{(char)(index % 26 + 'A')}", End = $"PE{(char)(index % 26 + 'A')}", FlightNo = $"{index % 1000:0000}", Cabin = $"{(char)(index % 26 + 'A')}", Price = index % 1000, DepDate = DateOnly.FromDateTime(BaseTime.AddHours(index)), DepTime = TimeOnly.FromDateTime(BaseTime.AddHours(index)), ArrDate = DateOnly.FromDateTime(BaseTime.AddHours(3 + index)), ArrTime = TimeOnly.FromDateTime(BaseTime.AddHours(3 + index)), }).ToArray(); // 使用类来存储 [Benchmakr] public FlightPriceClass[] GetClassStore() { var arrays = new FlightPriceClass[FlightPrices.Length]; for (int i = 0; i < FlightPrices.Length; i++) { var item = FlightPrices[i]; arrays[i] = new FlightPriceClass { Airline = item.Airline, Start = item.Start, End = item.End, FlightNo = item.FlightNo, Cabin = item.Cabin, Price = item.Price, DepDate = item.DepDate, DepTime = item.DepTime, ArrDate = item.ArrDate, ArrTime = item.ArrTime }; } return arrays; }来看看最终的结果,图片如下所示。

从上面的图可以看出来100W数据大约需要107MB的内存存储,那么一个占用对象大约就是112byte了,那么一亿的对象就是约等于10.4GB。这个大小已经比较大了,那么还有没有更多的方案可以减少一些内存占用呢?有小伙伴就说了一些方案。
- 可以用int来编号字符串
- 可以使用long来存储时间戳
- 可以想办法用zip之类算法压缩一下
- 等等
我们暂时也不用这些方法,对照本文的的标题,大家应该能想到用什么办法,嘿嘿,那就是使用结构体来替代类,我们定义了一个一样的结构体,如下所示。
[StructLayout(LayoutKind.Auto)] public struct FlightPriceStruct { // 属性与类一致 ...... }我们可以使用Unsafe.SizeOf来查看值类型所需要的内存大小,比如像下面这样。

可以看到这个结构体只需要88byte,比类所需要的112byte少了27%。来实际看看能节省多少内存。

结果很不错呀,内存确实如我们计算的一样少了27%,另外赋值速度快了57%,而且更重要的是GC发生的次数也少了。
那么为什么结构体可以节省那么多的内存呢?这里需要聊一聊结构体和类存储数据的区别,下图是类数组的存储格式。

我们可以看到类数组只存放指向数组引用元素的指针,不直接存储数据,而且每个引用类型的实例都有以下这些东西。
- 对象头:大小为8Byte,CoreCLR上的描述是存储“需要负载到对象上的所有附加信息”,比如存储对象的lock值或者HashCode缓存值。
- 方法表指针:大小为8Byte,指向类型的描述数据,也就是经常提到的(Method Table),MT里面会存放GCInfo,字段以及方法定义等等。
- 对象占位符:大小为8Byte,当前的GC要求所有的对象至少有一个当前指针大小的字段,如果是一个空类,除了对象头和方法表指针以外,还会占用8Byte,如果不是空类,那就是存放第一个字段。
也就是说一个空类不定义任何东西,也至少需要24byte的空间,8byte对象头+8byte方法表指针+8byte对象占位符。
回到本文中,由于不是一个空类,所以每个对象除了数据存储外需要额外的16byte存储对象头和方法表,另外数组需要8byte存放指向对象的指针,所以一个对象存储在数组中需要额外占用24byte的空间。我们再来看看值类型(结构体)。

从上图中,我们可以看到如果是值类型的数组,那么数据是直接存储在数组上,不需要引用。所以存储相同的数据,每个空结构体都能省下24byte(无需对象头、方法表和指向实例的指针)。
另外结构体数组当中的数组,数组也是引用类型,所以它也有24byte的数据,它的对象占位符用来存放数组类型的第一个字段-数组大小。
我们可以使用ObjectLayoutInspector这个Nuget包打印对象的布局信息,类定义的布局信息如下,可以看到除了数据存储需要的88byte以外,还有16byte额外空间。

结构体定义的布局信息如下,可以看到每个结构体都是实际的数据存储,不包含额外的占用。

那可不可以节省更多的内存呢?我们知道在64位平台上一个引用(指针)是8byte,而在C#上默认的字符串使用Unicode-16,也就是说2byte代表一个字符,像航司二字码、起抵机场这些小于4个字符的完全可以使用char数组来节省内存,比一个指针占用还要少,那我们修改一下代码。
// 跳过本地变量初始化 [SkipLocalsInit] // 调整布局方式 使用Explicit自定义布局 [StructLayout(LayoutKind.Explicit, CharSet = CharSet.Unicode)] public struct FlightPriceStructExplicit { // 需要手动指定偏移量 [FieldOffset(0)] // 航司使用两个字符存储 public unsafe fixed char Airline[2]; // 由于航司使用了4byte 所以起始机场偏移4byte [FieldOffset(4)] public unsafe fixed char Start[3]; // 同理起始机场使用6byte 偏移10byte [FieldOffset(10)] public unsafe fixed char End[3]; [FieldOffset(16)] public unsafe fixed char FlightNo[4]; [FieldOffset(24)] public unsafe fixed char Cabin[2]; // decimal 16byte [FieldOffset(28)] public decimal Price; // DateOnly 4byte [FieldOffset(44)] public DateOnly DepDate; // TimeOnly 8byte [FieldOffset(48)] public TimeOnly DepTime; [FieldOffset(56)] public DateOnly ArrDate; [FieldOffset(60)] public TimeOnly ArrTime; }在来看看这个新结构体对象的布局信息。

可以看到现在只需要68byte了,最后4byte是为了地址对齐,因为CPU字长是64bit,我们不用管。按照我们的计算能比88Byte节省了29%的空间。当然使用unsafe fixed char以后就不能直接赋值了,需要进行数据拷贝才行,代码如下。
// 用于设置string值的扩展方法 [MethodImpl(MethodImplOptions.AggressiveInlining)] public static unsafe void SetTo(this string str, char* dest) { fixed (char* ptr = str) { Unsafe.CopyBlock(dest, ptr, (uint)(Unsafe.SizeOf() * str.Length)); } } // Benchmark的方法 public static unsafe FlightPriceStructExplicit[] GetStructStoreStructExplicit() { var arrays = new FlightPriceStructExplicit[FlightPrices.Length]; for (int i = 0; i < FlightPrices.Length; i++) { ref var item = ref FlightPrices[i]; arrays[i] = new FlightPriceStructExplicit { Price = item.Price, DepDate = item.DepDate, DepTime = item.DepTime, ArrDate = item.ArrDate, ArrTime = item.ArrTime }; ref var val = ref arrays[i]; // 需要先fixed 然后再赋值 fixed (char* airline = val.Airline) fixed (char* start = val.Start) fixed (char* end = val.End) fixed (char* flightNo = val.FlightNo) fixed (char* cabin = val.Cabin) { item.Airline.SetTo(airline); item.Start.SetTo(start); item.End.SetTo(end); item.FlightNo.SetTo(flightNo); item.Cabin.SetTo(cabin); } } return arrays; } 再来跑一下,看看这样存储提升是不是能节省29%的空间呢。

是吧,从84MB->65MB节省了大约29%的内存,不错不错,基本可以达到预期了。
但是我们发现这个Gen0 Gen1 Gen2这些GC发生了很多次,在实际中的话因为这些都是使用的托管内存,GC在进行回收的时候会扫描这65MB的内存,可能会让它的STW变得更久;既然这些是缓存的数据,一段时间内不会回收和改变,那我们能让GC别扫描这些嘛?答案是有的,我们可以直接使用非托管内存,使用Marshal类就可以申请和管理非托管内存,可以达到你写C语言的时候用的malloc函数类似的效果。
// 分配非托管内存 // 传参是所需要分配的字节数 // 返回值是指向内存的指针 IntPtr Marshal.AllocHGlobal(int cb); // 释放分配的非托管内存 // 传参是由Marshal分配内存的指针地址 void Marshal.FreeHGlobal(IntPtr hglobal);
再修改一下Benchmark的代码,将它改成使用非托管内存。
// 定义了out ptr参数,用于将指针传回 public static unsafe int GetStructStoreUnManageMemory(out IntPtr ptr) { // 使用AllocHGlobal分配内存,大小使用SizeOf计算结构体大小乘需要的数量 var unManagerPtr = Marshal.AllocHGlobal(Unsafe.SizeOf() * FlightPrices.Length); ptr = unManagerPtr; // 将内存空间指派给Fligh
相关内容
- .NET使用Collections.Pooled提升性能优化的方法_实用技巧_
- 关于Net6 Xunit 集成测试的问题_实用技巧_
- asp.net6 blazor 文件上传功能_实用技巧_
- Redis中pop出队列多个元素思考_实用技巧_
- ASP.NET Core获取正确查询字符串参数示例_实用技巧_
- asp.net core实体类生产CRUD后台管理界面_实用技巧_
- ABP基础架构深入探索_基础应用_
- .NET core项目AsyncLocal在链路追踪中的应用_实用技巧_
- Asp.Net Core7 preview4限流中间件新特性详解_实用技巧_
- Asp.Net上传文件并配置可上传大文件的方法_基础应用_
点击排行
本栏推荐
