深入解析C#中List的存储结构
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>源码的简单解读,如错漏的地方,还望指正。