容器学习之01ArrayList

1. 简介

ArrayList 我们几乎每天都会使用到,但关于ArrayList的细节我们是否真正关注过?本文大家一起通过源码来重新认识ArrayList。
ArrayList顾名思义,其内部是用数组来存放数据。在初始化时,会为我们生成一个默认大小的数组。往容器里添加数据,其实就是在往数组里add。
在ArrayList的类注释中明确写道:

  • 允许 put null 值,会自动扩容
  • size、isEmpty、get、set、add 等方法时间复杂度都是 O (1);
  • 是非线程安全的,多线程情况下,推荐使用线程安全类:Collections#synchronizedList;
  • 增强 for 循环,或者使用迭代器迭代过程中,如果数组大小被改变,会快速失败,抛出异常。

而ArrayList类图如下:
arrayList类图

2. 简单事例

public static void main(String[] args) {
        ArrayList<Integer> arrayList = new ArrayList();
        arrayList.add(1);
        arrayList.add(2);
        arrayList.add(3);
        Iterator<Integer> iterator = arrayList.iterator();
        while (iterator.hasNext()) {
            System.out.println(iterator.next());
        }
    }

上面中,并没有使用大家熟悉的for循环,而是使用自带的iterator,通过while循环输出list内容。

3. 源码分析

3.1 构造函数

ArrayList构造函数有三种:无参数直接初始化、指定大小初始化、指定初始数据初始化,源码及注释如下:

  private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
//指定初始化数组大小
 public ArrayList(int initialCapacity) {
        if (initialCapacity > 0) {
            this.elementData = new Object[initialCapacity];
        } else if (initialCapacity == 0) {
            this.elementData = EMPTY_ELEMENTDATA;
        } else {
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        }
    }

    //默认初始化空数组
    public ArrayList() {
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }

   //根据传入的集合初始化,元素顺序是按照,传入的集合顺序指定的
    public ArrayList(Collection<? extends E> c) {
        elementData = c.toArray();
        if ((size = elementData.length) != 0) {
            if (elementData.getClass() != Object[].class)
                elementData = Arrays.copyOf(elementData, size, Object[].class);
        } else {
            this.elementData = EMPTY_ELEMENTDATA;
        }
    }

除了源码的中文注释,还需要注意: ArrayList 无参构造器初始化时,默认大小是空数组,并不是大家常说的 10,10 是在第一次 add 的时候扩容的数组值。

3.2 新增和扩容实现

新增就是往数组中添加元素,主要分成两步:

  • 判断是否需要扩容,如果需要执行扩容操作;
  • 直接赋值。
public boolean add(E e) {
  //确保数组大小是否足够,不够执行扩容,size 为当前数组的大小
  ensureCapacityInternal(size + 1);  // Increments modCount!!
  //直接赋值,线程不安全的
  elementData[size++] = e;
  return true;
}

扩容代码如下:

private void ensureCapacityInternal(int minCapacity) {
  //如果初始化数组大小时,有给定初始值,以给定的大小为准,不走 if 逻辑
  if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
    minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
  }
  //确保容积足够
  ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
  //记录数组被修改
  modCount++;
  // 如果我们期望的最小容量大于目前数组的长度,那么就扩容
  if (minCapacity - elementData.length > 0)
    grow(minCapacity);
}
//扩容,并把现有数据拷贝到新的数组里面去
private void grow(int minCapacity) {
  int oldCapacity = elementData.length;
  // oldCapacity >> 1 是把 oldCapacity 除以 2 的意思
  int newCapacity = oldCapacity + (oldCapacity >> 1);

  // 如果扩容后的值 < 我们的期望值,扩容后的值就等于我们的期望值
  if (newCapacity - minCapacity < 0)
    newCapacity = minCapacity;

  // 如果扩容后的值 > jvm 所能分配的数组的最大值,那么就用 Integer 的最大值
  if (newCapacity - MAX_ARRAY_SIZE > 0)
    newCapacity = hugeCapacity(minCapacity);
 
  // 通过复制进行扩容
  elementData = Arrays.copyOf(elementData, newCapacity);
}

我们还需要注意的四点是:

  • 扩容的规则并不是翻倍,是原来容量大小 + 容量大小的一半,直白来说,扩容后的大小是原来容量的 1.5 倍;

  • ArrayList 中的数组的最大值是 Integer.MAX_VALUE,超过这个值,JVM 就不会给数组分配内存空间了。

  • 新增时,并没有对值进行严格的校验,所以 ArrayList 是允许 null 值的。

从新增和扩容源码中,下面这点值得我们借鉴:

  • 源码在扩容的时候,有数组大小溢出意识,就是说扩容后数组的大小下界不能小于 0,上界不能大于 Integer 的最大值,这种意识我们可以学习。
  • 扩容完成之后,赋值是非常简单的,直接往数组上添加元素即可:elementData [size++] = e。也正是通过这种简单赋值,没有任何锁控制,所以这里的操作是线程不安全的,对于新增和扩容的实现,画了一个动图,如下:
    扩容动图
3.3 扩容的本质

数组的扩容,最底层是通过public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length);这段代码实现的,它被native 修饰,表示这是JVM提过我们的放法,实现是由JVM来实现,我们不需要关心。

3.4 删除

ArrayList 删除元素有很多种方式,比如根据数组索引删除、根据值删除或批量删除等等,原理和思路都差不多,我们选取根据值删除方式来进行源码说明:

public boolean remove(Object o) {
  // 如果要删除的值是 null,找到第一个值是 null 的删除
  if (o == null) {
    for (int index = 0; index < size; index++)
      if (elementData[index] == null) {
        fastRemove(index);
        return true;
      }
  } else {
    // 如果要删除的值不为 null,找到第一个和要删除的值相等的删除
    for (int index = 0; index < size; index++)
      // 这里是根据  equals 来判断值相等的,相等后再根据索引位置进行删除
      if (o.equals(elementData[index])) {
        fastRemove(index);
        return true;
      }
  }
  return false;
}
private void fastRemove(int index) {
  // 记录数组的结构要发生变动了
  modCount++;
  // numMoved 表示删除 index 位置的元素后,需要从 index 后移动多少个元素到前面去
  // 减 1 的原因,是因为 size 从 1 开始算起,index 从 0开始算起
  int numMoved = size - index - 1;
  if (numMoved > 0)
    // 从 index +1 位置开始被拷贝,拷贝的起始位置是 index,长度是 numMoved
    System.arraycopy(elementData, index+1, elementData, index, numMoved);
  //数组最后一个位置赋值 null,帮助 GC
  elementData[--size] = null;
}

我们需要注意的是:

  • 新增的时候是没有对 null 进行校验的,所以删除的时候也是允许删除 null 值的;
  • 找到值在数组中的索引位置,是通过 equals 来判断的,这说明如果是自定义类型,则需要我们去确认自定义类型的equals 放法。
    下面一个gif演示其过程:
    删除动图
3.5 迭代器

如果要自己实现迭代器,实现 java.util.Iterator 类就好了,ArrayList 也是这样做的,我们来看下迭代器的几个总要的参数:

// 迭代过程中,下一个元素的位置,默认从 0 开始。
int cursor;
 // 新增场景:表示上一次迭代过程中,索引的位置;删除场景:为 -1。
int lastRet = -1;
// expectedModCount 表示迭代过程中,期望的版本号;modCount 表示数组实际的版本号。
int expectedModCount = modCount;

迭代器一般来说有三个方法:

  • boolean hasNext(); 还有没有值可以迭代,返回bool
  • E next() 如果有值可以迭代,迭代的值是多少,返回Object
  • void remove() 删除当前迭代的值

下面是ArrayList迭代器源码:

 private class Itr implements Iterator<E> {
        int cursor;       // index of next element to return
        int lastRet = -1; // index of last element returned; -1 if no such
        int expectedModCount = modCount;

        public boolean hasNext() {
        	//cursor 表示下一个元素的位置,size 表示实际大小,
        	//如果两者相等,说明已经没有元素可以迭代了,如果不等,说明还可以迭代
            return cursor != size;
        }

        @SuppressWarnings("unchecked")
        public E next() {
		  //迭代过程中,判断版本号有无被修改,有被修改,抛 ConcurrentModificationException 异常
		  checkForComodification();
		  //本次迭代过程中,元素的索引位置
		  int i = cursor;
		  if (i >= size)
		    throw new NoSuchElementException();
		  Object[] elementData = ArrayList.this.elementData;
		  if (i >= elementData.length)
		    throw new ConcurrentModificationException();
		  // 下一次迭代时,元素的位置,为下一次迭代做准备
		  cursor = i + 1;
		  // 返回元素值
		  return (E) elementData[lastRet = i];
		}
		// 版本号比较
		final void checkForComodification() {
		  if (modCount != expectedModCount)
		    throw new ConcurrentModificationException();
		}

        public void remove() {
          // 如果上一次操作时,数组的位置已经小于 0 了,说明数组已经被删除完了
  			if (lastRet < 0)
		    	throw new IllegalStateException();
			  //迭代过程中,判断版本号有无被修改,
			  //有被修改,抛 ConcurrentModificationException 异常
			  checkForComodification();
		  try {
		    ArrayList.this.remove(lastRet);
		    cursor = lastRet;
		    // -1 表示元素已经被删除,这里也防止重复删除
		    lastRet = -1;
		    // 删除元素时 modCount 的值已经发生变化,在此赋值给 expectedModCount
		    // 这样下次迭代时,两者的值是一致的了
		    expectedModCount = modCount;
		  } catch (IndexOutOfBoundsException ex) {
		    throw new ConcurrentModificationException();
		  }
        }

        @Override
        @SuppressWarnings("unchecked")
        public void forEachRemaining(Consumer<? super E> consumer) {
            Objects.requireNonNull(consumer);
            final int size = ArrayList.this.size;
            int i = cursor;
            if (i >= size) {
                return;
            }
            final Object[] elementData = ArrayList.this.elementData;
            if (i >= elementData.length) {
                throw new ConcurrentModificationException();
            }
            while (i != size && modCount == expectedModCount) {
                consumer.accept((E) elementData[i++]);
            }
            // update once at end of iteration to reduce heap write traffic
            cursor = i;
            lastRet = i - 1;
            checkForComodification();
        }

        final void checkForComodification() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
        }
    }

remove这里我们需要注意的两点是:

  • lastRet = -1 的操作目的,是防止重复删除操作
  • 删除元素成功,数组当前 modCount 就会发生变化,这里会把 expectedModCount 重新赋值,下次迭代时两者的值就会一致了

4. 总结

  • 从我们上面新增或删除方法的源码解析,对数组元素的操作,只需要根据数组索引,直接新增和删除,所以时间复杂度是 O (1)。
  • ArrayList是线程不安全的,最根本原因是因为 ArrayList 自身的 elementData、size、modConut 在进行各种操作时,都没有加锁,而且这些变量的类型并非是可见(volatile)的,所以如果多个线程对这些变量进行操作时,可能会有值被覆盖的情况。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章