必须要学习的源码2--ArrayList

92 阅读11分钟

简单介绍

image-20220917182141868

本篇文章我们将重点分析 ArrayList 源码,并且同时介绍并对比 Vector 和 LinkedList

源码分析素材为 JDK1.8 的 ArrayList 源码。

什么是ArrayList, Vector, LinkedList?

ArrayList

ArrayList 实现了 List 接口,是一种变长的集合类,基于定长数组实现,它允许所有元素,包括null。

Vector

Vector 和 ArrayList 几乎是一样的,区别在于Vector是线程安全的,因为这个原因,它的性能较 ArrayList 差。

LinkedList

LinkedList 底层采用的双向链表结构,和 ArrayList 一样,支持空值和重复值。

接下来我们以提问的方式来回答ArrayList,Vector,LinkedList所具有的特征

ArrayList 的特征

ArrayList 是线程安全的吗?

ArrayList 不是线程安全的。

ArrayList 没有同步方法。如果多个线程同时访问一个List,则必须自己实现访问同步。一个解决方法就是在创建List时构造一个同步的List

 List list = Collections.synchronizedList(new ArrayList(...)); 

底层数据结构?

Arraylist 底层使用的是 Object 数组

是否支持快速随机访问?

ArrayList 实现了 RandomAccess 接口(该接口是个标志性接口),表明它具有随机访问的能力。

内存空间占用?

ArrayList 的空间浪费主要体现在 list 列表的结尾会预留一定的容量空间

Vector 的特征

Vector 是线程安全的吗?

Vector 是线程安全的。

底层数据结构?

VectorArrayList 一样 底层使用的是 Object 数组

是否支持快速随机访问?

VectorArrayList 一样,支持随机访问。

内存空间占用?

VectorArrayList 一样,会有在list结尾会预留容量空间的内存浪费

LinkedList 的特征

LinkedList 是线程安全的吗?

LinkedList 是线程不安全的,不是同步的。

底层数据结构?

LinkedList 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表JDK1.7 取消了循环。)

是否支持快速随机访问?

LinkedList 不支持高效的随机元素访问

内存空间占用?

LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。

ArrayList 源码分析

底层数据结构

类的属性:

 public class ArrayList<E> extends AbstractList<E>
         implements List<E>, RandomAccess, Cloneable, java.io.Serializable
 {
     /**
     * 默认初始容量大小
     */
     private static final int DEFAULT_CAPACITY = 10;
 ​
     /**
     * 空数组(用于空实例)。
     */
     private static final Object[] EMPTY_ELEMENTDATA = {};
 ​
     //用于默认大小空实例的共享空数组实例。
     //我们把它从EMPTY_ELEMENTDATA数组中区分出来,以知道在添加第一个元素时容量需要增加多少。
     private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
 ​
     /**
     * 保存ArrayList数据的数组
     */
     transient Object[] elementData; // non-private to simplify nested class access
 ​
     /**
     * ArrayList 所包含的元素个数
     */
     private int size;
 }

所以这里我们看出来 ArrayList 的底层数组 elementData 是底层的核心存储数组。

接下来我们通过构造函数了解 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;
 }

这两个构造方法做的事情目的都是初始化底层数组 elementData。其中,无参构造方法会将 elementData 初始化一个空数组,以后再根据扩容机制进行重新初始化。

我们一般使用默认构造函数就行了,如果知道会将 ArrayList 插入多少元素,我们可以使用有参构造方法,避免浪费空间。

add方法 (插入)

ArrayList 插入逻辑主要代码:

 /** 在元素序列尾部插入 */
 public boolean add(E e) {
     // 1. 检测是否需要扩容
     ensureCapacityInternal(size + 1);  // Increments modCount!!
     // 2. 将新元素插入序列尾部
     elementData[size++] = e;
     return true;
 }
 ​
 /** 在元素序列 index 位置处插入 */
 public void add(int index, E element) {
     rangeCheckForAdd(index);
 ​
     // 1. 检测是否需要扩容
     ensureCapacityInternal(size + 1);  // Increments modCount!!
     // 2. 将 index 及其之后的所有元素都向后移一位
     //arraycopy()方法实现数组自己复制自己
     System.arraycopy(elementData, index, elementData, index + 1,
                      size - index);
     // 3. 将新元素插入至 index 处
     elementData[index] = element;
     size++;
 }

根据插入的位置不同,ArrayList 源码分成两种不同的逻辑

  • 在元素序列尾部插入

    1. 检测数组是否需要扩容
    2. 将新元素插入到序列尾部
  • 在元素序列指定位置插入

    1. 检测数组是否需要扩容
    2. 将 index 及其之后的所有元素都向后移一位
    3. 将新元素插入至 index 处

    如下图所示:

    image-20220917182626337

    从这个过程我们可以看出插入元素是一个时间复杂度为O(N)的操作。所以我们应该尽量避免在大集合中调用第二个插入方法。

扩容机制(自动)

分析完插入后,接着我们就分析插入逻辑中出现的扩容机制

其中,扩容的入口方法是 ensureCapacityInternal,核心方法是 grow

我们知道 ArrayList 是一个变长数组,当底层的数组结构没有空余的空间时就需要进行扩容。

 /** 计算最小容量 */
 private static int calculateCapacity(Object[] elementData, int minCapacity) {
     if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
         return Math.max(DEFAULT_CAPACITY, minCapacity);
     }
     return minCapacity;
 }
 ​
 /** 扩容的入口方法 */
 private void ensureCapacityInternal(int minCapacity) {
     ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
 }
 ​
 private void ensureExplicitCapacity(int minCapacity) {
     modCount++;
 ​
     // overflow-conscious code
     if (minCapacity - elementData.length > 0)
         grow(minCapacity);
 }
 ​
 /** 扩容的核心方法 */
 private void grow(int minCapacity) {
     // oldCapacity为旧容量,newCapacity为新容量
     int oldCapacity = elementData.length;
     //我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍,
     // newCapacity = oldCapacity + oldCapacity / 2 = oldCapacity * 1.5
     int newCapacity = oldCapacity + (oldCapacity >> 1);
     if (newCapacity - minCapacity < 0)
         newCapacity = minCapacity;
     if (newCapacity - MAX_ARRAY_SIZE > 0)
         newCapacity = hugeCapacity(minCapacity);
     // 扩容
     elementData = Arrays.copyOf(elementData, newCapacity);
 }
 ​
 private static int hugeCapacity(int minCapacity) {
     if (minCapacity < 0) // overflow
         throw new OutOfMemoryError();
     // 如果最小容量超过 MAX_ARRAY_SIZE,则将数组容量扩容至 Integer.MAX_VALUE
     return (minCapacity > MAX_ARRAY_SIZE) ?
         Integer.MAX_VALUE :
         MAX_ARRAY_SIZE;
 }

扩容的核心在 grow 方法:

  1. oldCapacity为旧容量,我们以 oldCapicity 的1.5倍来作为 newCapacity(新容量)

  2. 检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量

  3. 如果新容量大于 MAX_ARRAY_SIZE, 进入(执行) hugeCapacity() 方法来比较 minCapacity 和 MAX_ARRAY_SIZE,

    如果minCapacity大于最大容量,则新容量则为Integer.MAX_VALUE,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 Integer.MAX_VALUE - 8

  4. 最后调用 Arrays.copyof() 方法完成扩容

remove方法 (删除)

ArrayList 的删除根据删除元素的位置不同,同样分出了两种逻辑。但是不同于插入操作,ArrayList 并没有无参的删除方法,只能删除指定位置的元素或者删除指定元素。

 /** 删除指定位置的元素 */
 public E remove(int index) {
     rangeCheck(index);
 ​
     modCount++;
     // 返回被删除的元素值
     E oldValue = elementData(index);
 ​
     int numMoved = size - index - 1;
     if (numMoved > 0)
         // 将 index + 1 及之后的元素向前移动一位,覆盖被删除值
         System.arraycopy(elementData, index+1, elementData, index,
                          numMoved);
     // 将最后一个元素置空,并将 size 值减1                
     elementData[--size] = null; // clear to let GC do its work
 ​
     return oldValue;
 }
 ​
 E elementData(int index) {
     return (E) elementData[index];
 }
 ​
 /** 删除指定元素,若元素重复,则只删除下标最小的元素 */
 public boolean remove(Object o) {
     if (o == null) {
         for (int index = 0; index < size; index++)
             if (elementData[index] == null) {
                 fastRemove(index);
                 return true;
             }
     } else {
         // 遍历数组,查找要删除元素的位置
         for (int index = 0; index < size; index++)
             if (o.equals(elementData[index])) {
                 fastRemove(index);
                 return true;
             }
     }
     return false;
 }
 ​
 /** 快速删除,不做边界检查,也不返回删除的元素值 */
 private void fastRemove(int index) {
     modCount++;
     int numMoved = size - index - 1;
     if (numMoved > 0)
         System.arraycopy(elementData, index+1, elementData, index,
                          numMoved);
     elementData[--size] = null; // clear to let GC do its work
 }
  • 删除指定位置的元素

    1. 获取指定位置 index 处的元素值
    2. 将 index + 1 及之后的元素向前移动一位,覆盖被删除值
    3. 将最后一个元素置空,并将 size 值减 1
    4. 返回被删除值,完成删除操作

    如下图:

    image-20220917184600132

    所以可以发现,这里删除指定位置的元素的时间复杂度也是O(N)

  • 删除指定元素

    1. 遍历数组,查找要删除元素的位置(若重复,只删除最小下标上的元素)
    2. 对该位置进行 "快速删除"(不进行边界检查,也不返回删除的元素值)

缩容机制(手动)

分析完删除后,我们思考这么一种情况:

我们往 ArrayList 中插入了很多的元素后,然后又删除了很多元素,这个时候底层数组肯定会空闲大量的空间。

由于 ArrayList 没有自动缩容的机制,导致底层数组大量的空闲空间不能被释放,造成浪费。对于这种情况ArrayList也提供了对应的处理方,即 trimToSize 方法

 /** 将数组容量缩小至元素数量 */
 public void trimToSize() {
     modCount++;
     if (size < elementData.length) {
         elementData = (size == 0)
           ? EMPTY_ELEMENTDATA
           : Arrays.copyOf(elementData, size);
     }
 }

通过这个方法,我们可以手动出发 ArrayList 的缩容机制,释放多余的空间,提高空间利用率。

Arrays.copyof() 和 System.arraycopy() 方法

在上面我们注意到了源码中不止一次的出现了 Arrays.copyof()System.arraycopy()方法

所以我认为这在这章讲讲这两个也是有必要的。

其中本章的测试引用自:javaguide.cn/java/collec…

System.arraycopy()方法

方法签名:

 public static native void arraycopy(Object src,  int  srcPos,
                                         Object dest, int destPos,
                                         int length);

参数解释:

  • src: 源数组;
  • srcPos: 源数组中的起始位置;
  • dest:目标数组;
  • destPos:目标数组中的起始位置;
  • length:要复制的数组元素的数量;

这个方法是一个native方法,于是我们直接对它做测试:

public class ArraycopyTest {

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		int[] a = new int[10];
		a[0] = 0;
		a[1] = 1;
		a[2] = 2;
		a[3] = 3;
		System.arraycopy(a, 2, a, 3, 3);
		a[2]=99;
		for (int i = 0; i < a.length; i++) {
			System.out.print(a[i] + " ");
		}
	}
}

结果:

0 1 99 2 3 0 0 0 0 0

Arrays.copyof()方法

源码:

 public static int[] copyOf(int[] original, int newLength) {
    	// 申请一个新的数组
        int[] copy = new int[newLength];
	// 调用System.arraycopy,将源数组中的数据进行拷贝,并返回新的数组
        System.arraycopy(original, 0, copy, 0,
                         Math.min(original.length, newLength));
        return copy;
    }

使用Arrays.copyof()方法主要目的应该是为了给原有数组扩容,测试:

public class ArrayscopyOfTest {

	public static void main(String[] args) {
		int[] a = new int[3];
		a[0] = 0;
		a[1] = 1;
		a[2] = 2;
		int[] b = Arrays.copyOf(a, 10);
		System.out.println("b.length"+b.length);
	}
}

结果:

10

快速随机访问

ArrayList 实现了 RandomAccess 接口(该接口是个标志性接口),表明它具有随机访问的能力。

ArrayList 底层基于数组实现,所以它可在常数阶的时间内完成随机访问,效率很高。

对 ArrayList 进行遍历时,一般情况下,我们喜欢使用 foreach 循环遍历,但这并不是推荐的遍历方式。ArrayList 具有随机访问的能力,如果在一些效率要求比较高的场景下,更推荐下面这种方式:

for (int i = 0; i < list.size(); i++) {
    list.get(i);
}

至于原因也不难理解,foreach 最终会被转换成迭代器遍历的形式,效率不如上面的遍历方式。

fail-fast (快速失败机制)

什么是快速失败机制?

java.util 包下的集合类都是实现了快速失败机制的。当遇到并发修改时,迭代器会快速失败,以免程序在该机制将来不确定的时间里出现不确定的行为。该机制被触发时,会抛出并发修改异常ConcurrentModificationException

原理

迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变 modCount 的值。每当迭代器使用 hashNext()/next() 遍历下一个元素之前,都会检测 modCount 变量是否为 expectedmodCount 值,是的话就返回遍历;否则抛出 ConcurrentModificationException 异常,终止遍历。

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() {
        return cursor != size;
    }

    
    public E next() {
        // 并发修改检测,检测不通过则抛出异常
        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();
    }
    
    // 省略不相关的代码
}

这里的逻辑不止可以在ArrayList中找到,在Vector和LinkedList中也有

观察源码我们知道了这里异常的抛出条件是检测到 modCount!=expectedmodCount 这个条件。如果集合发生变化时修改 modCount 值刚好又设置为了 expectedmodCount 值,则异常不会抛出。

既然谈到了快速失败机制,那么我们干脆就把与之相对应的失败安全机制一并端上来罢

safe-fast (失败安全机制)

什么是失败安全机制?

java.util.concurrent 包下的容器都是实现了失败安全机制的。可以在多线程下并发使用,并发修改。采用失败安全机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。

原理

由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发ConcurrentModificationException

同时迭代器并不能访问到修改后的内容,在遍历期间原集合发生的修改迭代器是不知道的。由于是需要拷贝的,所以也比较吃内存。

ArrayList 的使用

如何遍历 ArrayList?

遍历 ArrayList 主要有以下3种方式:

  1. 迭代器遍历

    Iterator<String> itr = list.iterator();
    
    // Iterator遍历
    while (itr.hasNext()) {
        System.out.println(itr.next());
    }
    
  2. for循环

    // for循环遍历
    for (int i = 0; i < list.size(); i++) {
        System.out.println(list.get(i));
    }
    
  3. foreach循环

    // foreach遍历
    for (String element : list) {
        System.out.println(element);
    }
    

小结

ArrayListLinkedListVectorList 接口的三个实现类,本文在特征以及概述部分分析了它们的异同。并且在源码分析篇中详细分析了 ArrayList 的源码。

ArrayList 作为最为常用的集合类之一,是面试中考察的重点。如果对这几个集合类有新的理解,本文会进行更新。

本文参考: