作者:incerry
出處:https://www.cnblogs.com/InCerry/archive/2022/05/05/Dotnet-Opt-Perf-Use-Struct-Instead-Of-Class.html
版權:本作品採用“署名-非商業性使用-相同方式共享 4.0 國際”許可協議進行許可。
聲明:本博客版權歸“incerry”所有。
1. 前言
我们知道在 C#和 Java 明显的一个区别就是 C#可以自定义值类型,也就是今天的主角struct,我们有了更加方便的class为什么微软还加入了struct呢?这其实就是今天要谈到的一个优化性能的 Tips使用结构体替代类。
那麼使用結構體替代類有什麼好處呢?在什麼樣的場景需要使用結構體來替代類呢?今天的文章為大家一一解答。
注意:本文全部都以 x64 位平台為例
2. 現實的案例
舉一個現實系統的例子,大家都知道機票購票的流程,開始選擇起抵城市和機場(這是航線),然後根據自己的需要日期和時間,挑一個自己喜歡的航班和艙位,然後付款。

2.1內存占用
那么全国大约 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
{
/// <summary>
/// 航司二字码 如 中国国际航空股份有限公司:CA
/// </summary>
public string Airline { get; set; }
/// <summary>
/// 起始机场三字码 如 上海虹桥国际机场:SHA
/// </summary>
public string Start { get; set; }
/// <summary>
/// 抵达机场三字码 如 北京首都国际机场:PEK
/// </summary>
public string End { get; set; }
/// <summary>
/// 航班号 如 CA0001
/// </summary>
public string FlightNo { get; set; }
/// <summary>
/// 舱位代码 如 Y
/// </summary>
public string Cabin { get; set; }
/// <summary>
/// 价格 单位:元
/// </summary>
public decimal Price { get; set; }
/// <summary>
/// 起飞日期 如 2017-01-01
/// </summary>
public DateOnly DepDate { get; set; }
/// <summary>
/// 起飞时间 如 08:00
/// </summary>
public TimeOnly DepTime { get; set; }
/// <summary>
/// 抵达日期 如 2017-01-01
/// </summary>
public DateOnly ArrDate { get; set; }
/// <summary>
/// 抵达时间 如 08:00
/// </summary>
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<char>() * 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<FlightPriceStructExplicit>() * FlightPrices.Length);
ptr = unManagerPtr;
// 将内存空间指派给FlightPriceStructExplicit数组使用
var arrays = new Span<FlightPriceStructExplicit>(unManagerPtr.ToPointer(), 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 (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.Length;
}
// 切记,非托管内存不使用的时候 需要手动释放
[Benchmark]
public void GetStructStoreUnManageMemory()
{
_ = FlightPriceCreate.GetStructStoreUnManageMemory(out var ptr);
// 释放非托管内存
Marshal.FreeHGlobal(ptr);
}
再來看看 benchmark 的結果。

結果非常 amazing 呀,沒有在託管內存上分配空間,賦值的速度也比原來快了很多,後面發生 gc 的時候也無需掃描這一段內存,降低了 gc 壓力。這樣的結果基本就比較滿意了。
到現在的話存儲 1 億的數據差不多 6.3gb,如果使用上文中提高的其他方法,應該還能降低一些,比如像如下代碼一樣,使用枚舉來替換字符串,金額使用'分'存儲,只存時間戳。
[StructLayout(LayoutKind.Explicit, CharSet = CharSet.Unicode)]
[SkipLocalsInit]
public struct FlightPriceStructExplicit
{
// 使用byte标识航司 byte范围0~255
[FieldOffset(0)]
public byte Airline;
// 使用无符号整形表示起抵机场和航班号 2^16次方
[FieldOffset(1)]
public UInt16 Start;
[FieldOffset(3)]
public UInt16 End;
[FieldOffset(5)]
public UInt16 FlightNo;
[FieldOffset(7)]
public byte Cabin;
// 不使用decimal 价格精确到分存储
[FieldOffset(8)]
public long PriceFen;
// 使用时间戳替代
[FieldOffset(16)]
public long DepTime;
[FieldOffset(24)]
public long ArrTime;
}
最後的出來的結果,每個數據只需要 32byte 的空間存儲,這樣存儲一億的的話也不到 3gb。

本文就不繼續討論這些方式了。
2.2計算速度
那麼使用結構體有什麼問題嗎?我們來看看計算,這個計算很簡單,就是把符合條件的航線篩選出來,首先類和結構體都定義了如下代碼的方法,explicit 結構體比較特殊,我們使用 span 比較。
// 类和结构体定义的方法 当然实际中的筛选可能更加复杂
// 比较航司
public bool EqulasAirline(string airline)
{
return Airline == airline;
}
// 比较起飞机场
public bool EqualsStart(string start)
{
return Start == start;
}
// 比较抵达机场
public bool EqualsEnd(string end)
{
return End == end;
}
// 比较航班号
public bool EqualsFlightNo(string flightNo)
{
return FlightNo == flightNo;
}
// 价格是否小于指定值
public bool IsPriceLess(decimal min)
{
return Price < min;
}
// 对于Explicit结构体 定义了EqualsSpan方法
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static unsafe bool SpanEquals(this string str, char* dest, int length)
{
// 使用span来比较两个数组
return new Span<char>(dest, length).SequenceEqual(str.AsSpan());
}
// 实现的方法如下所示
public static unsafe bool EqualsAirline(FlightPriceStructExplicit item, string airline)
{
// 传需要比较的长度
return airline.SpanEquals(item.Airline, 2);
}
// 下面的方式类似,不再赘述
public static unsafe bool EqualsStart(FlightPriceStructExplicit item, string start)
{
return start.SpanEquals(item.Start, 3);
}
public static unsafe bool EqualsEnd(FlightPriceStructExplicit item, string end)
{
return end.SpanEquals(item.End, 3);
}
public static unsafe bool EqualsFlightNo(FlightPriceStructExplicit item, string flightNo)
{
return flightNo.SpanEquals(item.FlightNo, 4);
}
public static unsafe bool EqualsCabin(FlightPriceStructExplicit item, string cabin)
{
return cabin.SpanEquals(item.Cabin, 2);
}
public static bool IsPriceLess(FlightPriceStructExplicit item, decimal min)
{
return item.Price < min;
}
最後 benchmark 的代碼如下所示,對於每種存儲結構都是同樣的代碼邏輯,由於 100w 數據一下就跑完了,每種存儲方式的數據量都為 150w。
// 将需要的数据初始化好 避免对测试造成影响
private static readonly FlightPriceClass[] FlightPrices = FlightPriceCreate.GetClassStore();
private static readonly FlightPriceStruct[] FlightPricesStruct = FlightPriceCreate.GetStructStore();
private static readonly FlightPriceStructUninitialized[] FlightPricesStructUninitialized =
FlightPriceCreate.GetStructStoreUninitializedArray();
private static readonly FlightPriceStructExplicit[] FlightPricesStructExplicit =
FlightPriceCreate.GetStructStoreStructExplicit();
// 非托管内存比较特殊 只需要存储指针地址即可
private static IntPtr _unManagerPtr;
private static readonly int FlightPricesStructExplicitUnManageMemoryLength =
FlightPriceCreate.GetStructStoreUnManageMemory(out _unManagerPtr);
[Benchmark(Baseline = true)]
public int GetClassStore()
{
var caAirline = 0;
var shaStart = 0;
var peaStart = 0;
var ca0001FlightNo = 0;
var priceLess500 = 0;
for (int i = 0; i < FlightPrices.Length; i++)
{
// 简单的筛选数据
var item = FlightPrices[i];
if (item.EqualsAirline("CA"))caAirline++;
if (item.EqualsStart("SHA"))shaStart++;
if (item.EqualsEnd("PEA"))peaStart++;
if (item.EqualsFlightNo("0001"))ca0001FlightNo++;
if (item.IsPriceLess(500))priceLess500++;
}
Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");
return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;
}
[Benchmark]
public int GetStructStore()
{
var caAirline = 0;
var shaStart = 0;
var peaStart = 0;
var ca0001FlightNo = 0;
var priceLess500 = 0;
for (int i = 0; i < FlightPricesStruct.Length; i++)
{
var item = FlightPricesStruct[i];
if (item.EqualsAirline("CA"))caAirline++;
if (item.EqualsStart("SHA"))shaStart++;
if (item.EqualsEnd("PEA"))peaStart++;
if (item.EqualsFlightNo("0001"))ca0001FlightNo++;
if (item.IsPriceLess(500))priceLess500++;
}
Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");
return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;
}
[Benchmark]
public int GetFlightPricesStructExplicit()
{
var caAirline = 0;
var shaStart = 0;
var peaStart = 0;
var ca0001FlightNo = 0;
var priceLess500 = 0;
for (int i = 0; i < FlightPricesStructExplicit.Length; i++)
{
var item = FlightPricesStructExplicit[i];
if (FlightPriceStructExplicit.EqualsAirline(item,"CA"))caAirline++;
if (FlightPriceStructExplicit.EqualsStart(item,"SHA"))shaStart++;
if (FlightPriceStructExplicit.EqualsEnd(item,"PEA"))peaStart++;
if (FlightPriceStructExplicit.EqualsFlightNo(item,"0001"))ca0001FlightNo++;
if (FlightPriceStructExplicit.IsPriceLess(item,500))priceLess500++;
}
Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");
return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;
}
[Benchmark]
public unsafe int GetFlightPricesStructExplicitUnManageMemory()
{
var caAirline = 0;
var shaStart = 0;
var peaStart = 0;
var ca0001FlightNo = 0;
var priceLess500 = 0;
var arrays = new Span<FlightPriceStructExplicit>(_unManagerPtr.ToPointer(), FlightPricesStructExplicitUnManageMemoryLength);
for (int i = 0; i < arrays.Length; i++)
{
var item = arrays[i];
if (FlightPriceStructExplicit.EqualsAirline(item,"CA"))caAirline++;
if (FlightPriceStructExplicit.EqualsStart(item,"SHA"))shaStart++;
if (FlightPriceStructExplicit.EqualsEnd(item,"PEA"))peaStart++;
if (FlightPriceStructExplicit.EqualsFlightNo(item,"0001"))ca0001FlightNo++;
if (FlightPriceStructExplicit.IsPriceLess(item,500))priceLess500++;
}
Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");
return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;
}
benchmark 的結果如下。

我們看到單獨使用結構體比類要慢一點點,但是後面那些使用 explicit 布局方式和非託管內存的就慢很多很多了,有一倍多的差距,魚和熊掌真的不可兼得嗎?
我們來分析一下後面 2 種方式比較慢的原因,原因是因為值拷貝,我們知道在 c#中默認引用類型是引用傳遞,而值類型是值傳遞。
- 引用類型調用方法傳遞時只需要拷貝一次,長度為 cpu 字長,32 位系統就是 4byte,64 位就是 8byte
- 值類型調用方法是值傳遞,比如值需要占用 4byte,那麼就要拷貝 4byte,在小於等於 cpu 字長時有優勢,大於時優勢就變為劣勢。
而我們的結構體都遠遠大於 cpu 字長 64 位 8byte,而我們的後面的代碼實現發生了多次值拷貝,這拖慢了整體的速度。
那麼有沒有什麼辦法不發生值拷貝呢?當然,值類型在 c#中也可以引用傳遞,我們有 ref 關鍵字,只需要在值拷貝的地方加上就好了,代碼如下所示。
// 改造比较方法,使其支持引用传递
// 加入ref
public static unsafe bool EqualsAirlineRef(ref FlightPriceStructExplicit item, string airline)
{
// 传递的是引用 需要fixed获取指针
fixed(char* ptr = item.Airline)
{
return airline.SpanEquals(ptr, 2);
}
}
// Benchmark内部代码也修改为引用传递
[Benchmark]
public unsafe int GetStructStoreUnManageMemoryRef()
{
var caAirline = 0;
var shaStart = 0;
var peaStart = 0;
var ca0001FlightNo = 0;
var priceLess500 = 0;
var arrays = new Span<FlightPriceStructExplicit>(_unManagerPtr.ToPointer(), FlightPricesStructExplicitUnManageMemoryLength);
for (int i = 0; i < arrays.Length; i++)
{
// 从数组里面拿直接引用
ref var item = ref arrays[i];
// 传参也直接传递引用
if (FlightPriceStructExplicit.EqualsAirlineRef(ref item,"CA"))caAirline++;
if (FlightPriceStructExplicit.EqualsStartRef(ref item,"SHA"))shaStart++;
if (FlightPriceStructExplicit.EqualsEndRef(ref item,"PEA"))peaStart++;
if (FlightPriceStructExplicit.EqualsFlightNoRef(ref item,"0001"))ca0001FlightNo++;
if (FlightPriceStructExplicit.IsPriceLessRef(ref item,500))priceLess500++;
}
Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");
return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;
}
我們再來跑一下結果,我們的 explicit 結構體遙遙領先,比使用類足足快 33%,而上一輪中使用非託管內存表現也很好,排在了第二的位置。

那麼同樣是引用傳遞,使用類會更慢一些呢?這就要回到更加底層的 cpu 相關的知識了,我們 cpu 裡面除了基本的計算單元以外,還有 l1、l2、l3 這些數據緩存,如下圖所示。


這個和 cpu 的性能掛鈎,記得文章開頭那一個圖嗎?cpu 內部的緩存是速度最快的,所以第一個原因就是對於結構體數組數據是存放的連續的地址空間,非常利於 cpu 緩存;而類對象,由於是引用類型,需要指針訪問,對於 cpu 緩存不是很有利。
第二個原因是因為引用類型在訪問時,需要進行解引用操作,也就是說需要通過指針找到對應內存中的數據,而結構體不需要。
那么如何验证我们的观点呢,其实BenchmarkDotNet提供了这样的指标展示,只需要引入BenchmarkDotNet.Diagnostics.Windows NuGet 包,然后在需要评测的类上面加入以下代码。
[HardwareCounters(
HardwareCounter.LlcMisses, // 缓存未命中次数
HardwareCounter.LlcReference)] // 解引用次数
public class SpeedBench : IDisposable
{
......
}
結果如下所示,由於需要額外的統計 windows etw 的信息,所以跑的會稍微慢一點。

我們可以從上圖看出,使用引用類型緩存未命中的次數最多,解引用的次數也很多,這些拖慢了性能。
如下圖所示,順序存儲的結構體要比跳躍式的引用類型內存訪問效率高。另外對象的體積越小,對於緩存就越友好。


3. 總結
在本文章中,我們討論了如何使用結構體替換類,達到降低大量內存占用和提升幾乎一半計算性能的目的。也討論了非託管內存在.net 中的簡單使用。結構體是我非常喜歡的東西,它有著相當高效的存儲結構和相當優異的性能。但是你不應該將所有的類都轉換為結構體,因為它們有不同的適用場景。
那麼我們在什麼時候需要使用結構體,什麼時候需要使用類呢?微軟官方給出了答案。
✔️如果類型的實例比較小並且通常生存期較短或者常嵌入在其他對象中,則考慮定義結構體而不是類。
❌ 避免定義結構,除非具有所有以下特徵:
- 它邏輯上表示單個值,類似於基元類型(int、double 等等)- 比如我們的緩存數據,基本都是基元類型。
- 它的實例大小小於 16 字節 - 值拷貝的代價是巨大的,不過現在有了 ref 能有更多的適用場景。
- 它是不可變的 - 在我們今天的例子中,緩存的數據是不會改變的,所以具有這個特徵。
- 它不必頻繁裝箱 - 頻繁裝拆箱對性能有較大的損耗,在我們的場景中,函數都做了 ref 適配,所以也不存在這種情況。
在所有其他情況下,都應將類型定義為類。
其實大家從這些方式也能看出來,c#是一門入門簡單但是上限很高的語言,平時可以利用 c#的語法特性,快速的進行需求變現;而如果有了性能瓶頸,你完全可以像寫 c代碼一樣寫 c#代碼,獲得和 c媲美的性能。