寫在前面
設計良好的系統,除了架構層面的優良設計外,剩下的大部分就在於如何設計良好的程式碼,.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: }