1 public class ListExample 2 { 3 public static void Main() 4 { 5 List<string> dinosaurs = new List<string>(){"A1-0"}; 6 dinosaurs.Add("C3-2"); 7 dinosaurs.Insert(2, "B2-1"}); 8 dinosaurs.Sort(); 9 dinosaurs.Reverse(); 10 } 11 }
以上的樣例中,我們對List進行初始化、使用Add()/Insert()方法對集合進行了元素的插入、藉助Sort()對集合進行了排序操作、最後使用了Reverse()對整個集合的元素進行了反轉。接下來我們將這幾個角度對List<T>進行一個整體的分析。
(1)、類型安全:在編譯時進行類型檢查,可以在編寫代碼時捕獲類型錯誤。
(2)、代碼重用:可以編寫與類型無關的代碼,從而提高了代碼的可重用性。
(3)、性能優化:可以避免裝箱和拆箱的開銷,這些操作會引入性能開銷,但使用泛型可以避免這些問題。
(4)、更好的可讀性和維護性:使代碼更加抽象,因此更容易理解和維護。
(5)、集合類的強大支持:較多的集合類(如 List、Dictionary、Queue 等)都是使用泛型實現的。
(6)、編寫更靈活的算法:可以編寫更靈活、更通用的算法,這些算法不再依賴於特定的數據類型。
泛型有以上幾種優勢,那麼泛型是如何在CoreCLR的底層中實現的呢?接下類我們藉助List<T>內部的底層實現邏輯,來具體看一下泛型是如何在內部夠完成的創建和維護的,對於List<T>數據結構,在其維護一個泛型數組。在CoreCLR的內部中,泛型實現的一些關鍵邏輯:
(1)、泛型類型擦除:在運行時,泛型類型的實例不會保留其類型參數的信息,泛型類型的實例在JIT編譯時被生成爲特定類型的代碼,其中類型參數被替換爲實際的類型。
(2)、通用模板:泛型類型和方法被定義爲通用模板,通用模板包含泛型參數,在JIT 編譯時被具體化,CoreCLR爲每種類型生成專門的代碼,同時確保類型安全性。
(3)、泛型共享代碼:如果兩個具體的泛型類型實例具有相同的運行時表示,CoreCLR將盡可能地共享生成的代碼,從而減小內存佔用和提高性能。
(4)、泛型代碼的延遲生成:泛型代碼不會在程序加載時就被全部生成,而是在運行時根據實際使用情況進行生成。有助於減小程序集的大小,因爲只有實際用到的泛型類型和方法纔會被生成。
(5)、泛型約束: 泛型約束允許在使用泛型類型時對類型參數進行限制。這有助於提供更多的類型安全性,併爲 JIT 編譯器提供了生成更有效代碼的機會。
以上的CoreCLR對泛型類型管理的基礎實現細節發現,泛型類型在 .NET 中是在編譯時創建,在運行時確定類型,這樣可以保障代碼的重用性和類型安全性。由於類型擦除,泛型在 .NET 中的實現相對高效,因爲它避免了在運行時維護多個相似類型的開銷。
介紹完了List<T>的初始化和泛型類型的管理策略,接下來我們再來看一下如何往List<T>插入元素,這裏重點介紹一下Add()/Insert()兩個方法,其中Add()是直接在數組的最後一個位置進行元素的插入,Insert()是往指定的位置插入元素。雖然兩個方法都是插入元素,但是兩個方法還是有比較大的差異的,無論是使用的場景還是其底層實現的邏輯。
首先我們看一下Add()方法對數組元素的插入源碼(以下代碼進行過刪減,刪除部分非核心代碼)。
1 public void Add(T item) 2 { 3 T[] array = _items; 4 int size = _size; 5 //獲取當前數組和大小的引用,檢查是否還有足夠的空間來添加元素。 6 if ((uint)size < (uint)array.Length) 7 { 8 //如果有足夠的空間,直接在數組中添加元素。 9 _size = size + 1; 10 array[size] = item; 11 } 12 else 13 { 14 //對數組進行擴容 15 AddWithResize(item); 16 } 17 } 18 19 //用於在需要擴容時添加元素 20 private void AddWithResize(T item) 21 { 22 int size = _size; 23 Grow(size + 1); 24 _size = size + 1; 25 _items[size] = item; 26 } 27 28 //用於調整數組的容量 29 internal void Grow(int capacity) 30 { 31 int newCapacity = _items.Length == 0 ? DefaultCapacity : 2 * _items.Length; 32 33 if ((uint)newCapacity > Array.MaxLength) newCapacity = Array.MaxLength; 34 35 if (newCapacity < capacity) newCapacity = capacity; 36 37 Capacity = newCapacity; 38 }
以上的三段代碼中說明了C#中List<T>的Add()方法是如何完成對元素的添加,Add()用於向動態數組添加元素,檢查是否還有足夠的空間來添加元素,如果空間不足時,使用AddWithResize() 方法用於在需要擴容時添加元素。當數組的容量不足時,調用Grow() 方法擴容數組,擴容後的容量是當前容量的2倍,然後基於擴容後的數組大小,檢查是否新容量超過了數組的最大長度限制,如果超過了,將容量設爲最大長度。
對於採用擴容爲2倍容量的方案存在如下的優劣勢:
1、優勢: (1)、均攤複雜度低:擴容爲當前容量的兩倍,均攤每次添加的複雜度較低。擴容操作並不是每次都觸發的,而是在數組達到一定容量時才執行。
(2)、減少頻繁擴容:擴容爲兩倍的策略減少了頻繁擴容的次數,每次擴容都需要重新分配內存並複製元素。
2、劣勢: (1)、空間浪費:導致內存浪費,在數組大小不斷接近容量極限時,如果數組的大小不一定會迅速接近容量極限,會導致內存空間的浪費。
(2)、潛在浪費:如果數組的實際大小相對較小,而容量很大,那麼數組可能會浪費大量的內存。
(3)、引起碎片化:擴容可能導致內存分配的碎片化,因爲需要爲新的數組分配一塊較大的連續內存。
以上描述了C#對於數組採用了2倍的擴容方案的優劣勢,該方案相對簡單,並且容易實現,均攤複雜度相對較低,但是也會引起內存的浪費,其實在整個計算機的體系內存在着以下的幾種擴容方案,每種方案都有其優劣勢。
1、倍增策略:(優勢)簡單、易於實現,均攤複雜度較低。(劣勢)會引起內存浪費,特別是在數組大小與容量之間有較大波動時。
2、增量策略:(優勢)按一定的增量進行擴容,減小內存浪費。(劣勢)需要更多的內存重新分配次數,增加了一些開銷。
3、動態調整策略:(優勢)根據實際使用情況動態調整容量,避免了一些固定倍增的缺點。(劣勢)增加了一些複雜性,難以確定最佳的調整策略。
4、預分配策略:(優勢)根據應用的預期負載預先分配足夠的容量,避免頻繁擴容。(劣勢)如果預測不準確,可能導致內存浪費。
5、緩慢增長策略:(優勢)初始容量較小,每次擴容容量不會增長得太快,更適用於節省內存。(劣勢) 可能導致頻繁的擴容操作,影響性能。
6、無限制擴容策略:(優勢)採用動態內存分配,不限制容量大小。(劣勢)可能存在資源耗盡的風險,適用於內存充足的情況。
對於不同的場景,可以選擇不同的擴容方案以滿足對應的需求。【其中java擴容的策略是將當前容量乘以一個固定的倍數,默認情況下是 1.5 倍。】我們在具體的開發過程過程中,可以提前分析數據的增長趨勢進行分析。如果可以提前預測到數組對應的容量,則能夠更好的提升數組的性能優勢。
1 public void Insert(int index, T item) 2 { 3 if (_size == _items.Length) Grow(_size + 1); 4 if (index < _size) 5 { 6 Array.Copy(_items, index, _items, index + 1, _size - index); 7 } 8 _items[index] = item; 9 _size++; 10 }
以上的代碼中,對於Grow()方法就不做具體的介紹了,我們來具體看一下Array.Copy()方法的實現邏輯,對於Lis<T>的底層實現,都是藉助於Array對象的底層操作進行實現,那麼我們來具體看一下其核心的實現邏輯。(部分非核心代碼已做刪減)
1 public static unsafe void Copy(Array sourceArray, Array destinationArray, int length) 2 { 3 MethodTable* pMT = RuntimeHelpers.GetMethodTable(sourceArray); 4 if (MethodTable.AreSameType(pMT, RuntimeHelpers.GetMethodTable(destinationArray)) && 5 !pMT->IsMultiDimensionalArray && 6 (uint)length <= sourceArray.NativeLength && 7 (uint)length <= destinationArray.NativeLength) 8 { 9 nuint byteCount = (uint)length * (nuint)pMT->ComponentSize; 10 ref byte src = ref Unsafe.As<RawArrayData>(sourceArray).Data; 11 ref byte dst = ref Unsafe.As<RawArrayData>(destinationArray).Data; 12 13 if (pMT->ContainsGCPointers) 14 Buffer.BulkMoveWithWriteBarrier(ref dst, ref src, byteCount); 15 else 16 Buffer.Memmove(ref dst, ref src, byteCount); 17 return; 18 } 19 20 CopyImpl(sourceArray, sourceArray.GetLowerBound(0), destinationArray, destinationArray.GetLowerBound(0), length, reliable: false); 21 }
以上代碼中,我們先來看第一行的代碼邏輯:MethodTable* pMT = RuntimeHelpers.GetMethodTable(sourceArray);該方法獲取給定對象的類型的 MethodTable(方法表),該方法主要用於獲取對象的MethodTable、獲取對象的類型信息、獲取對象的底層運行時類型信息。
1、獲取 MethodTable: 該方法用於獲取對象的 MethodTable,以便在運行時獲取有關對象類型的信息。
2、對象類型信息: MethodTable 包含與對象類型相關的信息,包括方法指針、字段信息、基類信息等。
3、用於高級編程: 通常在高級編程或與非託管代碼進行交互時可能會使用該方法,以獲取對象的底層運行時類型信息。
對於MethodTable 相關的一些實現細節和解釋,該結構有CoreCLR來進行維護:
1、類型信息:MethodTable 包含有關特定類型的信息,包括其方法定義、字段、基本類型以及其他相關元數據。
2、方法指針:MethodTable 包含指向該類型的方法實現的指針,允許高效調用方法。
3、接口實現:對於每個類型實現的接口,MethodTable 中有一個指向該接口實際方法實現的槽位。
4、繼承層次結構:MethodTable 還包含指向基本類型的 MethodTable 的指針,建立繼承層次結構。
5、虛方法表(VTable):在繼承和多態的上下文中,每種類型都有一個對應的 VTable,它實質上是一個指向虛方法的指針表。
6、垃圾回收:MethodTable 由垃圾回收器用於管理內存並跟蹤各種類型的對象。
7、方法分派:MethodTable 在運行時幫助進行方法分派,確保根據對象的實際類型調用正確的方法實現。
8、性能優化:MethodTable 允許高效的方法調用和與類型相關的操作,有助於提高 .NET 運行時的性能。
我們介紹完畢MethodTable的結構和通途後,接下來我們再來分析一下Copy()方法的其他核心邏輯。Unsafe.As 將 sourceArray和destinationArray分別視爲RawArrayData 類型,然後獲取它們的Data 屬性的引用。如果數組包含垃圾收集指針(GC Pointers),則使用 Buffer.BulkMoveWithWriteBarrier 進行移動,這會在移動數據時處理寫入屏障,確保垃圾收集器正確地識別對象引用。否則使用 Buffer.Memmove 進行高效的內存移動。
1、Buffer.BulkMoveWithWriteBarrier:在移動數據的過程中涉及到緩衝區操作,並進行寫入屏障(WriteBarrier)處理。
(1)、寫入屏障:用於確保垃圾回收器在進行垃圾收集時能正確識別對象引用的機制。寫入屏障記錄在對象的字段或元素中進行寫操作,
以便垃圾回收器能夠在必要時更新其內部數據結構,確保準確地跟蹤對象引用。
(2)、緩衝區操作:由於具體的實現可能對數據進行了某種優化,例如使用 SIMD(Single Instruction, Multiple Data)指令集來加速數據移動。
2、Buffer.Memmove:用於在內存中高效移動一塊數據的標準實現。
(1)、內存移動:使用底層平臺提供的高效內存移動操作,通常是使用處理器指令集中的優化指令,如rep movsb。
(2)、無寫入屏障:在使用這個方法時,開發人員需要確保沒有潛在的垃圾回收相關問題,例如可能導致懸空引用的情況。
Copy方法中不需要使用 GC.KeepAlive(sourceArray) 來保持對象的存活狀態。相反,通過保持 sourceArray 活躍,對象的 MethodTable (pMT) 會自動保持存活狀態。
1 public static void Sort<T>(T[] array) 2 { 3 if (array.Length > 1) 4 { 5 var span = new Span<T>(ref MemoryMarshal.GetArrayDataReference(array), array.Length); 6 ArraySortHelper<T>.Default.Sort(span, null); 7 } 8 } 9 10 internal static void IntrospectiveSort(Span<TKey> keys, Span<TValue> values, IComparer<TKey> comparer) 11 { 12 if (keys.Length > 1) 13 { 14 IntroSort(keys, values, 2 * (BitOperations.Log2((uint)keys.Length) + 1), comparer); 15 } 16 }
在C#中對於List<T>中的Sort()方法,其內部使用了一種混合排序(Hybrid Sorting)的方法,結合了快速排序(QuickSort)、堆排序(HeapSort)、插入排序(InsertionSort)三種算法,以提高性能。接下來我們按照以上的排序實現代碼來逐一進行介紹分析。
1、時間複雜度(asymptotic time complexity):大 O 時間複雜度實際上並不具體表示代碼真正的執行時間,而是表示代碼執行時間隨數據規模增長的變化趨勢,而不關心具體的常數因子或低階項。
常見的複雜度並不多,從低階到高階有:O(1)、O(logn)、O(n)、O(nlogn)、O(n2 )。 2、空間複雜度(asymptotic spacecomplexity):全稱就是漸進空間複雜度,表示算法的存儲空間與數據規模之間的增長關係。
常見的空間複雜度就是 O(1)、O(n)、O(n2), O(logn)、O(nlogn)。
1 private static void IntroSort(Span<TKey> keys, Span<TValue> values, int depthLimit, IComparer<TKey> comparer) 2 { 3 int partitionSize = keys.Length; 4 while (partitionSize > 1) 5 { 6 if (partitionSize <= Array.IntrosortSizeThreshold) 7 { 8 if (partitionSize == 2) 9 { 10 SwapIfGreaterWithValues(keys, values, comparer, 0, 1); 11 return; 12 } 13 14 if (partitionSize == 3) 15 { 16 SwapIfGreaterWithValues(keys, values, comparer, 0, 1); 17 SwapIfGreaterWithValues(keys, values, comparer, 0, 2); 18 SwapIfGreaterWithValues(keys, values, comparer, 1, 2); 19 return; 20 } 21 // 使用插入排序 22 InsertionSort(keys.Slice(0, partitionSize), values.Slice(0, partitionSize), comparer); 23 return; 24 } 25 26 if (depthLimit == 0) 27 { 28 // 使用堆排序 29 HeapSort(keys.Slice(0, partitionSize), values.Slice(0, partitionSize), comparer); 30 return; 31 } 32 depthLimit--; 33 // 使用快速排序,獲取新的分區點 p 34 int p = PickPivotAndPartition(keys.Slice(0, partitionSize), values.Slice(0, partitionSize), comparer); 35 // 對右半部分進行遞歸排序 36 IntroSort(keys[(p+1)..partitionSize], values[(p+1)..partitionSize], depthLimit, comparer); 37 partitionSize = p; 38 } 39 }
IntroSort ()方法是混合排序的核心實現。在循環中,首先檢查當前分區的大小,如果小於等於閾值 Array.IntrosortSizeThreshold,則使用插入排序;如果遞歸深度達到限制 depthLimit,則使用堆排序;否則,使用快速排序找到新的分區點 p,然後對右半部分進行遞歸排序。
1 private static void SwapIfGreaterWithValues<TKey, TValue>(Span<TKey> keys, Span<TValue> values, IComparer<TKey> comparer, int i, int j) 2 { 3 if (i != j && comparer.Compare(keys[i], keys[j]) > 0) 4 { 5 TKey key = keys[i]; 6 keys[i] = keys[j]; 7 keys[j] = key; 8 9 if (!values.IsEmpty) 10 { 11 TValue value = values[i]; 12 values[i] = values[j]; 13 values[j] = value; 14 } 15 } 16 }
以上的代碼中,展示了SwapIfGreaterWithValues()方法的核心邏輯,該方法是用於比較數組中的元素,並在需要時進行交換,這個方法被用於快速排序(QuickSort)過程中。這個方法的目的是確保在排序過程中,當發現當前元素的鍵大於另一個元素的鍵時,進行交換,從而保證排序的正確性。這是排序算法中常見的元素交換操作,用於維護排序的穩定性和順序。在這裏,通過泛型參數的使用,可以同時對關聯的值進行交換,以保持鍵值對的關聯性。對於InsertionSort()、HeapSort()、PickPivotAndPartition()、IntroSort()這幾種排序算法在C#中的實現代碼就不做展示,感興趣的同學可以具體看一下對應的實現代碼。(以上排序算法在C#中實現的方式相對較爲簡單,這裏就不做具體的展開)
1、InsertionSort() :插入排序。 優勢: (1)、在小型數組上表現良好,具有較低的常數因子。 (2)、對於部分有序的數組,插入排序的性能相對較好。 劣勢: (1)、在大型數組上的性能較差,其時間複雜度爲O(n^2)。 (2)、不適用於大規模或完全無序的數組。
2、HeapSort() :堆排序。 優勢: (1)、在最壞情況下也能保證 O(n log n) 的時間複雜度。
(2)、不需要額外的空間,是一種原地排序算法。
(3)、對於大規模數據集和外部排序等場景具有一定優勢。
劣勢:
(1)、由於對內存的隨機訪問較多,可能會導致緩存未命中,性能相對較差。
3、IntroSort() :"引入排序" 或 "介紹排序"。
優勢:
(1)、綜合了快速排序、堆排序、插入排序,充分利用各自的優勢。
(2)、在大多數情況下,IntroSort 的性能比單一排序算法更好。
劣勢:
(1)、對於小型數組,插入排序的性能可能更好,而 IntroSort 還需要一些額外的開銷。
(2)、需要額外的遞歸深度控制參數,這可能需要進行一些經驗性的調優。
"引入排序" 或 "介紹排序"的基本思路:在每一次遞歸時,都會檢查遞歸深度是否超過了一定的閾值(通常爲 log(N)),如果超過了,則切換到堆排序,以避免快速排序在最壞情況下的性能問題。對於以上介紹的幾種算法,有幾項簡單的總結:
1、對於小型數組或部分有序的數組,插入排序可能是一個不錯的選擇。
2、堆排序適用於大規模數據集,而且是原地排序。
3、快速排序在平均情況下性能較好,但在最壞情況下的性能可能較差。
4、IntroSort 綜合了多種排序算法的優勢,通常在各種輸入情況下都表現較好。
1 public static void Reverse(ref int buf, nuint length) 2 { 3 nint remainder = (nint)length; 4 nint offset = 0; 5 6 //檢查硬件是否支持相應的SIMD操作。 7 if (Vector512.IsHardwareAccelerated && remainder >= Vector512<int>.Count * 2) 8 { 9 nint lastOffset = remainder - Vector512<int>.Count; 10 do 11 { 12 Vector512<int> tempFirst = Vector512.LoadUnsafe(ref buf, (nuint)offset); 13 Vector512<int> tempLast = Vector512.LoadUnsafe(ref buf, (nuint)lastOffset); 14 tempFirst = Vector512.Shuffle(tempFirst, Vector512.Create(15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0)); 15 tempLast = Vector512.Shuffle(tempLast, Vector512.Create(15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0)); 16 17 tempLast.StoreUnsafe(ref buf, (nuint)offset); 18 tempFirst.StoreUnsafe(ref buf, (nuint)lastOffset); 19 20 offset += Vector512<int>.Count; 21 lastOffset -= Vector512<int>.Count; 22 } while (lastOffset >= offset); 23 24 remainder = lastOffset + Vector512<int>.Count - offset; 25 } 26 else if (Avx2.IsSupported && remainder >= Vector256<int>.Count * 2) 27 { 28 nint lastOffset = remainder - Vector256<int>.Count; 29 do 30 { 31 Vector256<int> tempFirst = Vector256.LoadUnsafe(ref buf, (nuint)offset); 32 Vector256<int> tempLast = Vector256.LoadUnsafe(ref buf, (nuint)lastOffset); 33 34 tempFirst = Avx2.PermuteVar8x32(tempFirst, Vector256.Create(7, 6, 5, 4, 3, 2, 1, 0)); 35 tempLast = Avx2.PermuteVar8x32(tempLast, Vector256.Create(7, 6, 5, 4, 3, 2, 1, 0)); 36 37 tempLast.StoreUnsafe(ref buf, (nuint)offset); 38 tempFirst.StoreUnsafe(ref buf, (nuint)lastOffset); 39 40 offset += Vector256<int>.Count; 41 lastOffset -= Vector256<int>.Count; 42 } while (lastOffset >= offset); 43 44 remainder = lastOffset + Vector256<int>.Count - offset; 45 } 46 else if (Vector128.IsHardwareAccelerated && remainder >= Vector128<int>.Count * 2) 47 { 48 nint lastOffset = remainder - Vector128<int>.Count; 49 do 50 { 51 Vector128<int> tempFirst = Vector128.LoadUnsafe(ref buf, (nuint)offset); 52 Vector128<int> tempLast = Vector128.LoadUnsafe(ref buf, (nuint)lastOffset); 53 54 tempFirst = Vector128.Shuffle(tempFirst, Vector128.Create(3, 2, 1, 0)); 55 tempLast = Vector128.Shuffle(tempLast, Vector128.Create(3, 2, 1, 0)); 56 57 tempLast.StoreUnsafe(ref buf, (nuint)offset); 58 tempFirst.StoreUnsafe(ref buf, (nuint)lastOffset); 59 60 offset += Vector128<int>.Count; 61 lastOffset -= Vector128<int>.Count; 62 } while (lastOffset >= offset); 63 64 remainder = lastOffset + Vector128<int>.Count - offset; 65 } 66 67 if (remainder > 1) 68 { 69 ReverseInner(ref Unsafe.Add(ref buf, offset), (nuint)remainder); 70 } 71 }
對於數組的反轉操作是相對比較耗時,我們接下來基於其實現反轉操作的代碼來看看是如何耗時,重點的實現邏輯在何處。該方法使用了 SIMD(SingleInstruction, Multiple Data)指令集,充分發揮現代處理器的並行計算能力,提高數組反轉的速度。
1、根據硬件支持情況,選擇適當的 SIMD 操作進行數組反轉:
(1)、如果硬件支持 512 位向量(Vector512則使用 512 位的 SIMD 指令集進行反轉。
(2)、如果不支持 512 位向量,但支持 256 位向量(Vector256),則使用 256 位的 SIMD 指令集進行反轉。
(3)、如果不支持 256 位向量,但支持 128 位向量(Vector128),則使用 128 位的 SIMD 指令集進行反轉。
2、在每個 SIMD 操作的循環中:
(1)、通過 Vector.LoadUnsafe方法加載向量的值。
(2)、使用適當的指令(Vector.Shuffle或Avx2.PermuteVar8x32將向量中的元素進行反轉。
(3)、通過 Vector.StoreUnsafe方法將反轉後的向量值存儲回數組中。
3、如果硬件不支持 SIMD 或數組長度不足以使用 SIMD:
(1)、調用 ReverseInner方法,該方法使用普通的循環來反轉剩餘的數組元素。
對於List<T>中的Reverse()實現數組的反轉方法,通過充分利用硬件的 SIMD 指令集,以高效的方式對數組進行反轉。在逐個元素反轉的情況下,傳統的循環操作會變得相對慢,而 SIMD 指令集可以同時處理多個元素,提高了反轉的效率。這對於處理大型數組時,可以顯著提升性能。
本文截止到當前對List<T>集合的初始化、元素插入(Add、Insert)、集合元素的排序、集合元素的反轉等幾個視角進行了簡單的介紹。我們從上面的描述和C#中的List<T>實現源碼中不難發現,無論是什麼數據結構,其內部的實現都是由相對簡單的方式和巧妙的技巧維護着高效的性能。
以上內容是對C#List<T>源碼的簡單解讀,如錯漏的地方,還望指正。