ArrayList 与LinkedList

对比

在java集合类中,不管是谁一定都用过如下两种集合。通常我们呢只是会用就行了,记住ArrayList 查询快,增删慢,LinkList刚好相反 查询慢增删快,即可,但是实际真的是这样吗? 下面我们做一个测试,使用的是JDK11

    public static void main(String[] args) {
        ArrayList list1 = new ArrayList<>(10000000);
        LinkedList list2 = new LinkedList<>();
        int num = 10000000;
        long tim1 = System.currentTimeMillis();
        for (int i = 0; i < num; i++) {
            list1.add(i);
        }
        long tim2 = System.currentTimeMillis();
        list1.add(2,2);
        long tim3 = System.currentTimeMillis();
        list1.add(num-1,num-1);
        long tim4 = System.currentTimeMillis();

        System.out.println("ArrayList 插入"+num+"条数据耗时:"+String.valueOf(tim2-tim1));
        System.out.println("ArrayList 头部插入数据耗时:"+String.valueOf(tim3-tim2));
        System.out.println("ArrayList 尾部插入数据耗时:"+String.valueOf(tim4-tim3));


        long tim5 = System.currentTimeMillis();
        for (int i = 0; i < num; i++) {
            list2.add(i);
        }
        long tim6 = System.currentTimeMillis();
         list2.add(2,2);
        long tim7 = System.currentTimeMillis();
        list2.add(num-1,num-1);
        long tim8 = System.currentTimeMillis();
        System.out.println("LinkedList 插入"+num+"条数据耗时:"+String.valueOf(tim6-tim5));
        System.out.println("LinkedList 头部插入数据耗时:"+String.valueOf(tim7-tim6));
        System.out.println("LinkedList 尾部插入数据耗时:"+String.valueOf(tim8-tim6));


        // 查找。取出
        long tim9 = System.currentTimeMillis();
        list1.get(num/num);
        long tim10 = System.currentTimeMillis();
        System.out.println("ArrayList 头部取出数据耗时:"+String.valueOf(tim10-tim9));

        list1.get(num / 2);
        long tim11 = System.currentTimeMillis();
        System.out.println("ArrayList 中部取出数据耗时:"+String.valueOf(tim11-tim10));

        list1.get(num);
        long tim12 = System.currentTimeMillis();
        System.out.println("ArrayList 尾部取出数据耗时:"+String.valueOf(tim12-tim11));

        list2.get(num/num);
        long tim13 = System.currentTimeMillis();
        System.out.println("LinkedList 头部取出数据耗时:"+String.valueOf(tim13-tim12));

        list2.get(num / 2);
        long tim14 = System.currentTimeMillis();
        System.out.println("LinkedList 中部取出数据耗时:"+String.valueOf(tim14-tim13));


        list2.get(num);
        long tim15 = System.currentTimeMillis();
        System.out.println("LinkedList 尾部取出数据耗时:"+String.valueOf(tim15-tim14));
        
    }

我们将Arraylist 的初始容量定位10000000,这样做的目的是为了先不考虑Arraylist扩容带来的时间损耗。

经我测试n=100000以下基本看不出来什么差别,当num= 1000000时。

ArrayList 插入1000000条数据耗时:24
ArrayList 头部插入数据耗时:1
ArrayList 尾部插入数据耗时:0
LinkedList 插入1000000条数据耗时:141
LinkedList 头部插入数据耗时:0
LinkedList 尾部插入数据耗时:0
ArrayList 头部取出数据耗时:0
ArrayList 中部取出数据耗时:1
ArrayList 尾部取出数据耗时:0
LinkedList 头部取出数据耗时:0
LinkedList 中部取出数据耗时:6
LinkedList 尾部取出数据耗时:0

可以明显的看到,LinkList的,添加数据明显变慢。再将
num=10000000时候。

ArrayList 插入10000000条数据耗时:267
ArrayList 头部插入数据耗时:45
ArrayList 尾部插入数据耗时:0
LinkedList 插入10000000条数据耗时:1583
LinkedList 头部插入数据耗时:0
LinkedList 尾部插入数据耗时:0
ArrayList 头部取出数据耗时:0
ArrayList 中部取出数据耗时:1
ArrayList 尾部取出数据耗时:0
LinkedList 头部取出数据耗时:0
LinkedList 中部取出数据耗时:39
LinkedList 尾部取出数据耗时:1

可以看到差距十分明显了。再将num设置的更大,我的电脑会卡住。
所以我们可以得到下面的几条结论,在数据量较大的情况下

  • linkedList 数据插入比Arraylist慢,尤其在数据量不断变大的情况下,该差异越明显
  • ArrayList 头部插入数据慢,尾部插入数据快。linkedlist 不管哪个位置,插入数据都一样。都相当快。
  • Arrsylist 不管哪个位置获取数据都一样,非常快LinkedList获取数据两端快,中间慢。

在这里我测试了插入数据,没测试删除数据,是因为两者效果是一样的我们后面再说。
所以和我么记住的规则并非完全相同。下面我们从源码来看为什么会这样。

数组和链表

数组:一段连续的内存空间。元素挨个存放,通过下标获取元素。

链表: 元素一节点的形式存在,每个节点除了存储本身信息,还存储前一个,后一个(单向链表只存储后一个,或者前一个,双向链表二者都存储)元素信息。存储空间由添加元素的时候操作系统给分配。可能连续可能不连续。

在清楚了,链表和数组的区别之后,继续往下看。

LinkedList 顺序添加比ArrayList慢

ArrayList 顺序添加


    public boolean add(E e) {
        ++this.modCount;
        this.add(e, this.elementData, this.size);  // 调用下面方法
        return true;
    }

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

        elementData[s] = e;
        this.size = s + 1;
    }

通过源码我们可以看到,当向数组最后添加一个元素的时候,只需要判断一下是否需要扩容,如果不需要则,直接赋值即可。如果我们将默认容量设置为10000000,所以是不需要扩容的。扩容的情况我们呢后面再说。

LinkedList顺序添加

    public boolean add(E e) {
        this.linkLast(e); // 调用下面函数
        return true;
    }
    
     void linkLast(E e) {
        LinkedList.Node l = this.last;
        LinkedList.Node newNode = new LinkedList.Node(l, e, (LinkedList.Node)null);
        this.last = newNode;
        if (l == null) {
            this.first = newNode;
        } else {
            l.next = newNode;
        }

        ++this.size;
        ++this.modCount;
    }

    可以看到LinkedList 每次添加元素的时候,需要先获取到最后一个节点,然后创建一个新的节点,然后将最后一个节点的next指向当前创建的新的节点,再将当前节点的标记为最后一个节点。之前看到网上说,linkedList 插入慢是因为linkedList 每次插入都需要通过遍历链表,找到尾节点。但是看来,至少在JDK11中看来并不是这个原因。

    我个人的理解是 LinkedList的数据结构是链表,它存储空间是不连续的,每次添加一个节点,都需要向操作系统申请分配一个节点大小的存储空间。而 ArrayList 的空间是已经分配好的。如果往一个集合中添加n个元素,那么不考虑扩容的情况下,Arraylist 只会申请一次内存空间分配,而且是在初始化后就已经分配好了,但是LinkedList 却需要申请n次。

ArrayList 头部插入慢,尾部插入数据快。linkedlist头尾插入都快。

上面的添加元素默认都是在尾部添加,我们现在看在中间插入的情况。

Arraylist


    public void add(int index, E element) {
        this.rangeCheckForAdd(index);
        ++this.modCount;
        int s;
        Object[] elementData;
        if ((s = this.size) == (elementData = this.elementData).length) {
            elementData = this.grow();
        }

        System.arraycopy(elementData, index, elementData, index + 1, s - index);
        elementData[index] = element;
        this.size = s + 1;
    }

ArrayList 在指定位置添加元素,也是首先判断是否需要扩容,我们默认先不扩容。
通过System.arraycopy()函数将从当前位置的index后面的元素全部后移一个。然后将index 当前位置赋值为添加的元素。这个慢主要慢在移动这里,虽然这个函数具体实现是由jvm实现的,但是实现的原理也是通过遍历循环index后面的指针,挨个重新赋值。所以元素越多。插入的位置月靠前。需要移动的元素就越多,耗时自然也就越长。

LinkedList 为什么快

    public void add(int index, E element) {
        this.checkPositionIndex(index);
        if (index == this.size) {
            this.linkLast(element);
        } else {
            this.linkBefore(element, this.node(index)); // 调用下方代码。
        }

    }
    
 void linkBefore(E e, LinkedList.Node succ) {
        LinkedList.Node pred = succ.prev;
        LinkedList.Node newNode = new LinkedList.Node(pred, e, succ);
        succ.prev = newNode;
        if (pred == null) {
            this.first = newNode;
        } else {
            pred.next = newNode;
        }

        ++this.size;
        ++this.modCount;
    }

LinkedList 在向链表中间插入元素的时候,只需要创建一个新节点,将index的前一个节点的next只想当前节点,当前节点的next指向原先的index+1 节点即可。并没有循环遍历的操作。所以快,,并且对于链表来说,无论是在头尾还是中间,添加节点,都是一样的。 这就是LinkedList快的原因。

我们在测试中,没有测试删除,是因为删除也是相同的操作。
Arraylist 删除元素,需要将删除元素后面的元素挨个前移一位。所以也是需要循环便利。
linkedList的删除就简单了,直接将删除位置的前一个节点的next指向删除的节点的后面一个元素即可,并将删除节点赋值为null,以便gc回收。。所以删除和添加是同理。

Arraylist任何位置元素都快,LinkedList获取元素,两端快中间慢

Arraylist

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

Arraylist 根据下标直接从数组中取回下标返回元素。所以无论哪个位置都很快

LinkedList

    public E get(int index) {
        this.checkElementIndex(index);
        return this.node(index).item; // 调用下面函数
    }
    
        LinkedList.Node node(int index) {
        LinkedList.Node x;
        int i;
        if (index < this.size >> 1) {
            x = this.first;

            for(i = 0; i < index; ++i) {
                x = x.next;
            }

            return x;
        } else {
            x = this.last;

            for(i = this.size - 1; i > index; --i) {
                x = x.prev;
            }

            return x;
        }
    }

LinkedList 获取元素的时候,先判断index < size/2 如果,成立,则从头开始找,否则从尾开始找。因为链表不能直接通过下标取出元素,每个节点只有前一个节点,当前节点,下一个节点。 所以就得循环便利,来便利到指定位置将元素返回。所以LinedList 获取元素就成了,首尾快,中间慢。因为首尾需要遍历的次数是最少的,最中间的位置需要遍历的次数最多。

Arraylist的扩容。

我们上面的测试结果,是在Arraylist初始化的时候就设置了足够大的初始容量。所以在添加元素的过程中没有经过扩容,但是我们在实际用的过程中,我们不知道可能会有多少数据。设置的太大了浪费内存,设置小的数据量太大会多次扩容。如下是源码中扩容部分的代码

    private Object[] grow(int minCapacity) {
        return this.elementData = Arrays.copyOf(this.elementData, this.newCapacity(minCapacity));
    }

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

    private int newCapacity(int minCapacity) {
        int oldCapacity = this.elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity <= 0) {
            if (this.elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
                return Math.max(10, minCapacity);
            } else if (minCapacity < 0) {
                throw new OutOfMemoryError();
            } else {
                return minCapacity;
            }
        } else {
            return newCapacity - 2147483639 <= 0 ? newCapacity : hugeCapacity(minCapacity);
        }
    }

可以看到,扩容就是开辟一size为原来空间1.5倍的新空间,然后将当前数据全部迁移过去,又避免不了万恶的循环遍历。
显而易见,这就是ArrayList的添加元素的性能瓶颈。我们再测试一下。
num=10000000 时候,Arraylist 不指定初始容量的情况。结果如下。

ArrayList 插入10000000条数据耗时:607
ArrayList 头部插入数据耗时:27
ArrayList 尾部插入数据耗时:0
LinkedList 插入10000000条数据耗时:2464
LinkedList 头部插入数据耗时:0
LinkedList 尾部插入数据耗时:0
ArrayList 头部取出数据耗时:0
ArrayList 中部取出数据耗时:0
ArrayList 尾部取出数据耗时:1
LinkedList 头部取出数据耗时:0
LinkedList 中部取出数据耗时:48
LinkedList 尾部取出数据耗时:0

可见扩容对ArrayList的添加元素时间的影响还是比较大的。但是即使扩容之后的ArrayList插入数据也比LinkedList快很多。

总结 如何选择

当数据量较小的时候,用ArrayList 和用linekList对性能的差异不是很明显。都可以。但是如果你的数据量特别大,有几万的那中,这个时候需要看以下两中情况

  • 1 是否元素都是顺序添加,是则选择ArrayList
  • 2 是否头部添加的情况比较多,是则选择LinkedList
  • 3 查询是否远远大于写入,是则选择ArrayList

总之,没有东西是完美无瑕的,根据使用场景选择最合适的数据结构才能达到最好的效果。

相关代码地址

通过手写一个低配版的Arraylist,对理解源码真的很有帮助,有不当之处还望大佬斧正。

https://github.com/ligengithub/java2020.git

你可能感兴趣的:(JAVA)