ArrayList与LinkedList源码分析

ArrayList 源码分析

1.概述

ArrayList 是一种变长的集合类,基于定长数组实现。ArrayList 允许空值和重复元素,当往 ArrayList 中添加的元素数量大于其底层数组容量时,其会通过扩容机制重新生成一个更大的数组。另外,由于 ArrayList 底层基于数组实现,所以其可以保证在 O(1) 复杂度下完成随机查找操作。其他方面,ArrayList 是非线程安全类,并发环境下,多个线程同时操作 ArrayList,会引发不可预知的错误。

ArrayList 是大家最为常用的集合类,作为一个变长集合类,其核心是扩容机制。所以只要知道它是怎么扩容的,以及基本的操作是怎样实现就够了。本文后续内容也将围绕这些点展开叙述。

2.源码分析

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;
    
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 初始化一个空数组,插入元素时,扩容将会按默认值重新初始化数组。而有参的构造方法则会将 elementData 初始化为参数值大小(>= 0)的数组。一般情况下,我们用默认的构造方法即可。倘若在可知道将会向 ArrayList 插入多少元素的情况下,应该使用有参构造方法。按需分配,避免浪费。

2.2 插入

对于数组(线性表)结构,插入操作分为两种情况。一种是在元素序列尾部插入,另一种是在元素序列其他位置插入。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 及其之后的所有元素都向后移一位
    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index);
    // 3. 将新元素插入至 index 处
    elementData[index] = element;
    size++;
}

对于在元素序列尾部插入,这种情况比较简单,只需两个步骤即可:

  1. 检测数组是否有足够的空间插入
  2. 将新元素插入至序列尾部

如下图:

img

如果是在元素序列指定位置(假设该位置合理)插入,则情况稍微复杂一点,需要三个步骤:

  1. 检测数组是否有足够的空间
  2. 将 index 及其之后的所有元素向后移一位
  3. 将新元素插入至 index 处

如下图:

img

从上图可以看出,将新元素插入至序列指定位置,需要先将该位置及其之后的元素都向后移动一位,为新元素腾出位置。这个操作的时间复杂度为O(N),频繁移动元素可能会导致效率问题,特别是集合中元素数量较多时。在日常开发中,若非所需,我们应当尽量避免在大集合中调用第二个插入方法。

以上是 ArrayList 插入相关的分析,上面的分析以及配图均未体现扩容机制。那么下面就来简单分析一下 ArrayList 的扩容机制。对于变长数据结构,当结构中没有空余空间可供使用时,就需要进行扩容。在 ArrayList 中,当空间用完,其会按照原数组空间的1.5倍进行扩容。相关源码如下:

/** 计算最小容量 */
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) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    // 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;
}

2.3 删除

不同于插入操作,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. 返回被删除值,完成删除操作

如下图:

img

上面就是删除指定位置元素的分析,并不是很复杂。

现在,考虑这样一种情况。我们往 ArrayList 插入大量元素后,又删除很多元素,此时底层数组会空闲处大量的空间。因为 ArrayList 没有自动缩容机制,导致底层数组大量的空闲空间不能被释放,造成浪费。对于这种情况,ArrayList 也提供了相应的处理方法,如下:

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

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

img

2.4 遍历

ArrayList 实现了 RandomAccess 接口(该接口是个标志性接口),表明它具有随机访问的能力。ArrayList 底层基于数组实现,所以它可在常数阶的时间内完成随机访问,效率很高。对 ArrayList 进行遍历时,一般情况下,我们喜欢使用 foreach 循环遍历,但这并不是推荐的遍历方式。ArrayList 具有随机访问的能力,如果在一些效率要求比较高的场景下,更推荐下面这种方式:

for (int i = 0; i < list.size(); i++) {
    list.get(i);//直接随机访问下标get效率更高
}

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

3.其他细节

3.1 快速失败机制

在 Java 集合框架中,很多类都实现了快速失败机制。该机制被触发时,会抛出并发修改异常ConcurrentModificationException,这个异常大家在平时开发中多多少少应该都碰到过。关于快速失败机制,ArrayList 的注释里对此做了解释,这里引用一下:

The iterators returned by this class’s iterator() and
listIterator(int) methods are fail-fast
if the list is structurally modified at any time after the iterator is
created, in any way except through the iterator’s own
ListIterator remove() or ListIterator add(Object) methods,
the iterator will throw a ConcurrentModificationException. Thus, in the face of
concurrent modification, the iterator fails quickly and cleanly, rather
than risking arbitrary, non-deterministic behavior at an undetermined
time in the future.

上面注释大致意思是,ArrayList 迭代器中的方法都是均具有快速失败的特性,当遇到并发修改的情况时,迭代器会快速失败,以避免程序在将来不确定的时间里出现不确定的行为。

以上就是 Java 集合框架中引入快速失败机制的原因,并不难理解,这里不多说了。

3.2 关于遍历时删除

遍历时删除是一个不正确的操作,即使有时候代码不出现异常,但执行逻辑也会出现问题。关于这个问题,阿里巴巴 Java 开发手册里也有所提及。这里引用一下:

【强制】不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素请使用 Iterator 方式,如果并发操作,需要对 Iterator 对象加锁。

相关代码(稍作修改)如下:

List<String> a = new ArrayList<String>();
	a.add("1");
	a.add("2");
	for (String temp : a) {
	    System.out.println(temp);
	    if("1".equals(temp)){
	        a.remove(temp);
	    }
	}
}

相信有些朋友应该看过这个,并且也执行过上面的程序。上面的程序执行起来不会虽不会出现异常,但代码执行逻辑上却有问题,只不过这个问题隐藏的比较深。我们把 temp 变量打印出来,会发现只打印了数字12没打印出来。初看这个执行结果确实很让人诧异,不明原因。如果死抠上面的代码,我们很难找出原因,此时需要稍微转换一下思路。我们都知道 Java 中的 foreach 是个语法糖,编译成字节码后会被转成用迭代器遍历的方式。所以我们可以把上面的代码转换一下,等价于下面形式:

List<String> a = new ArrayList<>();
a.add("1");
a.add("2");
Iterator<String> it = a.iterator();
while (it.hasNext()) {
    String temp = it.next();
    System.out.println("temp: " + temp);
    if("1".equals(temp)){
        a.remove(temp);
    }
}

以上是关于遍历时删除的分析,在日常开发中,我们要避免上面的做法。正确的做法使用迭代器提供的删除方法,而不是直接删除。

4.总结

看到这里,大家对 ArrayList 应该又有了些新的认识。ArrayList 是一个比较基础的集合类,用的很多。它的结构简单(本质上就是一个变长的数组),实现上也不复杂。尽管如此,本文还是啰里啰嗦讲了很多,大家见谅。好了,本文到这里就结束了,感谢阅读。

LinkedList 源码分析(JDK 1.8)

1.概述

LinkedList 是 Java 集合框架中一个重要的实现,其底层采用的双向链表结构。和 ArrayList 一样,LinkedList 也支持空值和重复值。由于 LinkedList 基于链表实现,存储元素过程中,无需像 ArrayList 那样进行扩容。但有得必有失,LinkedList 存储元素的节点需要额外的空间存储前驱和后继的引用。另一方面,LinkedList 在链表头部和尾部插入效率比较高,但在指定位置进行插入时,效率一般。原因是,在指定位置插入需要定位到该位置处的节点,此操作的时间复杂度为O(N)。最后,LinkedList 是非线程安全的集合类,并发环境下,多个线程同时操作 LinkedList,会引发不可预知的错误。

以上是对 LinkedList 的简单介绍,接下来,我将会对 LinkedList 常用操作展开分析,继续往下看吧。

2.继承体系

LinkedList 的继承体系较为复杂,继承自 AbstractSequentialList,同时又实现了 List 和 Deque 接口。继承体系图如下(删除了部分实现的接口):

img

LinkedList 继承自 AbstractSequentialList,AbstractSequentialList 又是什么呢?从实现上,AbstractSequentialList 提供了一套基于顺序访问的接口。通过继承此类,子类仅需实现部分代码即可拥有完整的一套访问某种序列表(比如链表)的接口。深入源码,AbstractSequentialList 提供的方法基本上都是通过 ListIterator 实现的,比如:

public E get(int index) {
    try {
        return listIterator(index).next();
    } catch (NoSuchElementException exc) {
        throw new IndexOutOfBoundsException("Index: "+index);
    }
}

public void add(int index, E element) {
    try {
        listIterator(index).add(element);
    } catch (NoSuchElementException exc) {
        throw new IndexOutOfBoundsException("Index: "+index);
    }
}

// 留给子类实现
public abstract ListIterator<E> listIterator(int index);

所以只要继承类实现了 listIterator 方法,它不需要再额外实现什么即可使用。对于随机访问集合类一般建议继承 AbstractList 而不是 AbstractSequentialList。LinkedList 和其父类一样,也是基于顺序访问。所以 LinkedList 继承了 AbstractSequentialList,但 LinkedList 并没有直接使用父类的方法,而是重新实现了一套的方法。

另外,LinkedList 还实现了 Deque (double ended queue),Deque 又继承自 Queue 接口。这样 LinkedList 就具备了队列的功能。比如,我们可以这样使用:

Queue<T> queue = new LinkedList<>();

除此之外,我们基于 LinkedList 还可以实现一些其他的数据结构,比如栈,以此来替换 Java 集合框架中的 Stack 类(该类实现的不好,《Java 编程思想》一书的作者也对此类进行了吐槽)。

关于 LinkedList 继承体系先说到这,下面进入源码分析部分。

3.源码分析

3.1 查找

LinkedList 底层基于链表结构,无法像 ArrayList 那样随机访问指定位置的元素。LinkedList 查找过程要稍麻烦一些,需要从链表头结点(或尾节点)向后查找,时间复杂度为 O(N)。相关源码如下:

public E get(int index) {
    checkElementIndex(index);
    return node(index).item;
}

Node<E> node(int index) {
    /* 
     *  查找位置 index 如果小于节点数量的一半,
     *	则从头节点开始查找,否则从尾节点查找
     */    
    if (index < (size >> 1)) {
        Node<E> x = first;
        // 循环向后查找,直至 i == index
        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;
    }
}

上面的代码比较简单,主要是通过遍历的方式定位目标位置的节点。获取到节点后,取出节点存储的值返回即可。这里面有个小优化,即通过比较 index 与节点数量 size/2 的大小,决定从头结点还是尾节点进行查找。查找操作的代码没什么复杂的地方,这里先讲到这里。

3.2 遍历

链表的遍历过程也很简单,和上面查找过程类似,我们从头节点往后遍历就行了。但对于 LinkedList 的遍历还是需要注意一些,不然可能会导致代码效率低下。通常情况下,我们会使用 foreach 遍历 LinkedList,而 foreach 最终转换成迭代器形式。所以分析 LinkedList 的遍历的核心就是它的迭代器实现,相关代码如下:

public ListIterator<E> listIterator(int index) {
    checkPositionIndex(index);
    return new ListItr(index);
}

private class ListItr implements ListIterator<E> {
    private Node<E> lastReturned;
    private Node<E> next;
    private int nextIndex;
    private int expectedModCount = modCount;

    /** 构造方法将 next 引用指向指定位置的节点 */
    ListItr(int index) {
        // assert isPositionIndex(index);
        next = (index == size) ? null : node(index);
        nextIndex = index;
    }

    public boolean hasNext() {
        return nextIndex < size;
    }

    public E next() {
        checkForComodification();
        if (!hasNext())
            throw new NoSuchElementException();

        lastReturned = next;
        next = next.next;    // 调用 next 方法后,next 引用都会指向他的后继节点
        nextIndex++;
        return lastReturned.item;
    }
    
    // 省略部分方法
}

上面的方法很简单,大家应该都能很快看懂,这里就不多说了。下面来说说遍历 LinkedList 需要注意的一个点。

我们都知道 LinkedList 不擅长随机位置访问,如果大家用随机访问的方式遍历 LinkedList,效率会很差。比如下面的代码:

List<Integet> list = new LinkedList<>();
list.add(1)
list.add(2)
......
for (int i = 0; i < list.size(); i++) {
    Integet item = list.get(i);
    // do something
}

当链表中存储的元素很多时,上面的遍历方式对于效率来说就是灾难。原因在于,通过上面的方式每获取一个元素,LinkedList 都需要从头节点(或尾节点)进行遍历,效率不可谓不低。上面的遍历方式在大数据量情况下,效率很差。大家在日常开发中应该尽量避免这种用法。

3.3 插入

LinkedList 除了实现了 List 接口相关方法,还实现了 Deque 接口的很多方法,所以我们有很多种方式插入元素。但这里,我只打算分析 List 接口中相关的插入方法,其他的方法大家自己看吧。LinkedList 插入元素的过程实际上就是链表链入节点的过程,学过数据结构的同学对此应该都很熟悉了。这里简单分析一下,先看源码吧:

/** 在链表尾部插入元素 */
public boolean add(E e) {
    linkLast(e);
    return true;
}

/** 在链表指定位置插入元素 */
public void add(int index, E element) {
    checkPositionIndex(index);

    // 判断 index 是不是链表尾部位置,如果是,直接将元素节点插入链表尾部即可
    if (index == size)
        linkLast(element);
    else
        linkBefore(element, node(index));
}

/** 将元素节点插入到链表尾部 */
void linkLast(E e) {
    final Node<E> l = last;
    // 创建节点,并指定节点前驱为链表尾节点 last,后继引用为空
    final Node<E> newNode = new Node<>(l, e, null);
    // 将 last 引用指向新节点
    last = newNode;
    // 判断尾节点是否为空,为空表示当前链表还没有节点
    if (l == null)
        first = newNode;
    else
        l.next = newNode;    // 让原尾节点后继引用 next 指向新的尾节点
    size++;
    modCount++;
}

/** 将元素节点插入到 succ 之前的位置 */
void linkBefore(E e, Node<E> succ) {
    // assert succ != null;
    final Node<E> pred = succ.prev;
    // 1. 初始化节点,并指明前驱和后继节点
    final Node<E> newNode = new Node<>(pred, e, succ);
    // 2. 将 succ 节点前驱引用 prev 指向新节点
    succ.prev = newNode;
    // 判断尾节点是否为空,为空表示当前链表还没有节点    
    if (pred == null)
        first = newNode;
    else
        pred.next = newNode;   // 3. succ 节点前驱的后继引用指向新节点
    size++;
    modCount++;
}

上面是插入过程的源码,我对源码进行了比较详细的注释,应该不难看懂。上面两个 add 方法只是对操作链表的方法做了一层包装,核心逻辑在 linkBefore 和 linkLast 中。这里以 linkBefore 为例,它的逻辑流程如下:

  1. 创建新节点,并指明新节点的前驱和后继
  2. succ 的前驱引用指向新节点
  3. 如果 succ 的前驱不为空,则将 succ 前驱的后继引用指向新节点

对应于下图:

img

以上就是插入相关的源码分析,并不复杂,就不多说了。继续往下分析。

3.4 删除

如果大家看懂了上面的插入源码分析,那么再看删除操作实际上也很简单了。删除操作通过解除待删除节点与前后节点的链接,即可完成任务。过程比较简单,看源码吧:

public boolean remove(Object o) {
    if (o == null) {
        for (Node<E> x = first; x != null; x = x.next) {
            if (x.item == null) {
                unlink(x);
                return true;
            }
        }
    } else {
        // 遍历链表,找到要删除的节点
        for (Node<E> x = first; x != null; x = x.next) {
            if (o.equals(x.item)) {
                unlink(x);    // 将节点从链表中移除
                return true;
            }
        }
    }
    return false;
}

public E remove(int index) {
    checkElementIndex(index);
    // 通过 node 方法定位节点,并调用 unlink 将节点从链表中移除
    return unlink(node(index));
}

/** 将某个节点从链表中移除 */
E unlink(Node<E> x) {
    // assert x != null;
    final E element = x.item;
    final Node<E> next = x.next;
    final Node<E> prev = x.prev;
    
    // prev 为空,表明删除的是头节点
    if (prev == null) {
        first = next;//新的头结点
    } else {
        // 将 x 的前驱的后继指向 x 的后继
        prev.next = next;
        // 将 x 的前驱引用置空,断开与前驱的链接
        x.prev = null;
    }

    // next 为空,表明删除的是尾节点
    if (next == null) {
        last = prev;//新的尾结点
    } else {
        // 将 x 的后继的前驱指向 x 的前驱
        next.prev = prev;
        // 将 x 的后继引用置空,断开与后继的链接
        x.next = null;
    }

    // 将 item 置空,方便 GC 回收
    x.item = null;
    size--;
    modCount++;
    return element;
}

和插入操作一样,删除操作方法也是对底层方法的一层保证,核心逻辑在底层 unlink 方法中。所以长驱直入,直接分析 unlink 方法吧。unlink 方法的逻辑如下(假设删除的节点既不是头节点,也不是尾节点):

  1. 将待删除节点 x 的前驱的后继指向 x 的后继 prev.next = next;
  2. 将待删除节点 x 的前驱引用置空,断开与前驱的链接 x.prev = null;
  3. 将待删除节点 x 的后继的前驱指向 x 的前驱 next.prev = prev;
  4. 将待删除节点 x 的后继引用置空,断开与后继的链接 x.next = null;

对应下图:

img

结合上图,理解 LInkedList 删除操作应该不难。好了,LinkedList 的删除源码分析就讲到这。

4.总结

通过上面的分析,大家对 LinkedList 的底层实现应该很清楚了。总体来看 LinkedList 的源码并不复杂,大家耐心看一下,一般都能看懂。同时,通过本文,向大家展现了使用 LinkedList 的一个坑,希望大家在开发中尽量避免。好了,本文到这里就结束了,感谢阅读!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值