Android面试题(14):ArrayList和LinkedList源码解析

AlexYoung——ArrayList
AlexYoung——LinkedList
程姐——fail-fast机制

1. ArrayList和LinkedList的实现简介

1.1 从数据结构方面来说

  • ArrayList是通过数组实现的一个元素列表,可以通过索引访问。内部数组的默认长度是10,当不满足需求时,会自动扩充到原来的1.5倍长度。可以通过构造函数修改这个默认值。

  • LinkedList则是通过双向链表的方式实现的,内部是一个个Node对象,没有索引值,查找一个元素时是根据索引大小决定从头部或者尾部的方向遍历。

所以,在修改List结构时(例如插入、删除一个元素),ArrayList因为需要通过数组拷贝的方式实现,所以是比较耗时的。
而LinkedList使用链表的方式,可以很简单的修改结构,但相对来说,查询索引则比较麻烦。

例如一个size为100的list,需要在索引为50的地方插入一个元素,list.add(50, obj);
如果这个list是ArrayList,那么它会做两件事:
1. 判断内部数组的长度是否能再存入一个元素,否则将数组扩充(数组拷贝的方式)到原来的额1.5倍,此时数组长度可能为150。
2. 再次执行数组拷贝动作

此时,ArrayList插入一个元素的代价是比较高的,可能执行了两次数组拷贝动作。

如果这个list是LinkedList,那么它也会做两件事:
1. 从头部开始遍历,直到第50个节点
2. 在该位置插入元素

所以,Linked虽然插入一个元素的代价很低,但是它也有遍历节点产生的耗时。

1.2 从内存分配方面来说

ArrayList和LinkedList哪个更耗费内存不好分析,但是从内存分配上来说是LinkedList更占优势的。

就像上面的例子,ArrayList的内部数组长度变为150,但实际上只使用到了101个位置。并且数组的内存分配是需要一片连续的空间的。

而LinkedList,因为内部其实是一个一个Node对象,所以不需要用到连续的内存。但是每一个Node对象中需要记录三个引用(当前对象,上一个对象,下一个对象)。

2. ArrayList源码解析

2.1 成员

来看看ArrayList几个重要的成员:

// 数组默认长度
private static final int DEFAULT_CAPACITY = 10;

// 两个静态的空数组
private static final Object[] EMPTY_ELEMENTDATA = {};
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

// 实际存放元素的数组
transient Object[] elementData;

// 数组中元素的个数(注意,不是elementData的长度)
private int size;

// 父类AbstractList中的成员
protected transient int modCount = 0;

空数组:
首先EMPTY_ELEMENTDATADEFAULTCAPACITY_EMPTY_ELEMENTDATA都是静态的长度为0的Object数组,用来区分是否拥有默认长度,判断在添加第一个元素时,需要扩充多少长度。
例如,默认情况下,定义一个ArrayList时使用的是DEFAULTCAPACITY_EMPTY_ELEMENTDATA这个数组,在第一次添加元素时,会拷贝一个长度为10的数组。
而使用new ArrayList(0)定义时,使用的是EMPTY_ELEMENTDATA这个数组,在第一次添加元素时,会拷贝一个长度为1的数组。
相关代码如下,add系列方法都会调用下面这个方法检测是否需要扩充长度:

private void ensureCapacityInternal(int minCapacity) {
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
        }
        ensureExplicitCapacity(minCapacity);
    }

modCount
修改结构的次数,例如删除一个元素时,modCount会自增一次。主要作用是在使用快速迭代时,判断是否修改了List的结构,抛出ConcurrentModificationException(并发修改)异常。
例如下面代码会抛出这个异常:

public static void main(String[] args) {
        ArrayList<Integer> list = new ArrayList<>();
        list.addAll(Arrays.asList(1,2,3,4,5,6,7,8));

        for (Integer integer : list) {
            if (integer < 5) {
                list.remove(integer);
            }
        }
    }

2.2 构造

    public ArrayList() {
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }

我们最常用的是这种构造方式,当第一次添加元素时,会拷贝一个长度为10的数组。

另外,我们还可以自己指定默认长度:

    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);
        }
    }

关于EMPTY_ELEMENTDATADEFAULTCAPACITY_EMPTY_ELEMENTDATA上面已经说过,不再赘述。
如果initialCapacity大于0时,会直接创建指定长度的数组。

还可以通过其它集合构造,这个就不详细说明了。

public ArrayList(Collection<? extends E> c)

2.3 主要函数

add方法:

    public void add(int index, E element) {
        // 检查index是否越界
        rangeCheckForAdd(index);

        // 扩充数组长度(如果需要的话)
        ensureCapacityInternal(size + 1);  // Increments modCount!!

        // 通过数组拷贝的方式添加元素。
        // (原数组,原索引,目标数组,目标索引,拷贝长度)
        // 例如[0,1,2,3,4,5, , , ],拷贝过后可能为[0,1,2,3, ,4,5, , ]
        System.arraycopy(elementData, index, elementData, index + 1,
                         size - index);
        elementData[index] = element;
        size++;
    }

数组扩充:

    private void ensureCapacityInternal(int minCapacity) {
        // new ArrayList()的方式,elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
        }

        ensureExplicitCapacity(minCapacity);
    }

    private void ensureExplicitCapacity(int minCapacity) {
        modCount++;

        // overflow-conscious code
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }

    // 这个方法就是关键的扩充方法。
    private void grow(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;

        // 主要看这行代码,延长原来长度的1.5倍。
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        // minCapacity is usually close to size, so this is a win:
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

    private static int hugeCapacity(int minCapacity) {
        if (minCapacity < 0) // overflow
            throw new OutOfMemoryError();
        return (minCapacity > MAX_ARRAY_SIZE) ?
            Integer.MAX_VALUE :
            MAX_ARRAY_SIZE;
    }

addAll、remove等方法类似,都是数组拷贝,就不赘述了。

3. LinkedList源码解析

LinkedList不仅仅只是一个列表,它还实现了Deque接口,可以作为双向队列或队列使用。
例如:

Queue<String> queue = new LinkedList<>();
Deque<String> deque = new LinkedList<>();

3.1 成员

// 链表的长度
transient int size = 0;

// 链表的开始节点
transient Node<E> first;

// 链表的结束节点
transient Node<E> last;
private static class Node<E> {
        E item;  // 持有的元素
        Node<E> next;  // 下一个节点
        Node<E> prev;  // 上一个节点

        Node(Node<E> prev, E element, Node<E> next) {
            this.item = element;
            this.next = next;
            this.prev = prev;
        }
    }

Node是LinkedList的一个静态内部类,可以看到链表的结构如上,每个节点分别持有上一个节点和下一个节点的引用,从而形成双向链表结构。

3.2 构造

public LinkedList() {}

public LinkedList(Collection<? extends E> c) {
    this();
    addAll(c);
}

只有两个简单的构造。
l

3.3 主要函数

我们先来跟踪一下add方法。

    public boolean add(E e) {
        linkLast(e);
        return true;
    }

    void linkLast(E e) {
        final Node<E> l = last;
        final Node<E> newNode = new Node<>(l, e, null);
        last = newNode;
        if (l == null)
            first = newNode;
        else
            l.next = newNode;
        size++;
        modCount++;
    }

add(E e)方法比较简单,不赘述了。来看看add方法的另一个重载:

    public void add(int index, E element) {
        // 检查角标是否越界
        checkPositionIndex(index);

        // 如果index == size,那么直接在最后插入,提高性能。
        if (index == size)
            linkLast(element);
        else
            linkBefore(element, node(index));
    }

    // node方法就是获取索引的值。根据索引的大小,判断从头部还是尾部开始遍历。
    Node<E> node(int index) {
        // assert isElementIndex(index);
        // 索引小于长度的一半,从头部开始遍历,否则从尾部开始遍历。
        // 最终返回目标节点。
        if (index < (size >> 1)) {
            Node<E> x = first;
            for (int i = 0; i < index; i++)
                x = x.next;
            return x;
        } else {
            Node<E> x = last;
            for (int i = size - 1; i > index; i--)
                x = x.prev;
            return x;
        }
    }

     // e是需要插入的元素,succ则是需要插入的索引对应的节点。
     // 此方法是将一个新的节点插入到succ渊先的位置。 
     void linkBefore(E e, Node<E> succ) {
        // assert succ != null;
        final Node<E> pred = succ.prev;
        final Node<E> newNode = new Node<>(pred, e, succ);
        succ.prev = newNode;
        if (pred == null)
            first = newNode;
        else
            pred.next = newNode;
        size++;
        modCount++;
    }

另外,remove(E e)、remove(int index)等操作集合的方法,都会调用一系列linkXxx()或者unLinkXxx()方法,都是使用近似的逻辑操作节点,就不再赘述了。

4. 再说modCount(fail-fast)

4.1 fail-fast机制

上面简单说过modCount是用来判断集合迭代时是否出现并发修改问题的(forEach实际上是通过迭代器实现的),而这个检查机制称为“fail-fast”机制。

通过迭代器自身提供的方法,在一定程度上可以解决fail-fast问题,不过这些方法是有限的。

Iterator.remove();
ListIterator.remove();
ListIterator.add(E e);
ListIterator.set(E e);

另外,ListIterator除了比Iterator多了两个可修改集合的方法外,还可以进行双向遍历,而Iterator只能向后遍历。

那么,List是如何通过modCount实现fail-fast判断的呢?

在进行集合的修改操作时,会让modCount自增,例如ArrayList调用add方法,会在在检查长度时做这个操作:

    private void ensureExplicitCapacity(int minCapacity) {
        modCount++;

        // overflow-conscious code
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }

而获取迭代器时,会记录下这个modCount的值,在每次调用next()或remove()等方法时,都会检查这个值是否被改变。

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;

        //  省略其余代码

        final void checkForComodification() {
            // 每次迭代时都会判断modCount是否被改变。
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
        }
    }

而迭代器自身提供的修改集合的方法不会出现fail-fast问题,是因为在这些方法中重置了expectedModCount的值:

      public void remove() {
            if (lastRet < 0)
                throw new IllegalStateException();
            checkForComodification();

            try {
                ArrayList.this.remove(lastRet);
                cursor = lastRet;
                lastRet = -1;
                expectedModCount = modCount; // 看这里
            } catch (IndexOutOfBoundsException ex) {
                throw new ConcurrentModificationException();
            }
        }

4.2 使用concurrent包下的容器类避免fail-fast

但是,上面这种方式仅仅对于单线程环境而言是安全的,在多线程环境中,虽然我们可以使用这个方式让一个List集合转换成线程安全的集合:

// 返回的是SynchronizedList,一个List的装饰类,给所有方法加上同步锁。
List<Integer> syncList = Collections.synchronizedList(new ArrayList<>());

但是这并不能解决多线程环境的fail-fast问题,因为多线程的fail-fast是这么产生的:
同步锁并不能阻止一个线程在迭代时让另一个线程修改这个集合,所以当线程A修改了集合,导致modCount自增,而线程B正在迭代,判断出expectedModCount不等于modCount,从而引发了并发修改异常。

而有效的解决fail-fast问题的办法是使用concurrent包下的容器类,例如CopyOnWriteArrayList。而CopyOnWriteArrayList不单单只是解决了多线程的fail-fast问题,而是从根本原因上避免这个问题,无论是多线程还是单线程。

那么CopyOnWriteArrayList是如何避免fail-fast问题的呢?

  public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            newElements[len] = e;
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }

例如add方法,实际上是在添加一个元素时,是拷贝了一个新的数组,然后这个新的数组上操作,最后再将这个新的数组替换掉原来的。这样,在迭代时就可以直接使用List本身的方法,不需要使用迭代器的方法去修改集合结构,因为迭代中的数组和集合当前的数组可能已经不是同一个数组了,自然就没有了fail-fast问题。所以,CopyOnWriteArrayList的迭代器也没有实现add、remove等方法,因为根本不需要。

public static void main(String[] args) {
//        ArrayList<Integer> list = new ArrayList<>();
        CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
        list.addAll(Arrays.asList(1,2,3,4,5,6,7,8));

        for (Integer integer : list) {
            if (integer < 5) {
                list.remove(integer);
            }
        }
        System.out.println(list);
    }

在之前使用ArrayList时,上面的代码会引发ConcurrentModificationException,但是替换成CopyOnWriteArrayList就没问题了,打印结果为[5, 6, 7, 8]

4.3 弱一致性(weakly consistency)

有些文章将CopyOnWriteArrayList这类容器说成是“fail-safe”的,实际上java中并没有这个机制,相对的应该叫weakly consistency(弱一致性)。
例如,线程A正在迭代一个集合,此时线程B往集合末尾添加一个元素,线程A的迭代器是无法迭代到这个新增的元素的,因为操作的不是同一个数组。

事实上看看CopyOnWriteArrayList的get方法就知道“弱一致性”体现在哪。

public E get(int index) {
        return get(getArray(), index);
    }

private E get(Object[] a, int index) {
        return (E) a[index];
    }

get方法并没有加锁,所以线程A可能获取不到线程B新增的一个元素,或者获取到了线程B已经移除的元素。

CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。

猜你喜欢

转载自blog.csdn.net/u010386612/article/details/80189499