C#中List底层代码剖析

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并不是高效的组件,真实情况是,它比数组的效率还要差,它只是一个兼容性比较强的组件而已,好用,但效率不高。

  • 26
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值