ArrayList与LinkedList性能比较

222 阅读7分钟

背景

在java中ArrayList与LinkedList的基本区别是一个底层实现是数组,一个底层实现是链表。但是在实际的项目开发中到底什么业务场景下使用ArrayList,什么场景下使用LinkedList呢?
这里先给出结论:把ArrayList当成你的女(男)朋友,LinkedList是其他人,问选哪个?ArrayList!!!

大家大概率对这个结论还是存疑的,其实问题主要就集中在不同场景下的插入(删除)效率与内存占用上。至于读的效率就不再讨论了,因为肯定是有索引ArrayList的效率高。

插入效率

插入(删除)效率可以分为以下几种情况:随机插入,头插入,尾插入。

随机插入

在大家的普遍认知中,可能都会认为LinkedList的随机插入(删除)是要比ArrayList的效率要高的,原因是LinkedList底层基于链表,ArrayList基于数组。链表节点的插入与删除是要比数组的效率要高的,因为在数组中间位置插入或删除元素会涉及到其他元素的位移。至于这个说法,先按下不表,我们用实际的代码看看结果如何。 随机插入(删除)有两种情况,一种是通过List的下标进行操作,一种是迭代器在遍历中删除。

public class ListTest extends TestCase {
    public ListTest(String name) {
        super(name);
    }

    class Data {
        private final int number;

        public Data(int number) {
            this.number = number;
        }
    }

    public void testArrayListRandomInsertByIndex() throws Exception {
        int baseSize = 1024 * 1024;
        List<Data> dataList = new ArrayList<>(baseSize);
        for (int i = 0; i < baseSize; i++) {
            dataList.add(new Data(i));
        }
        listRandomInsertByIndex(dataList);
    }

    public void testLinkedListRandomInsertByIndex() throws Exception {
        int baseSize = 1024 * 1024;
        List<Data> dataList = new LinkedList<>();
        for (int i = 0; i < baseSize; i++) {
            dataList.add(new Data(i));
        }
        listRandomInsertByIndex(dataList);
    }

    private void listRandomInsertByIndex(List<Data> dataList) throws Exception {
        Random random = new Random();
        int testTime = 500;
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        for (int i = 0; i < testTime; i++) {
            dataList.add(random.nextInt(dataList.size()), new Data(random.nextInt(Integer.MAX_VALUE)));
        }
        stopWatch.stop();
        System.out.println(dataList.getClass().getName()
                + " random insertion operation takes "
                + stopWatch.getLastTaskTimeMillis() + " milliseconds ");
    }

    public void testLinkedListIteratorRem() {
        int baseSize = 1024 * 1024;
        List<Data> dataList = new LinkedList<>();
        for (int i = 0; i < baseSize; i++) {
            dataList.add(new Data(i));
        }
        listIteratorRem(dataList, 3);
    }

    public void testArrayListIteratorRem() {
        int baseSize = 1024 * 1024;
        List<Data> dataList = new ArrayList<>(baseSize);
        for (int i = 0; i < baseSize; i++) {
            dataList.add(new Data(i));
        }
        listIteratorRem(dataList, 3);
    }

    private void listIteratorRem(List<Data> dataList, int number) {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        for (Iterator<Data> iter = dataList.iterator(); iter.hasNext();) {
            Data data = iter.next();
            if (data.number % number == 0) {
                iter.remove();
            }
        }
        stopWatch.stop();
        System.out.println(dataList.getClass().getName()
                + " random remove operation takes "
                + stopWatch.getLastTaskTimeMillis() + " milliseconds ");
    }
}

运行结果如下

java.util.ArrayList random insertion operation takes 67 milliseconds 
java.util.LinkedList random insertion operation takes 916 milliseconds 
java.util.ArrayList random remove operation takes 17975 milliseconds 
java.util.LinkedList random remove operation takes 35 milliseconds 

从上面的结果我们可以看出在通过下标的插入的过程中效率是ArrayList的效率要高于LinkedList。这里可能会让大家觉得违反直觉。因为链表的插入/删除元素的效率应该是要高于数组的,这句话本身没有错。但是通过下标进行删除元素时并不只是进行了元素的删除,大家容易忽略了删除前需要通过下标找到要删除的元素这一步。因此ArrayList与LinkedList在通过下标进行随机插入的场景下,如果LinkedList寻找目标元素代价 > ArrayList移动数组的代价,那么就ArrayList性能好。反之,则LinkedList性能好。

虽然移动数组的代价与通过链表寻找目标元素的代价都会随着元素的增长而增长,但移动数组的代价增加的并不多。因为数组的移动是通过C++的代码进行内存拷贝来完成的,并不需要一个个的遍历数组进行赋值。因此在绝大多数的情况下,随机插入的效率ArrayList在期望上优于LinkedList的。

但如果是如果是通过迭代器来移除元素的话,结果就不一样了,LinkedList比ArrayList的效率不在一个层次。因为通过迭代器来移除元素的场景下,才是真正发挥了链表的优势。但是这种情况下的业务场景很少,即使有也有可能可以用其他的数据结构进行代替。

尾插入

这个场景是用的最多的场景了。在这个场景下,大家肯定也认为LinkedList的速度要优于ArrayList。因为这种场景LinkedList肯定是很好发挥了它的底层是链表实现的优势,而ArrayList因为数组有长度限制,存在扩容的代价,所以速度慢于LinkedList。

同样的,我们先用代码测试下

public class ListTest extends TestCase {
    public ListTest(String name) {
        super(name);
    }

    class Data {
        private final int number;

        public Data(int number) {
            this.number = number;
        }
    }

    public void testArrayListTailInsert() {
        listTailInsert(new ArrayList<>(), 1024 * 1024 * 30);
    }

    public void testLinkedListTailInsert() {
        listTailInsert(new LinkedList<>(), 1024 * 1024 * 30);
    }

    private void listTailInsert(List<Data> dataList, int size) {
        StopWatch watch = new StopWatch();
        watch.start();
        for (int i = 0; i < size; i++) {
            dataList.add(new Data(i));
        }
        watch.stop();
        System.out.println(dataList.getClass().getName()
                + " tail insert operation takes "
                + watch.getLastTaskTimeMillis() + " milliseconds ");
    }
}

运行结果

java.util.LinkedList tail insert operation takes 6637 milliseconds 
java.util.ArrayList tail insert operation takes 1083 milliseconds 

从测试结果来看ArrayList的尾插入也是优于LinkedList。答案我们可以通过查看两者的源码得到。 LinkedList的add主要逻辑

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

ArrayList的主要逻辑

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

可以发现,LinkedList因为通过链表的方式实现,所以每增加一个元素都会增加一个Node,然后再将新的Node接到尾节点后,并将自己设为尾节点。而通过数组实现的ArrayList如果不需要扩容的话就简单多了,直接给数组上的元素赋值(引用)即可。

也就是说如果不考虑扩容,数组的尾插入的效率理论上的优于链表的,但是ArrayList的扩容策略保证了尽可能少的进行扩容。所以能让ArrayList在大部分情况下即使包含了扩容操作也是优于LinkedList。

头插入

关于头插入这里就不详细说测试过程了,因为对于数组来说在头部插入一个元素,肯定会导致大量的数组元素的移动。而对于链表就简单多了,所以头插入的结果肯定是LinkedList优于ArrayList。

内存占用

其实LinkedList与ArrayList相比最大的问题是它的内存占用,LinkedList的总体内存占用平均是ArrayList占用内存的4-6倍! 为什么内存占用大是最大的问题呢?内存占用大不仅仅是消耗物理内存多的问题,同时还会影响JVM的GC,具体的影响的大小要视所选择的垃圾收集器而定。 至于4-6倍这个结果是怎么得出的,可以先看下知乎的这个话题

这个时候有人可能会想到,ArrayList会有空间浪费的情况。比如ArrayList经过一次扩容后,有效的元素并不会占满整个数组。扩容之后的数组会有大量的位置是没用到的,但仍然占用着内存。最典型的就是ArrayList如果不设置初始的数组大小,在进行一次add操作后,会自动将数组扩容成10的数组。

image.png

上面是ArrayList扩容大小的主要逻辑,其中DEFAULTCAPACTTY=10,默认的ArrayList构造器引用一个空数组。当有第一个元素add进ArrayList时会扩容成容量为10的数组。如果出现大量的这种大小只有1的数组就会导致内存空间浪费,这种情况下初始化ArrayList时可以通过new ArrayList(1)来解决。
除了上面提到的情况外,即使ArrayList会有多余的内存占用,但总体的内存占用大小也是低于LinkedList的。如果只看元素,ArrayList的数组新增一个元素只需要4个字节,而LinkedList新增一个节点则需要构造一个LinkedList.Node对象,占用24个字节,也就意味着随着List的元素增加,它们之间的差距会越来越大。

总结

ArrayListLinkedList
头插入效率低效率高
随机插入效率一般效率一般
尾插入效率高大概率不如ArrayList
内存占用存在多余的内存占用情况,但总体优于LinkedList占用内存大

所以除了是头插入的,或者在迭代器里面移除元素的场景,其他情况下ArrayList都优于LinkedList。对于存在头插入的场景,也不应该用List来实现,可以考虑换成ArrayDeque。而如果出现需要用迭代器移除元素的场景,就意味有其他的优化空间,因为这种移除方式本身就是效率很低的。可以考虑在换成Map或者Set来实现,如果遇到不能重写hashcode和equals方法的情况,可以在插入时就将他们按类别分配到不同的List中,移除时就将对应的整个List删除。