从源码的角度刨析ArrayList

159 阅读5分钟

大家好,我是桑小榆,前段时间有碰见一些开发者,发现它们面试的时候很少追究集合这一块的内容,对ArrayList、HashTable、HashMap的底层其实并不是很清楚,让我有些大为震惊。故此篇以C#语言为背景去深挖集合之一的ArrayList的底层实现。


什么是ArrayList,它是用来干嘛的?

它是一个可变大小的列表,该列表使用一个对象数组来存储元素。

ArrayList有一个容量,即分配的长度内部数组的。

当元素添加到ArrayList时,容量通过重新分配内部阵列。

通俗点来说就是,ArrayList就是一个数组列表,主要用来装载数据,允许装载 int long  boolean short byte等类型,一旦装上任何一种类型的时候只能存储他们对应的包装类。

它的底层实现就是 Object[] emptyArray,就是通过数组实现的存储。

与它类似的LinkedList相比下,它对元素的查找和访问速度会较快,但是新增,删除上的速度较慢。

所以ArrayList拥有以下特点:查询效率高,增删效率低,线程不安全。但是使用频率很高。

那么,为啥它线程不安全,使用频率还这么高呢?

就是因为我们在生产过程中啊,普遍查询比较多,不会涉及太频繁的增删,\

如果涉及频繁的增删,也是可以使用LinkedList,如果使用线程安全,微软也提供了BlockingCollection 以及Concurrent集合类。

当然,不存在一个集合工具查询效率又高,增删效率也高,还线程安全的。因为数据结构的特性就是优劣共存的,想找一个平衡点很难,牺牲了部分提高另一部分,牺牲了安全速度就要提高上去。

前面我们有分析过,ArrayList的底层实现是数组,但是数组我们知道是定长的,如果不断Add添加数组进去,不会出问题吗?

首先我们来看看源码。

internal ArrayList(bool trash)  { }

public ArrayList()
  {
    _items = emptyArray;
  }
public ArrayList(int capacity)
  {
    if (capacity < 0throw new ArgumentOutOfRangeException("capacity", Environment.GetResourceString("ArgumentOutOfRange_MustBeNonNegNum""capacity"));
    Contract.EndContractBlock();

    if (capacity == 0)
        _items = emptyArray;
    else
        _items = new Object[capacity];
  }

ArrayList是可以通过构造方法初始化的时候指定数组层的大小的。

通过无参构造方式使得ArrayList()初始化,则赋值底层Object[] emptyArray 为一个默认空数组,emptyArray赋值就是一个 new T[0] 的一个空数组。

internal static class EmptyArray<T>
{
    public static readonly T[] Value = new T[0];
}

只有真正对ArrayList集合使用Add添加数据时,才会默认分配\


private const int _defaultCapacity = 4;

我们也可以对比下源码的有参和无参的构造 方法对应的赋值。

public ArrayList()
  {
    _items = emptyArray;
  }
public ArrayList(int capacity)
  {
    if (capacity < 0throw new ArgumentOutOfRangeException("capacity", Environment.GetResourceString("ArgumentOutOfRange_MustBeNonNegNum""capacity"));
    Contract.EndContractBlock();

    if (capacity == 0)
        _items = emptyArray;
    else
        _items = new Object[capacity];
  }

数组的长度是有限的,而ArrayList可以放任意数量的对象,且长度不受限,那么它是怎么实现的呢?

其实现方式也比较简单,就是通过数组扩容的方式去实现的。

首先从源码分析可以看到,每一次Add添加数据的时候都会去判断长度是否超过给定最小值,如果超过了的话,就会进行调用EnsureCapacity(_size + 1) 进行扩容。

public virtual int Add(Object value)
        {
            Contract.Ensures(Contract.Result<int>() >= 0);
            if (_size == _items.Length) EnsureCapacity(_size + 1);
            _items[_size] = value;
            _version++;
            return _size++;
        }

每一次扩容,都会按默认长度扩容或者当前长度的2倍进行扩容。

private void EnsureCapacity(int min) {
    if (_items.Length < min) 
    {
      int newCapacity = _items.Length == 0? _defaultCapacity: _items.Length * 2;
              
      if ((uint)newCapacity > Array.MaxArrayLength) newCapacity = Array.MaxArrayLength;
      if (newCapacity < min) newCapacity = min;
    Capacity = newCapacity;
    }

进行扩容之后的数组是重新定义一个的长度为当前长度,比如为 10,那扩容之后的长度为10 *2 的长度,然后把原数组的长度进行原封不动的拷贝到长度为20的数组里,这个时候把原数的地址换到新数组,ArrayList就是这样完成了扩容。

了解C#的朋友会发现,使用ArrayList 的时候会有一个AddRange的方法,一个批量添加的方法,那它是如何实现的呢?

 public virtual void InsertRange(int index, ICollection c) {
            if (c==null)
                throw new ArgumentNullException("c", Environment.GetResourceString("ArgumentNull_Collection"));
            if (index < 0 || index > _size) throw new ArgumentOutOfRangeException("index", Environment.GetResourceString("ArgumentOutOfRange_Index"));
            //Contract.Ensures(Count == Contract.OldValue(Count) + c.Count);
            Contract.EndContractBlock();
 
            int count = c.Count;
            if (count > 0) {
                EnsureCapacity(_size + count);                
                // shift existing items
                if (index < _size) {
                    Array.Copy(_items, index, _items, index + count, _size - index);
                }
 
                Object[] itemsToInsert = new Object[count];
                c.CopyTo(itemsToInsert, 0);
                itemsToInsert.CopyTo(_items, index);
                _size += count;
                _version++;
            }
        }

从源码我们可以了解到,它依然是一个数组操作,它会在数组的末尾添加批量元素,如果有需要将会进行2倍的扩容,然后使用Copy的方法将元素添加到数组末尾。

以上操作我们就能看明白,新增和删除为啥会慢了。

ArrayList 有指定新增和直接新增的方式,两者在插入之前都有一步校验长度

EnsureCapacity来决定是否需要扩容。

public virtual void Insert(int index, Object value) {
            // Note that insertions at the end are legal.
            if (index < 0 || index > _size) throw new ArgumentOutOfRangeException("index", Environment.GetResourceString("ArgumentOutOfRange_ArrayListInsert"));
            //Contract.Ensures(Count == Contract.OldValue(Count) + 1);
            Contract.EndContractBlock();
 
            if (_size == _items.Length) EnsureCapacity(_size + 1);
            if (index < _size) {
                Array.Copy(_items, index, _items, index + 1, _size - index);
            }
            _items[index] = value;
            _size++;
            _version++;
        }

在指定位置新增的时候,在长度校验之后进行数组的copy。

我们也可以使用图来说明:

图片

图 1.1 按意图想在第4的指定位置插入一个“A”的元素。

图 1.2 可见复制了一个数组,在index为4的位置开始的,然后把他它放在了index+1的位置,给新元素腾出了位置。

图 1.3 就是一个完整指定插入的元素后的集合了。

以上的一个操作,我们不免想象,在一个很小的集合里如此操作并不觉得慢多少,但是如果这个集合很庞大呢?将会反复复制挪移位置涉及容等等,岂不是更慢了吗?

接下来将会问一个真实场景,且很少人知道的:ArrayList(int initCapacity)

会不会初始化数组的大小?

首先回答是,会初始化大小,但是List的大小没有改变,因为list的大小是返回Capacity容量大小的。

并且将构造函数和初始化Capacity结合使用的话,然后insert()将会抛出异常,尽管该数组已经创建,但是它的大小设置是不正确的。

尽管使用EnsureCapacity()也不起作用,因为它是基于emptyArray数组而不是大小。

还有其他副作用,就是因为带有EnsureCapacity()的常量 _defaultCapacity。

如果需要解决此问题那就是构造函数之后根据实际需求使用add()多次。

可能有点懵吧,于是我手操作了一遍,首先我已经设置了ArrayList初始大小为 8。但是打印的时候 List的大小是0,容量Capacity是8。但是我在Insert()的时候是会报错,数组下标越界。

static void Main(string[] args)
        {
            ArrayList array = new ArrayList(8);
            Console.WriteLine(array.Count);
            Console.WriteLine(array.Capacity);
            array.Insert(4,1);
        }

图片

数组其实已经初始化了,但是List没有,那它的size就是没有变,Insert下标是和size比较的那就会报错了。

这也是ArrayList的一个经典问题,望各位周知。

那接下来我们看看,ArrayList的删除是怎么实现的呢?

删除的操作跟新增类似,不过从源码来看也不叫删除,就是通过copy一个数组进行覆盖。

 public virtual void RemoveAt(int index) {
            if (index < 0 || index >= _size) throw new ArgumentOutOfRangeException("index", Environment.GetResourceString("ArgumentOutOfRange_Index"));
            Contract.Ensures(Count >= 0);
            //Contract.Ensures(Count == Contract.OldValue(Count) - 1);
            Contract.EndContractBlock();
 
            _size--;
            if (index < _size) {
                Array.Copy(_items, index + 1, _items, index, _size - index);
            }
            _items[_size] = null;
            _version++;
        }

我们也可以用图来解释他的内部运转。

图片

比如我们要对图 1.1 删除index 4 的位置。

那它将会复制一个index 4 + 1 开始到最后的数组,然后把他放到index开始的位置。

index4的位置就完成了“删除”,其本质我们可以看到是被覆盖了。

那么,ArrayList是线程安全的吗?

显然不是,线程安全的话微软提供了SyncArrayList版本的。

而SyncArrayList的实现就是,把ArrayList所有的操作都加上了lock锁。

当然也可以不使用SyncArrayList,也可以使用BlockingCollection 以及Concurrent集合类把一个普通的ArrayList包装成了一个线程安全版本的数组容器。

那么,ArrayList适合用来做队列吗?

队列的特点一般是FIFO(先入先出),如果使用ArrayList做队列的话,就需要在数组的尾部追加数组,数组的头部移除数组,反过来也一样。

但是总会出现一个操作会涉及到数组的数据扩容搬迁过程,就比较耗性能了。

所以,ArrayList是不适合做队列的。

那数组适合用来做队列吗?

数组是非常合适做队列的。比如它可以作为一个定长队列,来实现一个环形队列来满足的。

通俗点就是两个偏移量标记数组的读位置和写位置,如果超过长度就折回到数组开头,前提就是要定长数组。\

试试比较ArrayList遍历和LinkedList遍历性能如何?

是遍历ArrayList要比LinkedList快的多,ArrayList遍历最大的优势就在于内存的连续性,CPU的内部缓存结构会缓存连续的内存片段,可以大幅度减少读取内存的性能开销。

最终我们来总结下:

ArrayList其实就是一个动态数组,类似一个Array的复杂版本,它提供了动态的增加和减少元素,实现了ICollection和IList接口,灵活的设置数组的大小等功能。

ArrayList常用方法总结:

int Add(Object value)//直接添加元素。

void Insert(int index,Object e);
//向指定的位置插入元素。

void AddRange(ICollection c);
//批量添加元素。

Object Clone();
//克隆此ArrayList,进行浅拷贝。

bool Contains(Object item);
//判断集合中是否包含指定元素。

Object this[int index] 
//返回指定集合中的元素。

int IndexOf(Object value);
//返回此列表中首次出现的指定元素的索引,或者此列表不包含元素,则返回-1.

int LastIndexOf(Object value);
//返回此列表中最后一次出现的指定元素的索引,或者此列表不包含元素,则返回-1.

void Remove(Object obj);
//移除给定索引处的元素,如果存在的话

void RemoveRange(int index, int count);
//从此列表中删除一系列元素。

int Count();
//返回列表中的元素数

void EnsureCapacity(int min);
//如有必要,增加数组实例的容量,以确保至少能够容纳最小容量参数所指定的元素数。

IList FixedSize(IList list);
//返回固定为当前大小的列表包装。操作添加或删除项目将失败,但允许替换项目。

好了,关于ArrayList的原理以及细节都处理完了,能看到这里的人也不是一般的大佬,后面也会逐渐更新更多从源码分析的角度来讲解知识。