Java - ArrayList和LinkedList的区别

文章内容转自【沉默王二】,对几篇相关文章进行了整合

ArrayListLinkedList 是 List 接口的两种不同实现,并且两者都不是线程安全的。ArrayList 是基于动态数组实现的,LinkedList 是基于双向链表实现的。这是最本质的区别。

ArrayList随机访问一个元素的时间复杂度为 O(1),但 LinkedList 要复杂一些,因为数据增大多少倍,耗时就增大多少倍,因为要循环遍历,所以时间复杂度为 O(n)。
至于 LinkedList 在插入、添加、删除元素的时候有没有比 ArrayList 更快,这要取决于数据量的大小,以及元素所在的位置,一般来说操作位置靠近开头和结尾速度就会很快,如果操作在位置是在中间位置,由于会遍历大量的数据,所以效率会很低。不过,从理论上来说,由于不需要移动数组,应该会更快一些。

ArrayList的实现

在这里插入图片描述
ArrayList 实现了 List 接口,继承了 AbstractList 抽象类,底层是基于数组实现的,并且实现了动态扩容

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
    private static final int DEFAULT_CAPACITY = 10;
    transient Object[] elementData;
    private int size;
}

注:ArrayList 中的关键字段 elementData 使用了 transient 关键字修饰,这个关键字的作用是,让它修饰的字段不被序列化。


如果非常确定 ArrayList 中元素的个数,在创建的时候还可以指定初始大小。

List<String> alist = new ArrayList<>(20);

这样做的好处是,可以有效地避免在添加新的元素时进行不必要的扩容。但通常情况下,我们很难确定 ArrayList 中元素的个数,因此一般不指定初始大小。
如果创建 ArrayList 的时候没有指定初始大小,那么 ArrayList 的初始大小就是 DEFAULT_CAPACITY = 10


ArrayList 还实现了 RandomAccess 接口,这是一个标记接口:

public interface RandomAccess {
}

内部是空的,标记“实现了这个接口的类支持快速(通常是固定时间)随机访问”。快速随机访问是什么意思呢?就是说不需要遍历,就可以通过下标(索引)直接访问到内存地址。
get(int index) 方法的时间复杂度为 O(1) ,因为是直接从底层数组根据下标获取的,和数组长度无关。

public E get(int index) {
    Objects.checkIndex(index, size);
    return elementData(index);
}
E elementData(int index) {
    return (E) elementData[index];
}

ArrayList 还实现了 Cloneable 接口,这表明 ArrayList 是支持拷贝的。ArrayList 内部的确也重写了 Object 类的clone()方法。

public Object clone() {
    try {
        ArrayList<?> v = (ArrayList<?>) super.clone();
        v.elementData = Arrays.copyOf(elementData, size);
        v.modCount = 0;
        return v;
    } catch (CloneNotSupportedException e) {
        // this shouldn't happen, since we are Cloneable
        throw new InternalError(e);
    }
}

ArrayList 还实现了 Serializable 接口,同样是一个标记接口:

public interface Serializable {
}

内部也是空的,标记“实现了这个接口的类支持序列化”。(Java 的序列化是指,将对象转换成以字节序列的形式来表示,这些字节序中包含了对象的字段和方法。序列化后的对象可以被写到数据库、写到文件,也可用于网络传输。)

这不前后矛盾吗?一个类既然实现了Serilizable 接口,肯定是想要被序列化的,对吧?那为什么保存关键数据的 elementData 又不想被序列化呢?

这还得从 “ArrayList 是基于数组实现的”开始说起。大家都知道,数组是定长的,一旦声明了,长度(容量)就是固定的,一旦装满了,就不能添加新的元素进来了。

ArrayList 想能屈能伸,所以它实现了动态扩容。一旦在添加元素的时候,发现容量用满了 s == elementData.length,就按照原来数组的 1.5 倍(oldCapacity >> 1)进行扩容。扩容之后,再将原有的数组复制到新分配的内存地址上 Arrays.copyOf(elementData, newCapacity)

private void add(E e, Object[] elementData, int s) {
    if (s == elementData.length)
        elementData = grow();
    elementData[s] = e;
    size = s + 1;
}

private Object[] grow() {
    return grow(size + 1);
}

private Object[] grow(int minCapacity) {
    int oldCapacity = elementData.length;
    if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        int newCapacity = ArraysSupport.newLength(oldCapacity,
                minCapacity - oldCapacity, /* minimum growth */
                oldCapacity >> 1           /* preferred growth */);
        return elementData = Arrays.copyOf(elementData, newCapacity);
    } else {
        return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
    }
}

注意:内部执行的 Arrays.copyOf() 方法是耗时的关键,需要把原有数组中的元素复制到扩容后的新数组当中。

动态扩容意味着数组的实际大小可能永远无法被填满的,总有多余出来空置的内存空间。

比如说,默认的数组大小是 10,当添加第 11 个元素的时候,数组的长度扩容了 1.5 倍,也就是 15,意味着还有 4 个内存空间是闲置的,对吧?

序列化的时候,如果把整个数组都序列化的话,是不是就多序列化了 4 个内存空间。当存储的元素数量非常非常多的时候,闲置的空间就非常非常大,序列化耗费的时间就会非常非常多。

于是,ArrayList 做了一个愉快而又聪明的决定,内部提供了两个私有方法 writeObjectreadObject 来完成序列化和反序列化。

private void writeObject(java.io.ObjectOutputStream s)
        throws java.io.IOException {
    // Write out element count, and any hidden stuff
    int expectedModCount = modCount;
    s.defaultWriteObject();

    // Write out size as capacity for behavioral compatibility with clone()
    s.writeInt(size);

    // Write out all elements in the proper order.
    for (int i=0; i<size; i++) {
        s.writeObject(elementData[i]);
    }

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

从 writeObject 方法的源码中可以看得出,它使用了 ArrayList 的实际大小size而不是数组的长度(elementData.length)来作为元素的上限进行序列化。

此处应该有掌声啊!



LinkedList的实现

在这里插入图片描述
LinkedList 是一个继承自 AbstractSequentialList 的双向链表,因此它也可以被当作堆栈、队列或双端队列进行操作。

public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
    transient int size = 0;
    transient Node<E> first;
    transient Node<E> last;
}

LinkedList 内部定义了一个 Node 节点,它包含 3 个部分:元素内容 item,前引用 prev 和后引用 next。代码如下所示:

private static class Node<E> {
    E item;
    LinkedList.Node<E> next;
    LinkedList.Node<E> prev;

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

LinkedList 还实现了 Cloneable 接口,这表明 LinkedList 是支持拷贝的。

LinkedList 还实现了 Serializable 接口,这表明 LinkedList 是支持序列化的。眼睛雪亮的小伙伴可能又注意到了,LinkedList 中的关键字段 size、first、last 都使用了 transient 关键字修饰,这不又矛盾了吗?到底是想序列化还是不想序列化?
答案是 LinkedList 想按照自己的方式序列化,来看它自己实现的 writeObject() 方法:

private void writeObject(java.io.ObjectOutputStream s)
        throws java.io.IOException {
    // Write out any hidden serialization magic
    s.defaultWriteObject();

    // Write out size
    s.writeInt(size);

    // Write out all elements in the proper order.
    for (LinkedList.Node<E> x = first; x != null; x = x.next)
        s.writeObject(x.item);
}

LinkedList 在序列化的时候只保留了元素的内容 item,并没有保留元素的前后引用。这样就节省了不少内存空间。
那有些小伙伴可能就疑惑了,只保留元素内容,不保留前后引用,那反序列化的时候怎么办?

private void readObject(java.io.ObjectInputStream s)
        throws java.io.IOException, ClassNotFoundException {
    // Read in any hidden serialization magic
    s.defaultReadObject();

    // Read in size
    int size = s.readInt();

    // Read in all elements in the proper order.
    for (int i = 0; i < size; i++)
        linkLast((E)s.readObject());
}

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

注意 for 循环中的 linkLast() 方法,它可以把链表重新链接起来,这样就恢复了链表序列化之前的顺序。

和 ArrayList 相比,LinkedList 没有实现 RandomAccess 接口,这是因为 LinkedList 存储数据的内存地址是不连续的,所以不支持随机访问。
get(int index) 方法的时间复杂度为 O(n),因为需要循环遍历整个链表。

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

LinkedList.Node<E> node(int index) {
    // assert isElementIndex(index);

    if (index < (size >> 1)) {
        LinkedList.Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {
        LinkedList.Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}

下标小于链表长度的一半时,从前往后遍历;否则从后往前遍历,这样从理论上说,就节省了一半的时间。

如果下标为 0 或者 list.size() - 1 的话,时间复杂度为 O(1)。因为first 和 last 在链表中是直接存储的,可以使用 getFirst()getLast() 方法。

public E getFirst() {
    final LinkedList.Node<E> f = first;
    if (f == null)
        throw new NoSuchElementException();
    return f.item;
}

public E getLast() {
    final LinkedList.Node<E> l = last;
    if (l == null)
        throw new NoSuchElementException();
    return l.item;
}

ArrayList和LinkedList添加元素比较

(一)ArrayList

  1. add(E e) 添加到末尾:先判断是否需要扩容,然后直接通过索引将元素添加到末尾。如果不需要扩容,时间复杂度为 O(1),如果扩容,时间复杂度为 O(n)。
public boolean add(E e) {
    modCount++;
    add(e, elementData, size);
    return true;
}

private void add(E e, Object[] elementData, int s) {
    if (s == elementData.length)
        elementData = grow();
    elementData[s] = e;
    size = s + 1;
}
  1. add(int index, E element) 添加到指定位置:先检查插入的位置是否在合理的范围之内,然后判断是否需要扩容,再把该位置以后的元素复制到新添加元素的位置之后,最后通过索引将元素添加到指定的位置。这种情况是非常伤的,性能会比较差。根据最坏的打算(不管需要不需要扩容,System.arraycopy() 肯定要执行),所以时间复杂度为 O(n)。
public void add(int index, E element) {
    rangeCheckForAdd(index);
    modCount++;
    final int s;
    Object[] elementData;
    if ((s = size) == (elementData = this.elementData).length)
        elementData = grow();
    System.arraycopy(elementData, index,
            elementData, index + 1,
            s - index);
    elementData[index] = element;
    size = s + 1;
}

(二)LinkedList

  1. add(E e) 添加到末尾:先将队尾的节点 last 存放到临时变量 l 中,然后生成新的 Node 节点,并赋给 last,如果 l 为 null,说明是第一次添加,所以 first 为新的节点;否则将新的节点赋给之前 last 的 next。时间复杂度为 O(1)。
public boolean add(E e) {
    linkLast(e);
    return true;
}
void linkLast(E e) {
    final LinkedList.Node<E> l = last;
    final LinkedList.Node<E> newNode = new LinkedList.Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    size++;
    modCount++;
}
  1. add(int index, E element) 添加到指定位置:先检查插入的位置是否在合理的范围之内,然后判断插入的位置是否是队尾,如果是,添加到队尾;否则执行 linkBefore() 方法。
public void add(int index, E element) {
    checkPositionIndex(index);

    if (index == size)
        linkLast(element);
    else
        linkBefore(element, node(index));
}
LinkedList.Node<E> node(int index) {
    // assert isElementIndex(index);

    if (index < (size >> 1)) {
        LinkedList.Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {
        LinkedList.Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}
void linkBefore(E e, LinkedList.Node<E> succ) {
    // assert succ != null;
    final LinkedList.Node<E> pred = succ.prev;
    final LinkedList.Node<E> newNode = new LinkedList.Node<>(pred, e, succ);
    succ.prev = newNode;
    if (pred == null)
        first = newNode;
    else
        pred.next = newNode;
    size++;
    modCount++;
}

在执行 linkBefore() 方法之前,会调用 node() 方法查找指定位置上的元素,这一步是需要遍历 LinkedList 的。如果插入的位置靠近前半段,就从队头开始往后找;否则从队尾往前找。也就是说,如果插入的位置越靠近 LinkedList 的中间位置,遍历所花费的时间就越多。

找到指定位置上的元素(succ)之后,就开始执行 linkBefore() 方法了,先将 succ 的前一个节点(prev)存放到临时变量 pred 中,然后生成新的 Node 节点(newNode),并将 succ 的前一个节点变更为 newNode,如果 pred 为 null,说明插入的是队头,所以 first 为新节点;否则将 pred 的后一个节点变更为 newNode。
在这里插入图片描述

当两者的起始长度是一样的情况下:

  • 如果是从集合的头部新增元素,ArrayList 花费的时间应该比 LinkedList 要多很多,因为需要对头部以后的元素进行复制。
  • 如果是从集合的中间位置新增元素,ArrayList 花费的时间搞不好要比 LinkedList 少很多,因为 LinkedList 需要遍历。
  • 如果是从集合的尾部新增元素,ArrayList 花费的时间应该比 LinkedList 少,因为数组是一段连续的内存空间,也不需要复制数组;而链表需要创建新的对象,前后引用也要重新排列。

【结论】

ArrayList 在添加元素的时候如果不涉及到扩容,性能在两种情况下(中间位置新增元素、尾部新增元素)比 LinkedList 好很多,只有头部新增元素的时候比 LinkedList 差,因为数组复制的原因。

当然了,如果涉及到数组扩容的话,ArrayList 的性能就没那么可观了,因为扩容的时候也要复制数组。

ArrayList和LinkedList删除元素比较

(一)ArrayList

  1. remove(Object) 直接删除:时间复杂度为 O(n)
public boolean remove(Object o) {
    final Object[] es = elementData;
    final int size = this.size;
    int i = 0;
    found: {
        if (o == null) {
            for (; i < size; i++)
                if (es[i] == null)
                    break found;
        } else {
            for (; i < size; i++)
                if (o.equals(es[i]))
                    break found;
        }
        return false;
    }
    fastRemove(es, i);
    return true;
}
  1. remove(int) 按照索引删除:时间复杂度为 O(n)
public E remove(int index) {
    Objects.checkIndex(index, size);
    final Object[] es = elementData;

    @SuppressWarnings("unchecked") E oldValue = (E) es[index];
    fastRemove(es, index);

    return oldValue;
}

它们最后调用的都是 fastRemove(Object, int) 方法。当删除的是末尾的元素时,不需要复制数组,直接把末尾的元素赋值为 null 即可,只要删除的不是最后一个元素,都需要数组重组。删除的元素位置越靠前,代价就越大。

private void fastRemove(Object[] es, int i) {
    modCount++;
    final int newSize;
    if ((newSize = size - 1) > i)
        System.arraycopy(es, i + 1, es, i, newSize - i);
    es[size = newSize] = null;
}

(二)LinkedList

  1. remove(Object) 直接删除:遍历整个集合,时间复杂度为 O(n)。
public boolean remove(Object o) {
    if (o == null) {
        for (LinkedList.Node<E> x = first; x != null; x = x.next) {
            if (x.item == null) {
                unlink(x);
                return true;
            }
        }
    } else {
        for (LinkedList.Node<E> x = first; x != null; x = x.next) {
            if (o.equals(x.item)) {
                unlink(x);
                return true;
            }
        }
    }
    return false;
}
  1. remove(int) 按照索引删除:考虑到需要调用 node(index) 方法查找元素,所以时间复杂度为 O(n)。
public E remove(int index) {
    checkElementIndex(index);
    return unlink(node(index));
}

该方法会调用 unlink() 方法对前后节点进行更新。

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;
    }
  1. removeFirst():删除第一个节点。时间复杂度为 O(1) 。
public E removeFirst() {
    final LinkedList.Node<E> f = first;
    if (f == null)
        throw new NoSuchElementException();
    return unlinkFirst(f);
}
private E unlinkFirst(LinkedList.Node<E> f) {
    // assert f == first && f != null;
    final E element = f.item;
    final LinkedList.Node<E> next = f.next;
    f.item = null;
    f.next = null; // help GC
    first = next;
    if (next == null)
        last = null;
    else
        next.prev = null;
    size--;
    modCount++;
    return element;
}
  1. removeLast():删除最后一个节点。时间复杂度为 O(1) 。

代码测试结果和新增元素保持一致:

  • 从集合头部删除元素时,ArrayList 花费的时间比 LinkedList 多很多;
  • 从集合中间位置删除元素时,ArrayList 花费的时间比 LinkedList 少很多;
  • 从集合尾部删除元素时,ArrayList 花费的时间比 LinkedList 少一点。

【结论】

LinkedList 在删除比较靠前和比较靠后的元素时,非常高效,但如果删除的是中间位置的元素,效率就比较低了。

ArrayList和LinkedList遍历元素比较

(一)ArrayList

  1. get(int) :根据索引找元素非常的快,一步到位。时间复杂度 O(1) 。
public E get(int index) {
    Objects.checkIndex(index, size);
    return elementData(index);
}
  1. indexOf(Object) :根据元素找索引的话,就需要遍历整个数组了,从头到尾依次找。时间复杂度 O(n) 。
public int indexOf(Object o) {
    return indexOfRange(o, 0, size);
}

int indexOfRange(Object o, int start, int end) {
    Object[] es = elementData;
    if (o == null) {
        for (int i = start; i < end; i++) {
            if (es[i] == null) {
                return i;
            }
        }
    } else {
        for (int i = start; i < end; i++) {
            if (o.equals(es[i])) {
                return i;
            }
        }
    }
    return -1;
}

(二)LinkedList

  1. get(int) :需要调用 node(int) 方法,时间复杂度 O(n) 。
public E get(int index) {
    checkElementIndex(index);
    return node(index).item;
}
  1. indexOf(Object) :和 ArrayList 的 indexOf() 类似,需要遍历整个链表。
    如果使用的是 for 循环,可想而知 LinkedList 在 get 的时候性能会非常差,因为每一次外层的 for 循环,都要执行一次 node(int) 方法进行前后半段的遍历。
    如果使用的是迭代器,迭代器只会调用一次 node(int) 方法,返回第一个节点。在此之后,迭代器就执行 hasNext() 判断有没有下一个,执行 next() 方法下一个节点。

【结论】

遍历 LinkedList 的时候,千万不要使用 for 循环,要使用迭代器。
for 循环遍历的时候,ArrayList 花费的时间远小于 LinkedList;迭代器遍历的时候,两者性能差不多。

大佬总结

如果列表很大很大,ArrayList 和 LinkedList 在内存的使用上也有所不同。LinkedList 的每个元素都有更多开销,因为要存储上一个和下一个元素的地址。ArrayList 没有这样的开销。

但是,ArrayList 占用的内存在声明的时候就已经确定了(默认大小为 10),不管实际上是否添加了元素,因为复杂对象的数组会通过 null 来填充。LinkedList 在声明的时候不需要指定大小,元素增加或者删除时大小随之改变。

另外,ArrayList 只能用作列表;LinkedList 可以用作列表或者队列,因为它还实现了 Deque 接口。

我在写这篇文章的时候,遇到了一些问题,所以请教了一些大厂的技术大佬,结果有个朋友说,“如果真的不知道该用 ArrayList 还是 LinkedList,就选择 ArrayList 吧!”

我当时以为他在和我开玩笑呢,结果通过时间复杂度的分析,好像他说得有道理啊。查询的时候,ArrayList 比 LinkedList 快,这是毋庸置疑的;插入和删除的时候,之前有很多资料说 LinkedList 更快,时间复杂度为 O(1) ,但其实不是的,因为要遍历列表,对吧?

反而 ArrayList 更轻量级,不需要在每个元素上维护上一个和下一个元素的地址。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值