寫在前面 設計良好的系統,除了架構層面的優良設計外,剩下的大部分就在於如何設計良好的代碼,.NET提供了很多的類型,這些類型非常靈活,也非常好用,比如List,Dictionary、HashSet、StringBuilder、string等等。在大多數情況下,大家都是看著業務需要直接去用,似乎並沒有 ...
寫在前面
設計良好的系統,除了架構層面的優良設計外,剩下的大部分就在於如何設計良好的代碼,.NET提供了很多的類型,這些類型非常靈活,也非常好用,比如List,Dictionary、HashSet、StringBuilder、string等等。在大多數情況下,大家都是看著業務需要直接去用,似乎並沒有什麼問題。從我的實際經驗來看,出現問題的情況確實是少之又少。之前有朋友問我,我有沒有遇到過記憶體泄漏的情況,我說我寫的系統沒有,但是同事寫的我遇到過幾次。
為了記錄曾經發生的問題,也為了以後可以避免類似的問題,總結這篇文章,力圖從數據統計角度總結幾個有效提升.NET性能的方法。
本文基於.NET Core 3.0 Preview4,採用[Benchmark]進行測試,如果不瞭解Benchmark,建議瞭解完之後再看本文。
集合-隱藏的初始容量及自動擴容
在.NET里,List、Dictionary、HashSet這些集合類型都具有初始容量,當新增的數據大於初始容量時,會自動擴展,可能大家在使用的時候很少註意這個隱藏的細節(此處暫不考慮預設初始容量、載入因數、擴容增量)。
自動擴容給使用者的感知是無限容量,如果用的不是很好,可能會帶來一些新的問題。因為每當集合新增的數據大於當前已經申請的容量的時候,會再申請更大的記憶體容量,一般是當前容量的兩倍。這就意味著我們在集合操作過程中可能需要額外的記憶體開銷。
在本次測試中,我用到了四種場景,可能並不是很完全,但是很有說明性,每個方法都是迴圈了1000次,時間複雜度均為O(1000):
- DynamicCapacity:不設置預設長度
- LargeFixedCapacity:預設長度為2000
- FixedCapacity:預設長度為1000
- FixedAndDynamicCapacity:預設長度為100
下圖為List的測試結果,可以看到其綜合性能排名是FixedCapacity>LargeFixedCapacity>DynamicCapacity>FixedAndDynamicCapacity
下圖為Dictionary的測試結果,可以看到其綜合性能排名是FixedCapacity>LargeFixedCapacity>FixedAndDynamicCapacity>DynamicCapacity,在Dictionary場景中,FixedAndDynamicCapacity和DynamicCapacity的兩個方法性能相差並不大,可能是量還不夠大
下圖為HashSet的測試結果,可以看到其綜合性能排名是FixedCapacity>LargeFixedCapacity>FixedAndDynamicCapacity>DynamicCapacity,在HashSet場景中,FixedAndDynamicCapacity和DynamicCapacity的兩個方法性能相差還是很大的
綜上所述:
一個恰當的容量初始值,可以有效提升集合操作的效率,如果不太好設置一個準確的數據,可以申請比實際稍大的空間,但是會浪費記憶體空間,併在實際上降低集合操作性能,編程的時候需要特別註意。
以下是List的測試源碼,另兩種類型的測試代碼與之基本一致:
1: public class ListTest
2: {
3: private int size = 1000;
4:
5: [Benchmark]
6: public void DynamicCapacity()
7: {
8: List<int> list = new List<int>();
9: for (int i = 0; i < size; i++)
10: {
11: list.Add(i);
12: }
13: }
14:
15: [Benchmark]
16: public void LargeFixedCapacity()
17: {
18: List<int> list = new List<int>(2000);
19: for (int i = 0; i < size; i++)
20: {
21: list.Add(i);
22: }
23: }
24:
25: [Benchmark]
26: public void FixedCapacity()
27: {
28: List<int> list = new List<int>(size);
29: for (int i = 0; i < size; i++)
30: {
31: list.Add(i);
32: }
33: }
34:
35: [Benchmark]
36: public void FixedAndDynamicCapacity()
37: {
38: List<int> list = new List<int>(100);
39: for (int i = 0; i < size; i++)
40: {
41: list.Add(i);
42: }
43: }
44: }
結構體與類
結構體是值類型,引用類型和值類型之間的區別是引用類型在堆上分配併進行垃圾回收,而值類型在堆棧中分配併在堆棧展開時被釋放,或內聯包含類型併在它們的包含類型被釋放時被釋放。 因此,值類型的分配和釋放通常比引用類型的分配和釋放開銷更低。
一般來說,框架中的大多數類型應該是類。 但是,在某些情況下,值類型的特征使得其更適合使用結構。
如果類型的實例比較小並且通常生存期較短或者通常嵌入在其他對象中,則定義結構而不是類。
該類型具有所有以下特征,可以定義一個結構:
-
它邏輯上表示單個值,類似於基元類型(
int
,double
,等等) -
它的實例大小小於 16 位元組
-
它是不可變的
-
它不會頻繁裝箱
在所有其他情況下,應將類型定義為類。由於結構體在傳遞的時候,會被覆制,因此在某些場景下可能並不適合提升性能。
以上摘自MSDN,可點擊查看詳情
可以看到Struct的平均分配時間只有Class的六分之一。
以下為該案例的測試源碼:
1: public struct UserStructTest
2: {
3: public int UserId { get;set; }
4:
5: public int Age { get; set; }
6: }
7:
8: public class UserClassTest
9: {
10: public int UserId { get; set; }
11:
12: public int Age { get; set; }
13: }
14:
15: public class StructTest
16: {
17: private int size = 1000;
18:
19: [Benchmark]
20: public void TestByStruct()
21: {
22: UserStructTest[] test = new UserStructTest[this.size];
23: for (int i = 0; i < size; i++)
24: {
25: test[i].UserId = 1;
26: test[i].Age = 22;
27: }
28: }
29:
30: [Benchmark]
31: public void TestByClass()
32: {
33: UserClassTest[] test = new UserClassTest[this.size];
34: for (int i = 0; i < size; i++)
35: {
36: test[i] = new UserClassTest
37: {
38: UserId = 1,
39: Age = 22
40: };
41: }
42: }
43: }
StringBuilder與string
字元串是不可變的,每次的賦值都會重新分配一個對象,當有大量字元串操作時,使用string非常容易出現記憶體溢出,比如導出Excel操作,所以大量字元串的操作一般推薦使用StringBuilder,以提高系統性能。
以下為一千次執行的測試結果,可以看到StringBuilder對象的記憶體分配效率十分的高,當然這是在大量字元串處理的情況,少部分的字元串操作依然可以使用string,其性能損耗可以忽略
這是執行五次的情況,可以發現雖然string的記憶體分配時間依然較長,但是穩定且錯誤率低
測試代碼如下:
1: public class StringBuilderTest
2: {
3: private int size = 5;
4:
5: [Benchmark]
6: public void TestByString()
7: {
8: string s = string.Empty;
9: for (int i = 0; i < size; i++)
10: {
11: s += "a";
12: s += "b";
13: }
14: }
15:
16: [Benchmark]
17: public void TestByStringBuilder()
18: {
19: StringBuilder sb = new StringBuilder();
20: for (int i = 0; i < size; i++)
21: {
22: sb.Append("a");
23: sb.Append("b");
24: }
25:
26: string s = sb.ToString();
27: }
28: }
析構函數
析構函數標識了一個類的生命周期已調用完畢時,會自動清理對象所占用的資源。析構方法不帶任何參數,它實際上是保證在程式中會調用垃圾回收方法 Finalize(),使用析構函數的對象不會在G0中處理,這就意味著該對象的回收可能會比較慢。通常情況下,不建議使用析構函數,更推薦使用IDispose,而且IDispose具有剛好的通用性,可以處理托管資源和非托管資源。
以下為本次測試的結果,可以看到記憶體平均分配效率的差距還是很大的
測試代碼如下:
1: public class DestructionTest
2: {
3: private int size = 5;
4:
5: [Benchmark]
6: public void NoDestruction()
7: {
8: for (int i = 0; i < this.size; i++)
9: {
10: UserTest userTest = new UserTest();
11: }
12: }
13:
14: [Benchmark]
15: public void Destruction()
16: {
17: for (int i = 0; i < this.size; i++)
18: {
19: UserDestructionTest userTest = new UserDestructionTest();
20: }
21: }
22: }
23:
24: public class UserTest: IDisposable
25: {
26: public int UserId { get; set; }
27:
28: public int Age { get; set; }
29:
30: public void Dispose()
31: {
32: Console.WriteLine("11");
33: }
34: }
35:
36: public class UserDestructionTest
37: {
38: ~UserDestructionTest()
39: {
40:
41: }
42:
43: public int UserId { get; set; }
44:
45: public int Age { get; set; }
46: }