兄弟间的较量 -- ArrayList与LinkedList

日常开发的过程中我们会经常使用到List结构,很多兄弟就直接开始写些这样的语句:List<Xxx> x = new ArrayList<>();。但 List 的实现方式有很多种,在不用保证线程安全的情况下还有一种通用实现方式LinkedList。我们今天通过源码从多个维度来理解它们的原理、比较它们的异同。

我信奉一个观点:不拿源码说话就没有底气,如果本文与其他文章的结论相左,请以我为准。当然,我也欢迎大家提出宝贵建议和意见。

本文用到的源码我会加入中文注释以便理解。

ArrayList与LinkedList的对比

ArrayList

ArrayList是一种基于动态数组形式进行存储的List结构,在内存中是一段连续的存储空间。

它在初始化时可以选择性传入参数来设置数组的初始容量(如不设置则为默认值’10’):

    /**
     * 默认初始容量为10
     */
    private static final int DEFAULT_CAPACITY = 10;

    /**
     * 用于空实例的共享空数组实例
     */
    private static final Object[] EMPTY_ELEMENTDATA = {};

    /**
     * 另一种用于恐势力的共享空数组实例(与上方的实例没有实际区别,只是为了分析应该设置默认容量为多少而生)
     */
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

    /**
     * 此为ArrayList的底层容器
     */
    transient Object[] elementData;

    /**
     * list中当前存储的元素数目(并非数组的大小)
     */
    private int size;

在查找和修改元素上由于能够使用二分查找的随机访问(Random Access)策略,因此速度极快:

   /**
    * get方法获得数组中的元素,先检查index是否在允许的范围内,然后直接去拿下标为index的元素
    */
    public E get(int index) {
        rangeCheck(index);

        return elementData(index);
    }
       
   /**
    * set方法修改数组中的元素,先检查index是否在允许的范围内,然后直接替换下标为index的元素
    */
    public E set(int index, E element) {
        rangeCheck(index);

        E oldValue = elementData(index);
        elementData[index] = element;
        return oldValue;
    }
    
    private void rangeCheck(int index) {
        if (index >= size)
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
    }

    E elementData(int index) {
        return (E) elementData[index];
    }

在增删过程中因为需要移动元素,因此效率大打折扣。

  • 当移除元素时,数组会跳过需要移除的元素并复制,然后重新将新的list赋值给自己:
   /**
    * remove方法移除数组中的元素
    */
    public E remove(int index) {
        rangeCheck(index);

        modCount++; // 此数组被修改次数+1
        E oldValue = elementData(index);

        int numMoved = size - index - 1;
        if (numMoved > 0)
            // 跳过需要移除的元素并复制之后的所有元素,然后给到自己的需要移除的元素的位置进行覆盖
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);
        elementData[--size] = null; // 将数组最后一位置空,等待GC回收

        return oldValue;
    }

提一下arraycopy(Object src, int srcPos, Object dest, int destPos, int length) 方法:此方法为java.lang.System类下的一个native方法,第一个参数是源数组;第二个参数为源数组复制的起始位置;第三个参数为目标数组;第思个参数为被复制的部分放置到目标数组的起始位置;最后一个参数为复制的长度。

  • 当增加元素时,若总元素数目超出了当前list的最大容量,会进行扩容操作。每次扩容会达到当前容量的1.5倍:
    /**
     * 添加元素到数组末尾
     */
    public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // 判断是否应该扩容,如果需要,进行扩容
        elementData[size++] = e;
        return true;
    }
    
    /**
     * 扩容方法,传入参数为需要装载的最小容量
     */
    private void grow(int minCapacity) {
        // 之前的数组容量
        int oldCapacity = elementData.length;
        // 新的容量 = 旧的容量 + 1/2 的旧容量
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        // 如果容量足够装所有元素则确定新的容量
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        // 如果容量不够装所有元素则使用 hugeCapacity(minCapacity) 方法处理
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        // 复制数组
        elementData = Arrays.copyOf(elementData, newCapacity);
    }
    
    /**
     * MAX_ARRAY_SIZE 为 Integer.MAX_VALUE - 8,如果只比array的最大限制大一点点,可以接受;否则只能扩容到 Integer.MAX_VALUE
     */
    private static int hugeCapacity(int minCapacity) {
        if (minCapacity < 0) // overflow
            throw new OutOfMemoryError();
        return (minCapacity > MAX_ARRAY_SIZE) ?
            Integer.MAX_VALUE :
            MAX_ARRAY_SIZE;
    }

我们总说“如果我们能够知道list的最终大小范围时,合理的设置其初始容量将有利于性能。”在这里得到了合理解释。试想在数组扩容到原容量的1.5倍大小时,会出现一些空置的数组空间。这就意味着通常最后我们获得的这个list所占的空间总是比实际需要的要大。如果你有一个包含大量元素的ArrayList对象, 那么最终将有很大的空间会被浪费。虽然我们有trimToSize()方法能够在ArrayList分配完毕后干掉数组最后方浪费掉的空间,但由于每次扩容都会对数组进行重新分配,而重新分配的过程比较耗资源,从而导致性能的下降。好的初值设定能够尽可能的避免此问题产生。

LinkedList

LinkedList是一种基于双向链表形式进行存储的List结构,正和数组相反,增删元素速度快,而查询元素速度较慢。

    transient int size = 0;

    /**
     * 指向链表头部节点
     */
    transient Node<E> first;

    /**
     * 指向链表尾部节点
     */
    transient Node<E> last;

    /**
     * 构造方法为空,初始化后size=0,头尾节点都为空
     */
    public LinkedList() {
    }

LinkedList的类中定义了一个私有静态内部类Node,每个Node对象即为LinkedList中的一个元素。而Node类中有3个变量:范型的item变量保存元素的值(地址),prevnext变量分别存有此节点的上一个/下一个元素对象。而在LinkedList中只存有list中的第一项(transient Node<E> first;)和最后一项(transient Node<E> last;)。因此LinkedList的存储方式造成了每个元素上的额外开销。

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

在查找和修改元素上由于需要对链表进行遍历,因此速度较慢:

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

    public E set(int index, E element) {
        checkElementIndex(index);
        Node<E> x = node(index);
        E oldVal = x.item;
        x.item = element;
        return oldVal;
    }

    /**
     * 查找index对应节点的底层方法,通过遍历的方式寻找对应下标的节点
     */
    Node<E> node(int index) {
        // assert isElementIndex(index);
        
        // 这里有一个有意思的点,通过下标是否小于size的一半来选择使用从头到尾遍历还是从尾到头遍历来增加效率
        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;
        }
    }

在增删元素时,无需移动元素位置,只需改变个别节点的指针指向即可,效率极高。

  • add(E e)添加元素到链表尾
    public void addLast(E e) {
        linkLast(e);
    }

   /**
    * 默认加到链表尾,只要还能分配空间,就会返回true,否则抛出OOM异常
    */
    public boolean add(E e) {
        linkLast(e);
        return true;
    }
    
    /**
     * 把新元素链到链表末尾
     */
    void linkLast(E e) {
        // 旧的尾节点
        final Node<E> l = last;
        // 构造新的尾节点Node对象
        final Node<E> newNode = new Node<>(l, e, null);
        // 将新的尾节点赋值给LinkedList中的last参数
        last = newNode;
        // 如果这个链表一开始没有任何元素,旧的尾节点则为空,那么将新的尾节点也赋值给LinkedList中的first参数;否则将旧的尾节点的next节点设置为新加的节点
        if (l == null)
            first = newNode;
        else
            l.next = newNode;
        // 链表长度+1
        size++;
        // 链表修改次数+1
        modCount++;
    }

  • addFirst(E e)添加元素到链表头(和添加元素到表尾实现相同)
    public void addFirst(E e) {
        linkFirst(e);
    }
    
    /**
     * 把新元素链到链表头部
     */
    private void linkFirst(E e) {
        // 旧的头节点
        final Node<E> f = first;
        // 构造新的头节点Node对象
        final Node<E> newNode = new Node<>(null, e, f);
        // 将新的头节点赋值给LinkedList中的first参数
        first = newNode;
        // 如果这个链表一开始没有任何元素,旧的头节点则为空,那么将新的头节点也赋值给LinkedList中的last参数;否则将旧的头节点的prev节点设置为新加的节点
        if (f == null)
            last = newNode;
        else
            f.prev = newNode;
        // 链表长度+1
        size++;
        // 链表修改次数+1
        modCount++;
    }
  • add(int index, E element) 添加元素到指定位置
    public void add(int index, E element) {
        checkPositionIndex(index); // 检查index是否越界

        if (index == size)
            linkLast(element); //和 add(E e) 添加到链表末尾相同
        else
            linkBefore(element, node(index)); // node(index)即前处遍历获得对应节点的方法
    }

    private void checkPositionIndex(int index) {
        if (!isPositionIndex(index))
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
    }

    /**
     * 把新元素链到指定节点之前
     */
    void linkBefore(E e, Node<E> succ) {
        // assert succ != null;
        // 获得index处节点的前一节点
        final Node<E> pred = succ.prev;
        // 构造新的节点Node对象,前节点为原index处节点的前一节点,后节点为index处节点
        final Node<E> newNode = new Node<>(pred, e, succ);
        // 设置index处节点的前一节点为新节点
        succ.prev = newNode;
        // 若index处原节点的前一节点为空,则index处为头节点,需要把头节点标志位设置成新节点,否则将index处原节点的前一节点的后节点设置为新的节点
        if (pred == null)
            first = newNode;
        else
            pred.next = newNode;
        size++;
        modCount++;
    }

删除元素,即将当前节点的前后节点的prev/next参数相互关联,并修改LinkedList中的 first/next 节点(可能)。

    public E remove(int index) {
        checkElementIndex(index);
        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;

        if (prev == null) {
            first = next;
        } else {
            prev.next = next;
            x.prev = null;
        }

        if (next == null) {
            last = prev;
        } else {
            next.prev = prev;
            x.next = null;
        }

        x.item = null;
        size--;
        modCount++;
        return element;
    }

总结

ArrayList和LinkedList各有所长,在细节上,
1.在列表末尾增加一个元素所花的开销都是固定的。对 ArrayList而言,偶尔可能会引发数组的重新分配;而对LinkedList而言,开销一直是分配一个内部Node对象。
2.在ArrayList的 中间增删元素意味着这个列表中剩余的元素都会被移动;而在LinkedList的中间增删节点的开销是固定的。
3.LinkedList不支持高效的随机元素访问。
4.ArrayList的空间浪费主要体现在在list列表的结尾可能会预留一定的容量空间,而LinkedList的空间花费则体现在它的每一个元素都需要消耗更多的空间。

综上, 若经常在一系列数据中间处进行增删操作,使用LinkedList性能更佳,而若查询次数较多且增删总是出现在末尾处,使用ArrayList更强。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值