.NET使用結(jié)構(gòu)體替代類提升性能優(yōu)化的技巧
前言
我們知道在C#和Java明顯的一個(gè)區(qū)別就是C#可以自定義值類型,也就是今天的主角struct,我們有了更加方便的class為什么微軟還加入了struct呢?這其實(shí)就是今天要談到的一個(gè)優(yōu)化性能的Tips使用結(jié)構(gòu)體替代類。
那么使用結(jié)構(gòu)體替代類有什么好處呢?在什么樣的場(chǎng)景需要使用結(jié)構(gòu)體來(lái)替代類呢?今天的文章為大家一一解答。
注意:本文全部都以x64位平臺(tái)為例
現(xiàn)實(shí)的案例
舉一個(gè)現(xiàn)實(shí)系統(tǒng)的例子,大家都知道機(jī)票購(gòu)票的流程,開(kāi)始選擇起抵城市和機(jī)場(chǎng)(這是航線),然后根據(jù)自己的需要日期和時(shí)間,挑一個(gè)自己喜歡的航班和艙位,然后付款。

內(nèi)存占用
那么全國(guó)大約49航司,8000多個(gè)航線,平均每個(gè)航線有20個(gè)航班,每個(gè)航班平均有10組艙位價(jià)格(經(jīng)濟(jì)艙、頭等還有不同的折扣權(quán)益),一般OTA(Online Travel Agency:在線旅游平臺(tái))允許預(yù)訂一年內(nèi)的機(jī)票。也就是說(shuō)平臺(tái)可能有8000*20*10*365=~5億的價(jià)格數(shù)據(jù)(以上數(shù)據(jù)均來(lái)源網(wǎng)絡(luò),實(shí)際中的數(shù)據(jù)量不方便透露)。
OTA平臺(tái)為了能讓你更快的搜索想要的航班,會(huì)將熱門(mén)的航線價(jià)格數(shù)據(jù)從數(shù)據(jù)庫(kù)拿出來(lái)緩存在內(nèi)存中(內(nèi)存比單獨(dú)網(wǎng)絡(luò)和磁盤(pán)傳輸快的多得多,詳情見(jiàn)下圖),就取20%也大約有1億數(shù)據(jù)在內(nèi)存中。
| 操作 | 速度 |
|---|---|
| 執(zhí)行指令 | 1/1,000,000,000 秒 = 1 納秒 |
| 從一級(jí)緩存讀取數(shù)據(jù) | 0.5 納秒 |
| 分支預(yù)測(cè)失敗 | 5 納秒 |
| 從二級(jí)緩存讀取數(shù)據(jù) | 7 納秒 |
| 使用Mutex加鎖和解鎖 | 25 納秒 |
| 從主存(RAM內(nèi)存)中讀取數(shù)據(jù) | 100 納秒 |
| 在1Gbps速率的網(wǎng)絡(luò)上發(fā)送2Kbyte的數(shù)據(jù) | 20,000 納秒 |
| 從內(nèi)存中讀取1MB的數(shù)據(jù) | 250,000 納秒 |
| 磁頭移動(dòng)到新的位置(代指機(jī)械硬盤(pán)) | 8,000,000 納秒 |
| 從磁盤(pán)中讀取1MB的數(shù)據(jù) | 20,000,000 納秒 |
| 發(fā)送一個(gè)數(shù)據(jù)包從美國(guó)到歐洲然后回來(lái) | 150 毫秒 = 150,000,000 納秒 |
假設(shè)我們有如下一個(gè)類,類里面有這些屬性(現(xiàn)實(shí)中要復(fù)雜的多,而且會(huì)分航線、日期等各個(gè)維度存儲(chǔ),而且不同航班有不同的售賣(mài)規(guī)則,這里演示方便忽略),那么這1億數(shù)據(jù)緩存在內(nèi)存中需要多少空間呢?
public class FlightPriceClass
{
/// <summary>
/// 航司二字碼 如 中國(guó)國(guó)際航空股份有限公司:CA
/// </summary>
public string Airline { get; set; }
/// <summary>
/// 起始機(jī)場(chǎng)三字碼 如 上海虹橋國(guó)際機(jī)場(chǎng):SHA
/// </summary>
public string Start { get; set; }
/// <summary>
/// 抵達(dá)機(jī)場(chǎng)三字碼 如 北京首都國(guó)際機(jī)場(chǎng):PEK
/// </summary>
public string End { get; set; }
/// <summary>
/// 航班號(hào) 如 CA0001
/// </summary>
public string FlightNo { get; set; }
/// <summary>
/// 艙位代碼 如 Y
/// </summary>
public string Cabin { get; set; }
/// <summary>
/// 價(jià)格 單位:元
/// </summary>
public decimal Price { get; set; }
/// <summary>
/// 起飛日期 如 2017-01-01
/// </summary>
public DateOnly DepDate { get; set; }
/// <summary>
/// 起飛時(shí)間 如 08:00
/// </summary>
public TimeOnly DepTime { get; set; }
/// <summary>
/// 抵達(dá)日期 如 2017-01-01
/// </summary>
public DateOnly ArrDate { get; set; }
/// <summary>
/// 抵達(dá)時(shí)間 如 08:00
/// </summary>
public TimeOnly ArrTime { get; set; }
}我們可以寫(xiě)一個(gè)Benchmark,來(lái)看看100W的數(shù)據(jù)需要多少空間,然后在推導(dǎo)出1億的數(shù)據(jù)
// 隨機(jī)預(yù)先生成100W的數(shù)據(jù) 避免計(jì)算邏輯導(dǎo)致結(jié)果不準(zhǔn)確
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();
// 使用類來(lái)存儲(chǔ)
[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;
}來(lái)看看最終的結(jié)果,圖片如下所示。

從上面的圖可以看出來(lái)100W數(shù)據(jù)大約需要107MB的內(nèi)存存儲(chǔ),那么一個(gè)占用對(duì)象大約就是112byte了,那么一億的對(duì)象就是約等于10.4GB。這個(gè)大小已經(jīng)比較大了,那么還有沒(méi)有更多的方案可以減少一些內(nèi)存占用呢?有小伙伴就說(shuō)了一些方案。
- 可以用int來(lái)編號(hào)字符串
- 可以使用long來(lái)存儲(chǔ)時(shí)間戳
- 可以想辦法用zip之類算法壓縮一下
- 等等
我們暫時(shí)也不用這些方法,對(duì)照本文的的標(biāo)題,大家應(yīng)該能想到用什么辦法,嘿嘿,那就是使用結(jié)構(gòu)體來(lái)替代類,我們定義了一個(gè)一樣的結(jié)構(gòu)體,如下所示。
[StructLayout(LayoutKind.Auto)]
public struct FlightPriceStruct
{
// 屬性與類一致
......
}我們可以使用Unsafe.SizeOf來(lái)查看值類型所需要的內(nèi)存大小,比如像下面這樣。

可以看到這個(gè)結(jié)構(gòu)體只需要88byte,比類所需要的112byte少了27%。來(lái)實(shí)際看看能節(jié)省多少內(nèi)存。

結(jié)果很不錯(cuò)呀,內(nèi)存確實(shí)如我們計(jì)算的一樣少了27%,另外賦值速度快了57%,而且更重要的是GC發(fā)生的次數(shù)也少了。
那么為什么結(jié)構(gòu)體可以節(jié)省那么多的內(nèi)存呢?這里需要聊一聊結(jié)構(gòu)體和類存儲(chǔ)數(shù)據(jù)的區(qū)別,下圖是類數(shù)組的存儲(chǔ)格式。

我們可以看到類數(shù)組只存放指向數(shù)組引用元素的指針,不直接存儲(chǔ)數(shù)據(jù),而且每個(gè)引用類型的實(shí)例都有以下這些東西。
- 對(duì)象頭:大小為8Byte,CoreCLR上的描述是存儲(chǔ)“需要負(fù)載到對(duì)象上的所有附加信息”,比如存儲(chǔ)對(duì)象的lock值或者HashCode緩存值。
- 方法表指針:大小為8Byte,指向類型的描述數(shù)據(jù),也就是經(jīng)常提到的(Method Table),MT里面會(huì)存放GCInfo,字段以及方法定義等等。
- 對(duì)象占位符:大小為8Byte,當(dāng)前的GC要求所有的對(duì)象至少有一個(gè)當(dāng)前指針大小的字段,如果是一個(gè)空類,除了對(duì)象頭和方法表指針以外,還會(huì)占用8Byte,如果不是空類,那就是存放第一個(gè)字段。
也就是說(shuō)一個(gè)空類不定義任何東西,也至少需要24byte的空間,8byte對(duì)象頭+8byte方法表指針+8byte對(duì)象占位符。
回到本文中,由于不是一個(gè)空類,所以每個(gè)對(duì)象除了數(shù)據(jù)存儲(chǔ)外需要額外的16byte存儲(chǔ)對(duì)象頭和方法表,另外數(shù)組需要8byte存放指向?qū)ο蟮闹羔槪砸粋€(gè)對(duì)象存儲(chǔ)在數(shù)組中需要額外占用24byte的空間。我們?cè)賮?lái)看看值類型(結(jié)構(gòu)體)。

從上圖中,我們可以看到如果是值類型的數(shù)組,那么數(shù)據(jù)是直接存儲(chǔ)在數(shù)組上,不需要引用。所以存儲(chǔ)相同的數(shù)據(jù),每個(gè)空結(jié)構(gòu)體都能省下24byte(無(wú)需對(duì)象頭、方法表和指向?qū)嵗闹羔槪?br />另外結(jié)構(gòu)體數(shù)組當(dāng)中的數(shù)組,數(shù)組也是引用類型,所以它也有24byte的數(shù)據(jù),它的對(duì)象占位符用來(lái)存放數(shù)組類型的第一個(gè)字段-數(shù)組大小。
我們可以使用ObjectLayoutInspector這個(gè)Nuget包打印對(duì)象的布局信息,類定義的布局信息如下,可以看到除了數(shù)據(jù)存儲(chǔ)需要的88byte以外,還有16byte額外空間。

結(jié)構(gòu)體定義的布局信息如下,可以看到每個(gè)結(jié)構(gòu)體都是實(shí)際的數(shù)據(jù)存儲(chǔ),不包含額外的占用。

那可不可以節(jié)省更多的內(nèi)存呢?我們知道在64位平臺(tái)上一個(gè)引用(指針)是8byte,而在C#上默認(rèn)的字符串使用Unicode-16,也就是說(shuō)2byte代表一個(gè)字符,像航司二字碼、起抵機(jī)場(chǎng)這些小于4個(gè)字符的完全可以使用char數(shù)組來(lái)節(jié)省內(nèi)存,比一個(gè)指針占用還要少,那我們修改一下代碼。
// 跳過(guò)本地變量初始化
[SkipLocalsInit]
// 調(diào)整布局方式 使用Explicit自定義布局
[StructLayout(LayoutKind.Explicit, CharSet = CharSet.Unicode)]
public struct FlightPriceStructExplicit
{
// 需要手動(dòng)指定偏移量
[FieldOffset(0)]
// 航司使用兩個(gè)字符存儲(chǔ)
public unsafe fixed char Airline[2];
// 由于航司使用了4byte 所以起始機(jī)場(chǎng)偏移4byte
[FieldOffset(4)]
public unsafe fixed char Start[3];
// 同理起始機(jī)場(chǎng)使用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;
}在來(lái)看看這個(gè)新結(jié)構(gòu)體對(duì)象的布局信息。

可以看到現(xiàn)在只需要68byte了,最后4byte是為了地址對(duì)齊,因?yàn)镃PU字長(zhǎng)是64bit,我們不用管。按照我們的計(jì)算能比88Byte節(jié)省了29%的空間。當(dāng)然使用unsafe fixed char以后就不能直接賦值了,需要進(jìn)行數(shù)據(jù)拷貝才行,代碼如下。
// 用于設(shè)置string值的擴(kuò)展方法
[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];
// 需要先f(wàn)ixed 然后再賦值
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;
}
再來(lái)跑一下,看看這樣存儲(chǔ)提升是不是能節(jié)省29%的空間呢。

是吧,從84MB->65MB節(jié)省了大約29%的內(nèi)存,不錯(cuò)不錯(cuò),基本可以達(dá)到預(yù)期了。
但是我們發(fā)現(xiàn)這個(gè)Gen0 Gen1 Gen2這些GC發(fā)生了很多次,在實(shí)際中的話因?yàn)檫@些都是使用的托管內(nèi)存,GC在進(jìn)行回收的時(shí)候會(huì)掃描這65MB的內(nèi)存,可能會(huì)讓它的STW變得更久;既然這些是緩存的數(shù)據(jù),一段時(shí)間內(nèi)不會(huì)回收和改變,那我們能讓GC別掃描這些嘛?答案是有的,我們可以直接使用非托管內(nèi)存,使用Marshal類就可以申請(qǐng)和管理非托管內(nèi)存,可以達(dá)到你寫(xiě)C語(yǔ)言的時(shí)候用的malloc函數(shù)類似的效果。
// 分配非托管內(nèi)存 // 傳參是所需要分配的字節(jié)數(shù) // 返回值是指向內(nèi)存的指針 IntPtr Marshal.AllocHGlobal(int cb); // 釋放分配的非托管內(nèi)存 // 傳參是由Marshal分配內(nèi)存的指針地址 void Marshal.FreeHGlobal(IntPtr hglobal);
再修改一下Benchmark的代碼,將它改成使用非托管內(nèi)存。
// 定義了out ptr參數(shù),用于將指針傳回
public static unsafe int GetStructStoreUnManageMemory(out IntPtr ptr)
{
// 使用AllocHGlobal分配內(nèi)存,大小使用SizeOf計(jì)算結(jié)構(gòu)體大小乘需要的數(shù)量
var unManagerPtr = Marshal.AllocHGlobal(Unsafe.SizeOf<FlightPriceStructExplicit>() * FlightPrices.Length);
ptr = unManagerPtr;
// 將內(nèi)存空間指派給FlightPriceStructExplicit數(shù)組使用
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);
}
}
// 返回長(zhǎng)度
return arrays.Length;
}
// 切記,非托管內(nèi)存不使用的時(shí)候 需要手動(dòng)釋放
[Benchmark]
public void GetStructStoreUnManageMemory()
{
_ = FlightPriceCreate.GetStructStoreUnManageMemory(out var ptr);
// 釋放非托管內(nèi)存
Marshal.FreeHGlobal(ptr);
}
再來(lái)看看Benchmark的結(jié)果。

結(jié)果非常Amazing呀,沒(méi)有在托管內(nèi)存上分配空間,賦值的速度也比原來(lái)快了很多,后面發(fā)生GC的時(shí)候也無(wú)需掃描這一段內(nèi)存,降低了GC壓力。這樣的結(jié)果基本就比較滿意了。
到現(xiàn)在的話存儲(chǔ)1億的數(shù)據(jù)差不多6.3GB,如果使用上文中提高的其它方法,應(yīng)該還能降低一些,比如像如下代碼一樣,使用枚舉來(lái)替換字符串,金額使用'分'存儲(chǔ),只存時(shí)間戳。
[StructLayout(LayoutKind.Explicit, CharSet = CharSet.Unicode)]
[SkipLocalsInit]
public struct FlightPriceStructExplicit
{
// 使用byte標(biāo)識(shí)航司 byte范圍0~255
[FieldOffset(0)]
public byte Airline;
// 使用無(wú)符號(hào)整形表示起抵機(jī)場(chǎng)和航班號(hào) 2^16次方
[FieldOffset(1)]
public UInt16 Start;
[FieldOffset(3)]
public UInt16 End;
[FieldOffset(5)]
public UInt16 FlightNo;
[FieldOffset(7)]
public byte Cabin;
// 不使用decimal 價(jià)格精確到分存儲(chǔ)
[FieldOffset(8)]
public long PriceFen;
// 使用時(shí)間戳替代
[FieldOffset(16)]
public long DepTime;
[FieldOffset(24)]
public long ArrTime;
}
最后的出來(lái)的結(jié)果,每個(gè)數(shù)據(jù)只需要32byte的空間存儲(chǔ),這樣存儲(chǔ)一億的的話也不到3GB。

本文就不繼續(xù)討論這些方式了。
計(jì)算速度
那么使用結(jié)構(gòu)體有什么問(wèn)題嗎?我們來(lái)看看計(jì)算,這個(gè)計(jì)算很簡(jiǎn)單,就是把符合條件的航線篩選出來(lái),首先類和結(jié)構(gòu)體都定義了如下代碼的方法,Explicit結(jié)構(gòu)體比較特殊,我們使用Span比較。
// 類和結(jié)構(gòu)體定義的方法 當(dāng)然實(shí)際中的篩選可能更加復(fù)雜
// 比較航司
public bool EqulasAirline(string airline)
{
return Airline == airline;
}
// 比較起飛機(jī)場(chǎng)
public bool EqualsStart(string start)
{
return Start == start;
}
// 比較抵達(dá)機(jī)場(chǎng)
public bool EqualsEnd(string end)
{
return End == end;
}
// 比較航班號(hào)
public bool EqualsFlightNo(string flightNo)
{
return FlightNo == flightNo;
}
// 價(jià)格是否小于指定值
public bool IsPriceLess(decimal min)
{
return Price < min;
}
// 對(duì)于Explicit結(jié)構(gòu)體 定義了EqualsSpan方法
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static unsafe bool SpanEquals(this string str, char* dest, int length)
{
// 使用span來(lái)比較兩個(gè)數(shù)組
return new Span<char>(dest, length).SequenceEqual(str.AsSpan());
}
// 實(shí)現(xiàn)的方法如下所示
public static unsafe bool EqualsAirline(FlightPriceStructExplicit item, string airline)
{
// 傳需要比較的長(zhǎng)度
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的代碼如下所示,對(duì)于每種存儲(chǔ)結(jié)構(gòu)都是同樣的代碼邏輯,由于100W數(shù)據(jù)一下就跑完了,每種存儲(chǔ)方式的數(shù)據(jù)量都為150W。
// 將需要的數(shù)據(jù)初始化好 避免對(duì)測(cè)試造成影響
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();
// 非托管內(nèi)存比較特殊 只需要存儲(chǔ)指針地址即可
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++)
{
// 簡(jiǎn)單的篩選數(shù)據(jù)
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的結(jié)果如下。

我們看到單獨(dú)使用結(jié)構(gòu)體比類要慢一點(diǎn)點(diǎn),但是后面那些使用Explicit布局方式和非托管內(nèi)存的就慢很多很多了,有一倍多的差距,魚(yú)和熊掌真的不可兼得嗎?
我們來(lái)分析一下后面2種方式比較慢的原因,原因是因?yàn)橹悼截?,我們知道在C#中默認(rèn)引用類型是引用傳遞,而值類型是值傳遞。
- 引用類型調(diào)用方法傳遞時(shí)只需要拷貝一次,長(zhǎng)度為CPU字長(zhǎng),32位系統(tǒng)就是4byte,64位就是8byte
- 值類型調(diào)用方法是值傳遞,比如值需要占用4byte,那么就要拷貝4byte,在小于等于CPU字長(zhǎng)時(shí)有優(yōu)勢(shì),大于時(shí)優(yōu)勢(shì)就變?yōu)榱觿?shì)。
而我們的結(jié)構(gòu)體都遠(yuǎn)遠(yuǎn)大于CPU字長(zhǎng)64位8byte,而我們的后面的代碼實(shí)現(xiàn)發(fā)生了多次值拷貝,這拖慢了整體的速度。
那么有沒(méi)有什么辦法不發(fā)生值拷貝呢?當(dāng)然,值類型在C#中也可以引用傳遞,我們有ref關(guān)鍵字,只需要在值拷貝的地方加上就好了,代碼如下所示。
// 改造比較方法,使其支持引用傳遞
// 加入ref
public static unsafe bool EqualsAirlineRef(ref FlightPriceStructExplicit item, string airline)
{
// 傳遞的是引用 需要fixed獲取指針
fixed(char* ptr = item.Airline)
{
return airline.SpanEquals(ptr, 2);
}
}
// Benchmark內(nèi)部代碼也修改為引用傳遞
[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++)
{
// 從數(shù)組里面拿直接引用
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;
}
我們?cè)賮?lái)跑一下結(jié)果,我們的Explicit結(jié)構(gòu)體遙遙領(lǐng)先,比使用類足足快33%,而上一輪中使用非托管內(nèi)存表現(xiàn)也很好,排在了第二的位置。

那么同樣是引用傳遞,使用類會(huì)更慢一些呢?這就要回到更加底層的CPU相關(guān)的知識(shí)了,我們CPU里面除了基本的計(jì)算單元以外,還有L1、L2、L3這些數(shù)據(jù)緩存,如下圖所示。


這個(gè)和CPU的性能掛鉤,記得文章開(kāi)頭那一個(gè)圖嗎?CPU內(nèi)部的緩存是速度最快的,所以第一個(gè)原因就是對(duì)于結(jié)構(gòu)體數(shù)組數(shù)據(jù)是存放的連續(xù)的地址空間,非常利于CPU緩存;而類對(duì)象,由于是引用類型,需要指針訪問(wèn),對(duì)于CPU緩存不是很有利。
第二個(gè)原因是因?yàn)橐妙愋驮谠L問(wèn)時(shí),需要進(jìn)行解引用操作,也就是說(shuō)需要通過(guò)指針找到對(duì)應(yīng)內(nèi)存中的數(shù)據(jù),而結(jié)構(gòu)體不需要。
那么如何驗(yàn)證我們的觀點(diǎn)呢,其實(shí)BenchmarkDotNet提供了這樣的指標(biāo)展示,只需要引入BenchmarkDotNet.Diagnostics.WindowsNuget包,然后在需要評(píng)測(cè)的類上面加入以下代碼。
[HardwareCounters(
HardwareCounter.LlcMisses, // 緩存未命中次數(shù)
HardwareCounter.LlcReference)] // 解引用次數(shù)
public class SpeedBench : IDisposable
{
......
}
結(jié)果如下所示,由于需要額外的統(tǒng)計(jì)Windows ETW的信息,所以跑的會(huì)稍微慢一點(diǎn)。

我們可以從上圖看出,使用引用類型緩存未命中的次數(shù)最多,解引用的次數(shù)也很多,這些拖慢了性能。
如下圖所示,順序存儲(chǔ)的結(jié)構(gòu)體要比跳躍式的引用類型內(nèi)存訪問(wèn)效率高。另外對(duì)象的體積越小,對(duì)于緩存就越友好。


總結(jié)
在本文章中,我們討論了如何使用結(jié)構(gòu)體替換類,達(dá)到降低大量?jī)?nèi)存占用和提升幾乎一半計(jì)算性能的目的。也討論了非托管內(nèi)存在.NET中的簡(jiǎn)單使用。結(jié)構(gòu)體是我非常喜歡的東西,它有著相當(dāng)高效的存儲(chǔ)結(jié)構(gòu)和相當(dāng)優(yōu)異的性能。但是你不應(yīng)該將所有的類都轉(zhuǎn)換為結(jié)構(gòu)體,因?yàn)樗鼈冇胁煌倪m用場(chǎng)景。
那么我們?cè)谑裁磿r(shí)候需要使用結(jié)構(gòu)體,什么時(shí)候需要使用類呢?微軟官方給出了答案。
?? 如果類型的實(shí)例比較小并且通常生存期較短或者常嵌入在其他對(duì)象中,則考慮定義結(jié)構(gòu)體而不是類。
? 避免定義結(jié)構(gòu),除非具有所有以下特征:
- 它邏輯上表示單個(gè)值,類似于基元類型(
int、double等等)- 比如我們的緩存數(shù)據(jù),基本都是基元類型。 - 它的實(shí)例大小小于16字節(jié) - 值拷貝的代價(jià)是巨大的,不過(guò)現(xiàn)在有了
ref能有更多的適用場(chǎng)景。 - 它是不可變的 - 在我們今天的例子中,緩存的數(shù)據(jù)是不會(huì)改變的,所以具有這個(gè)特征。
- 它不必頻繁裝箱 - 頻繁裝拆箱對(duì)性能有較大的損耗,在我們的場(chǎng)景中,函數(shù)都做了
ref適配,所以也不存在這種情況。
在所有其他情況下,都應(yīng)將類型定義為類。
其實(shí)大家從這些方式也能看出來(lái),C#是一門(mén)入門(mén)簡(jiǎn)單但是上限很高的語(yǔ)言,平時(shí)可以利用C#的語(yǔ)法特性,快速的進(jìn)行需求變現(xiàn);而如果有了性能瓶頸,你完全可以像寫(xiě)C++代碼一樣寫(xiě)C#代碼,獲得和C++媲美的性能。
附錄
本文源碼鏈接-晚點(diǎn)會(huì)上傳
選擇結(jié)構(gòu)體還是類
結(jié)構(gòu)體設(shè)計(jì)原則
.NET Marshal類
.NET Span類
CPU不同硬件的速度
到此這篇關(guān)于.NET使用結(jié)構(gòu)體替代類提升性能優(yōu)化的技巧的文章就介紹到這了,更多相關(guān).NET性能優(yōu)化結(jié)構(gòu)體替代類內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
概述.net開(kāi)發(fā)過(guò)程中Bin目錄下面幾種文件格式
本篇文章主要對(duì)項(xiàng)目發(fā)布的時(shí)候,經(jīng)常用到的幾個(gè)文件:.pdb、.xsd、.vshost.exe、.exe、.exe.config、.vshost.exe.config的作用進(jìn)行介紹,具有一定的參考價(jià)值,需要的朋友可以看下2016-12-12
如何實(shí)現(xiàn)ListView高效分頁(yè)代碼
ListView選擇自動(dòng)分頁(yè)時(shí) 其實(shí)就是添加了一個(gè)DataPager分頁(yè)控件兩者間存在著嵌套關(guān)系《Repeater與ListView》中提到這樣的分頁(yè)并不是高效的 因?yàn)閿?shù)據(jù)源還是返回了所有的數(shù)據(jù) 而非當(dāng)前頁(yè)數(shù)據(jù)2013-02-02
Debian 8或Debian 9(64 位)安裝 .NET Core
這篇文章主要為大家詳細(xì)介紹了Debian 8或Debian 9(64 位)安裝 .NET Core,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-03-03
Math.NET?Numerics?開(kāi)源數(shù)學(xué)庫(kù)安裝使用詳解
本文給大家分享Math.NETNumerics庫(kù)的安裝方法和使用示例,該庫(kù)是C#中進(jìn)行科學(xué)計(jì)算和數(shù)據(jù)分析的常用工具,本文介紹實(shí)例代碼給大家介紹的非常詳細(xì),感興趣的朋友一起看看吧2025-03-03
ScriptManager.RegisterStartupScript()方法在ajax頁(yè)面無(wú)效的解決方法
ScriptManager.RegisterStartupScript()方法在ajax頁(yè)面無(wú)效的解決方法2010-03-03
Asp.Net防止刷新重復(fù)提交數(shù)據(jù)的辦法
最近在用Asp.Net編寫(xiě)點(diǎn)東西時(shí)遇到個(gè)問(wèn)題:即用戶在提交表單后按刷新就會(huì)重復(fù)提交數(shù)據(jù),即所謂的“刷新重復(fù)提交”的問(wèn)題。2013-03-03
asp.net中通過(guò)DropDownList的值去控制TextBox是否可編寫(xiě)的實(shí)現(xiàn)代碼
Web窗體上有兩控件,DropDownList1,TextBox1,當(dāng)DropDownList的值選擇是YES的時(shí)候,TextBox1可編輯,當(dāng)選擇NO的時(shí)候,TextBox1的值為空,并且不能編輯,該如何實(shí)現(xiàn)2012-11-11
asp.net gridview的Rowcommand命令中獲取行索引的方法總結(jié)
asp.net gridview的Rowcommand命令中獲取行索引的方法總結(jié),需要的朋友可以參考下。2010-05-05

