C#中List底层源码剖析
前言
- 面试中,List源码是常考项,很有了解的必要。
- List在游戏开发中的使用非常频繁,比如玩家列表、敌人列表、消息队列、物体池等。
- 由于List特别好用,就导致会出现滥用的情况,然而滥用List会出现一些问题,下文讲解List具体模块的时候会说明。
- 希望大家能养成多思考底层原理的习惯。在了解了这些组件在底层是如何运作之后,再次编写代码时,能有意识地去理解背后的执行步骤,更好地提升代码质量。
List底层源码剖析
这里先贴一下List源码网址:https://referencesource.microsoft.com/#mscorlib/system/collections/generic/list.cs
List的常用接口如下表所示。
接口 | 功能 |
---|---|
Add(T item) | 添加元素 |
AddRange(IEnumerable collection) | 将指定集合的元素添加到List 的末尾。参数是一个实现了 IEnumerable<T> 接口的集合,也可以是数组等可枚举的数据类型 |
Remove(T item) | 移除元素 |
Inset(T item) | 插入元素 |
[] | 获取给定索引处的元素 |
Clear() | 清除列表的内容 |
Contains(T item) | 是否包含该元素 |
ToArray() | 返回一个新的数组 |
Find(Predicate match) | 用于查找满足指定条件的第一个元素,Predicate<T> 是一个表示对指定对象是否满足条件的方法的委托 |
Enumerator() | 返回一个实现了 IEnumerator<T> 接口的枚举器对象。这个枚举器对象负责在集合上进行迭代,允许你按顺序访问集合中的每个元素 |
Sort() | 对所有元素进行排序 |
构造部分
首先我们来看构造部分的源码,这部分比较简单,字段解析就看注释好了:
public class List<T> : IList<T>, System.Collections.IList, IReadOnlyList<T>
{
//表示列表的默认容量,即在构造函数中未指定容量时使用的值。
private const int _defaultCapacity = 4;
//表示列表的元素数组,用于存储列表中的元素。
private T[] _items;
//表示列表中的元素数量,即实际存储在_items数组中的元素数量。
private int _size;
//表示列表的版本号。
private int _version;
//表示同步根对象,用于实现多线程同步。
private Object _syncRoot;
//表示一个空的泛型数组,用于在创建空列表时返回一个共享的空数组对象,以避免浪费内存。
static readonly T[] _emptyArray = new T[0];
//构建一个列表,列表最初是空的,容量为0
public List() {
_items = _emptyArray;
}
//构造具有给定初始容量的列表。
public List(int capacity) {
if (capacity < 0) ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.capacity, ExceptionResource.ArgumentOutOfRange_NeedNonNegNum);
Contract.EndContractBlock();
if (capacity == 0)
_items = _emptyArray;
else
_items = new T[capacity];
}
}
源码中有一个_version
字段,表示的是当前List值的一个版本号,每次对List进行增删改操作都会使其对_version++,其主要作用是在对List进行foreach时,判断是否有进行修改,如果版本号对不上,C#会抛出 InvalidOperationException的异常。这也就是我们在foreach中不能对相关变量进行更改的原因。
看构造部分,我们明确了List内部是用数组实现的,而不是链表,也就是说,在对List进行Add和Remove操作时,都是采用**在数组上对元素进行转移的操作,或者从原数组复制生成到新数组。**并且当没有给予指定容量时,初始容量为0。
Add接口
//将给定的对象添加到次列表的末尾,列表的大小加一
public void Add(T item) {
if (_size == _items.Length) EnsureCapacity(_size + 1);
_items[_size++] = item;
_version++;
}
//如果列表的当前容量小于min,则容量将增加到当前容量的两倍或min,以较大者为准
private void EnsureCapacity(int min) {
if (_items.Length < min) {
int newCapacity = _items.Length == 0? _defaultCapacity : _items.Length * 2;
//在遇到溢出之前,允许列表增长到最大可能的容量(约2GB元素)
//注意,即时_items.Length由于(unit)强制转换而溢出,此检查仍然有效
if ((uint)newCapacity > Array.MaxArrayLength) newCapacity = Array.MaxArrayLength;
if (newCapacity < min) newCapacity = min;
Capacity = newCapacity;
}
}
Add接口涉及到Add方法和EnsureCapacity方法。
Add方法:
Add
方法用于向List<T>
中添加一个元素。- 首先,它检查当前
_size
是否等于数组_items
的长度,如果相等,说明数组已满,需要调用EnsureCapacity
方法来确保有足够的容量来存储新的元素。 _items[_size++] = item;
将新元素添加到数组中,并将_size
增加。这里使用后缀递增操作符_size++
是为了在添加元素后更新_size
的值。_version++
用于追踪对List<T>
结构的修改。
EnsureCapacity方法:
EnsureCapacity
方法用于确保_items
数组有足够的容量来存储至少min
个元素。- 首先,它检查当前数组的长度是否小于
min
,如果是,则需要扩展数组的容量。 - 计算新的容量
newCapacity
,如果数组长度为零,则使用_defaultCapacity
,否则将对数组进行扩容,需要注意的是,每次容量不够的时候,整个数组的容量都会扩充一倍,_defaultCapacity指的是默认容量,在构造部分中我们知道这个值为4,因此整个扩充的路线是4、8、16、32、64、128······依次类推。。 - 使用
(uint)newCapacity > Array.MaxArrayLength
来检查是否溢出,如果超过了Array.MaxArrayLength
,则将容量设置为Array.MaxArrayLength
。 - 然后,检查
newCapacity
是否小于min
,如果是,则将容量设置为min
。 - 最后,通过
Capacity
属性将数组的容量设置为新的容量。
优缺点:
List使用数组作为底层数据结构,优点是使用索引方式提取元素很快。缺点是在扩容的时候会很糟糕,每次针对数组进行new操作,都会造成内存垃圾,这给垃圾回收(GC)带来了很大负担。
这里按2的指数扩容的方式,可以为GC减轻负担,但是,如果数组被连续申请扩容,还是会造成不小的GC负担,特别是代码中频繁使用List.Add时,数组会不断被扩容。此外,如果使用不当,也会浪费大量的内存空间,例如:当元素的数量为520时,List就会扩容到1024个元素,如果不使用剩余的504个空间单位,就会造成大量的浪费。
Remove接口
//从列表中移除指定的元素。
public bool Remove(T item) {
int index = IndexOf(item);
if (index >= 0) {
RemoveAt(index);
return true;
}
return false;
}
//查找指定元素在列表中的索引。
public int IndexOf(T item) {
Contract.Ensures(Contract.Result<int>() >= -1);
Contract.Ensures(Contract.Result<int>() < Count);
return Array.IndexOf(_items, item, 0, _size);
}
//删除给定索引处的元素。
public void RemoveAt(int index) {
if ((uint)index >= (uint)_size) {
ThrowHelper.ThrowArgumentOutOfRangeException();
}
Contract.EndContractBlock();
_size--;
if (index < _size) {
Array.Copy(_items, index + 1, _items, index, _size - index);
}
_items[_size] = default(T);
_version++;
}
Remove接口有三个部分, 分别是Remove,IndexOf,RemoveAt
Remove方法:
Remove
方法用于从列表中移除指定的元素。- 首先,通过调用
IndexOf
方法查找元素在列表中的索引。 - 如果索引大于等于0,表示找到了元素,然后调用
RemoveAt
方法删除该索引处的元素,并返回true
。 - 如果索引小于0,表示列表中不存在该元素,直接返回
false
。
IndexOf方法:
IndexOf
方法用于查找指定元素在列表中的索引。- 使用
Array.IndexOf
方法在数组_items
中查找元素的索引,从索引0开始,到_size - 1
结束。 Contract.Ensures
是代码合同中的一部分,用于指定方法的后置条件,确保返回值在范围[0, Count - 1]
。Contract.Ensures
具体来说:Contract.Ensures(Contract.Result<int>() >= -1);
这个语句表明方法执行结束后,返回值应该>= -1(如果元素不存在返回-1),返回值通过Contract.Result<int>()
获取;Contract.Ensures(Contract.Result<int>() < Count);
这个语句表明方法执行结束后,返回值应该小于列表的元素数量(Count
属性)。这是因为索引的有效范围是[0, Count - 1]
,所以返回值应该在这个范围内。
RemoveAt方法:
RemoveAt
方法用于删除给定索引处的元素。- 首先,检查索引是否越界,如果超出有效范围,抛出
ArgumentOutOfRangeException
异常。 Contract.EndContractBlock();
指代码合同结束,由于IndexOf方法中Contract.Ensures
语句设置了方法的后置条件,所以这里需要用到Contract.EndContractBlock();
表示该块合同的结束,然后执行实际的方法调用return Array.IndexOf(_items, item, 0, _size);
。_size--
用于减小列表的大小。- 如果删除的不是最后一个元素,使用
Array.Copy
将位于索引之后的元素向前移动一个位置,覆盖被删除的元素位置。 - 将最后一个元素设置为默认值(
default(T)
),以便释放引用并协助垃圾回收。 _version++
用于追踪对List<T>
结构的修改。
从源码中可以看到,元素删除的原理就是使用Array.Copy对数组进行覆盖,IndexOf是用Array.IndexOf接口来查找元素的索引位置,这个接口本身的内部就是按索引顺序从0到n对每个位置进行比较,复杂度为线性迭代O(n)。
Insert接口
public void Insert(int index, T item) {
//请注意,结尾处的插入是合法的
//检查插入的索引是否在合法范围内。
if ((uint) index > (uint)_size) {
ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.index, ExceptionResource.ArgumentOutOfRange_ListInsert);
}
//表示合同块的结束
Contract.EndContractBlock();
//确保容量足够,不够则调用EnsureCapacity方法,在Add接口中有详解过此方法。
if (_size == _items.Length) EnsureCapacity(_size + 1);
//如果插入的位置在当前元素范围内(即 index < _size),则使用 Array.Copy 将从插入位置开始的元素向后移动一个位置,为新元素腾出空间。
if (index < _size) {
Array.Copy(_items, index, _items, index + 1, _size - index);
}
//插入新元素
_items[index] = item;
//更新元素数量和版本号
_size++;
_version++;
}
详见注释。
与Add接口一样,先检查容量是否足够,不足则扩容。从上面源码中发现,使用Insert插入元素时,使用的是复制数组的形式,将数组里指定元素后面的所有元素向后移动一个位置。
[]接口
public T this[int index] {
get {
if ((uint) index >= (uint)_size) {
ThrowHelper.ThrowArgumentOutOfRangeException();
}
Contract.EndContractBlock();
return _items[index];
}
set {
if ((uint) index >= (uint)_size) {
ThrowHelper.ThrowArgumentOutOfRangeException();
}
Contract.EndContractBlock();
_items[index] = value;
_version++;
}
}
get
方法用于获取索引处的元素值。- 首先,通过条件判断检查索引的合法性。如果索引小于 0 或者大于等于当前元素数量
_size
,则抛出ArgumentOutOfRangeException
异常。 Contract.EndContractBlock();
表示合同块的结束,然后返回索引处的元素值。set
方法用于设置索引处的元素值。- 同样,通过条件判断检查索引的合法性。如果索引小于 0 或者大于等于当前元素数量
_size
,则抛出ArgumentOutOfRangeException
异常。 Contract.EndContractBlock();
表示合同块的结束,然后将索引处的元素值设置为新值,并递增_version
,用于追踪对List<T>
结构的修改。
Clear接口
public void Clear() {
if (_size > 0)
{
Array.Clear(_items, 0, _size);
_size = 0;
}
_version++;
}
Clear接口是清除数组的接口,在调用时并不会删除数组,而只是将数组中的元素设置为0或者NULL,并设置_size为0而已,用于虚拟的表明当前容量为0。
有时候会认为对数组执行清零操作也是多余的,因为我们并不关心不使用的数组元素中的对象,但如果不清零,对象的引用会依然被标记,那么垃圾回收器会认为该元素依然是被引用的,造成GC压力和内存浪费,所以对数组执行清零操作是有必要的。
Contains接口
public bool Contains(T item) {
if ((Object) item == null) {
for(int i=0; i<_size; i++)
if ((Object) _items[i] == null)
return true;
return false;
}
else {
EqualityComparer<T> c = EqualityComparer<T>.Default;
for(int i=0; i<_size; i++) {
if (c.Equals(_items[i], item)) return true;
}
return false;
}
}
以上源码可以看到,Contains接口是使用线性查找方式比较元素,对数组执行循环操作,比较每个元素与参数实例是否一致,如果一致则返回true,全部比较结束后还没找到则认为查找失败。
ToArray接口
public T[] ToArray() {
Contract.Ensures(Contract.Result<T[]>() != null);
Contract.Ensures(Contract.Result<T[]>().Length == Count);
T[] array = new T[_size];
Array.Copy(_items, 0, array, 0, _size);
return array;
}
ToArray接口将List转化为数组,根据源码可知,它重新创建了一个指定大小的数组,将本身数组上的数据复制到新数组上再返回,如果使用过多,就会造成大量内存的分配,在内存上留下很多无用的垃圾。
Find接口
public T Find(Predicate<T> match) {
if( match == null) {
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.match);
}
Contract.EndContractBlock();
for(int i = 0 ; i < _size; i++) {
if(match(_items[i])) {
return _items[i];
}
}
return default(T);
}
Find接口是查找接口,它使用的同样是线性查找方式,对每个元素进行循环比较,复杂度为O(n)
Enumerator接口
//这个方法用于返回列表的枚举器。在 List<T> 类中,通常通过 foreach 循环来使用这个枚举器。
public Enumerator GetEnumerator() {
return new Enumerator(this);
}
//显式实现了 IEnumerable<T> 接口的 GetEnumerator 方法,该方法返回与 List<T> 关联的枚举器。
IEnumerator<T> IEnumerable<T>.GetEnumerator() {
return new Enumerator(this);
}
//显式实现了 IEnumerable 接口的 GetEnumerator 方法,该方法返回与 List<T> 关联的枚举器。
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() {
return new Enumerator(this);
}
//实现了 IEnumerator<T> 接口和 System.Collections.IEnumerator 接口,提供了对列表元素的遍历功能。
[Serializable]
public struct Enumerator : IEnumerator<T>, System.Collections.IEnumerator
{
private List<T> list;
private int index;
private int version;
private T current;
internal Enumerator(List<T> list) {
this.list = list;
index = 0;
version = list._version;
current = default(T);
}
public void Dispose() {
}
//MoveNext 用于移动到下一个元素,返回 true 表示成功移动,false 表示已经到达列表的末尾。实现中使用了版本控制,以确保在枚举期间列表没有被修改。如果版本号匹配且索引在有效范围内,将当前元素设置为列表中的下一个元素,索引递增,返回 true。否则,调用 MoveNextRare 处理。
public bool MoveNext() {
List<T> localList = list;
if (version == localList._version && ((uint)index < (uint)localList._size))
{
current = localList._items[index];
index++;
return true;
}
return MoveNextRare();
}
//MoveNextRare 用于处理在特殊情况下移动到下一个元素的逻辑。如果版本号不匹配,抛出异常。将索引移到列表末尾的下一个位置,将当前元素设置为默认值(default(T)),返回 false,表示已经到达列表的末尾。
private bool MoveNextRare()
{
if (version != list._version) {
ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EnumFailedVersion);
}
index = list._size + 1;
current = default(T);
return false;
}
//用于获取当前位置的元素值。
public T Current {
get {
return current;
}
}
//显式实现 System.Collections.IEnumerator 接口的 Current 属性,用于获取当前位置的元素值。在获取之前,检查索引是否在有效范围内,如果不在有效范围内,抛出异常。
Object System.Collections.IEnumerator.Current {
get {
if( index == 0 || index == list._size + 1) {
ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EnumOpCantHappen);
}
return Current;
}
}
//显式实现 System.Collections.IEnumerator 接口的 Reset 方法,将枚举器重置到起始位置。在重置之前,检查版本号是否匹配,如果不匹配,抛出异常。将索引设置为 0,当前元素设置为默认值。
void System.Collections.IEnumerator.Reset() {
if (version != list._version) {
ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EnumFailedVersion);
}
index = 0;
current = default(T);
}
}
源码中要注意的是Enumerator这个结构体,每次获取迭代器时,Enumerator都会被创建出来,如果大量使用迭代器,比如foreach,就会产生大量的垃圾对象,这也是为什么尽量不要使用foreach,因为List的foreach会增加新的Enumerator实例,最后由GC单元将垃圾回收掉。虽然.NET在4.0后已经修复了这个问题,但仍不建议大量使用foreach。
Sort接口
public void Sort(int index, int count, IComparer<T> comparer) {
if (index < 0) {
ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.index, ExceptionResource.ArgumentOutOfRange_NeedNonNegNum);
}
if (count < 0) {
ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.count, ExceptionResource.ArgumentOutOfRange_NeedNonNegNum);
}
if (_size - index < count)
ThrowHelper.ThrowArgumentException(ExceptionResource.Argument_InvalidOffLen);
Contract.EndContractBlock();
Array.Sort<T>(_items, index, count, comparer);
_version++;
}
我们可以看到,List中的Sort用的是Array.Sort接口进行排序,它使用的是快速排序方式进行排序,所以List的Sort排序效率为O(nlogn)
总结
以上把List大部分接口都列举出来了,也对列举出来的接口进行了具体分析,可以看到,List的效率其实并不高,只是通用性强而已,大部分算法使用的是线性复杂度的算法,当遇到规模比较大的计算量级时,这种线性算法会导致CPU的内存大量损耗。当然,知道了问题所在,我们可以自己根据实际需求对其进行改造,比如不再使用有线性算法的接口,自己重写一套,但凡要优化List中线性算法的地方,都是用我们自己制作的容器类。
List的内存分配也极为不合理,当List中的元素不断增加时,会多次重新分配数组,导致原来的数组被抛弃,最后当GC被调用时就会造成回收的压力。为了避免这种情况,我们可以在创建List时,提前告知List对象最多会有多少元素在里面,这样List就不会因为空间不够而抛弃原有数组去重新申请数组了。
最后,List并不是高效的组件,真实情况是,它比数组的效率还要差,它只是一个兼容性比较强的组件而已,好用,但效率不高。