美文网首页
ArrayList和LinkedList——数组VS链表

ArrayList和LinkedList——数组VS链表

作者: 文景大大 | 来源:发表于2019-05-14 20:32 被阅读0次

一、数据结构

1.1 数组

ArrayList是一种数组类型的数据结构,数组是内存中一段地址连续的空间。

public static void main(String[] args) {
        List<String> fruitList = new ArrayList<>();
        fruitList.add("apple");
        fruitList.add("banana");
        fruitList.add("orange");
        fruitList.add("grape");
    }

我们使用如上代码创建的一个数组可以用示意图表示为:


数组结构示意图

1.2 链表

在JDK1.7之前的版本中,LinkedList是一种链表类型的数据结构(双向循环链表),它所包含元素的内存空间地址是离散排列的,每一个元素除了包含所代表的内容之外,还需要维护两个指针,分别指向上一个和下一个元素,除此之外,第一个节点称为头节点,是整个循环链表的开始,也是结束。

public static void main(String[] args) {
        List<String> fruitList = new LinkedList<>();
        fruitList.add("apple");
        fruitList.add("banana");
        fruitList.add("orange");
        fruitList.add("grape");
    }

我们使用如上代码创建的一个链表可以用示意图表示为:


双向循环链表结构示意图

而在JDK1.7及以后LinkedList取消了双向链表的循环特性,新增了明确的链头和链尾,其结构示意图和上述类似,只不过取消了循环的指针。

详细内容可以参考:JDK1.7-LinkedList循环链表优化

二、常见操作

2.1 尾部增加元素

ArrayList和LinkedList添加元素到尾部的代码,一模一样,可是它们内部的实现却完全不同。

首先看下ArrayList是如何实现的:

    public boolean add(E e) {
        // 确保新增加的元素有空间
        ensureCapacityInternal(size + 1); 
        // 将新增加的元素置于尾部
        elementData[size++] = e;
        return true;
    }
    private void ensureCapacityInternal(int minCapacity) {
        // elementData是一个Object[]数组,存放当前链表中所有的内容
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
        }
        // 确保最有足够的空间
        ensureExplicitCapacity(minCapacity);
    }
    private void ensureExplicitCapacity(int minCapacity) {
        modCount++;

        // 超过当前已经分配的空间时需要扩容
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }
    private void grow(int minCapacity) {
        int oldCapacity = elementData.length;
        // 右移动一位代表除以2,所以此处是扩大到原来的1.5倍
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        // 如果三倍扩容还不够就置为需要的空间大小
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        //如果需要的比数组最大长度还长就扩容为整数的最大值
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        // 将存储当前链表中内容的Object[]数组扩容到指定大小
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

    private static int hugeCapacity(int minCapacity) {
        if (minCapacity < 0) // overflow
            throw new OutOfMemoryError();
        return (minCapacity > MAX_ARRAY_SIZE) ?
            Integer.MAX_VALUE :
            MAX_ARRAY_SIZE;
    }

因此,可以得出结论,当需要增加到尾部的元素不超过当前链表的已分配大小时,直接添加即可,非常快;当超过已分配大小的时候,需要花点时间先扩容。

然后对比看下LinkedList是如何实现的:

    public boolean add(E e) {
        // 将元素添加到链表的尾部
        linkLast(e);
        return true;
    }
    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高。

2.2 任意位置增加元素

ArrayList的实现如下:

    public void add(int index, E element) {
        rangeCheckForAdd(index);

        ensureCapacityInternal(size + 1);
        // 每次都要拷贝数组
        System.arraycopy(elementData, index, elementData, index + 1,
                         size - index);
        elementData[index] = element;
        size++;
    }

可以看到,如果在ArrayList任意位置添加元素,由于数组是内存中地址连续的空间,因此每次都需要挪动数组内容,为新加入的元素腾出空间,在ArrayList比较长的时候,性能堪忧。

再看看LinkedList的实现:

    public void add(int index, E element) {
        checkPositionIndex(index);
        // 加入到链尾的情况
        if (index == size)
            linkLast(element);
        else
            // 加入到任意位置的情况
            linkBefore(element, node(index));
    }
    void linkBefore(E e, Node<E> succ) {
        // 需要插入位置的原节点的前一个节点
        final Node<E> pred = succ.prev;
        // 新建一个节点,存储需要新增的元素
        final Node<E> newNode = new Node<>(pred, e, succ);
        // 该位置原节点的前一个节点设置为新增的这个节点
        succ.prev = newNode;
        // 如果该位置原节点没有前一个节点,则当前新增的节点就为头节点
        if (pred == null)
            first = newNode;
        else
            // 该位置原节点的前一个节点往后指向当前新增的节点
            pred.next = newNode;
        size++;
        modCount++;
    }

LinkedList在任意位置新增节点和在尾部新增节点的操作基本类似,没有空间和扩容的限制,更不存在挪动其它元素节点的情况,唯一需要做的就是找到需要操作的链表的位置,如下是这个操作的源码:

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

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

可以看到,如果我们是在LinkedList的头部或者尾部新增节点,那效率非常高,这个在上面已经说明过了;但是如果要在中间某个位置新增节点,则需要通过for循环遍历找寻到该位置,所以,如果LinkedList很长的话,这步操作可能会比较影响性能。

2.3 任意位置删除元素

同在任意位置增加元素,此处不再赘述。

三、扩容机制

3.1 数组扩容

这个在如上ArrayList新增元素部分已经说明过了,参照源码的阅读,整个扩容步骤大致如下:

  • 首先检测已分配空间是否足够;
  • 不够的情况下首先扩容1.5倍;
  • 扩容1.5倍还不够,就扩容到需要增加内容后的指定大小;
  • 如果需要扩容的指定大小比数组最大值还大,就扩容到整数的最大值;

3.2 链表扩容

链表不存在空间检测和扩容的情况,只要内存足够,就可以一直新增元素。

四、循环遍历上的差异

4.1 使用for循环

我们使用如下的代码分别对ArrayList和LinkedList进行测试:

    public static void main(String[] args) {
//        List<String> fruitList = new LinkedList<>();
//        List<String> fruitList = new ArrayList<>();
        for(int i=0;i<1000000;i++){
            fruitList.add("apple"+ i);
        }

        // 遍历fruitList
        String fruitName;
        Long startTime = System.currentTimeMillis();
        for(int j = 0; j < fruitList.size(); j++){
            fruitName = fruitList.get(j);
        }
        System.out.println("time cost:" + (System.currentTimeMillis() - startTime));
    }

运行结果:

  • ArrayList:16
  • LinkedList:等待很久都没有结果

4.2 使用迭代器

    public static void main(String[] args) {
//        List<String> fruitList = new LinkedList<>();
//        List<String> fruitList = new ArrayList<>();
        for(int i=0;i<1000000;i++){
            fruitList.add("apple"+ i);
        }

        // 遍历fruitList
        String fruitName;
        Long startTime = System.currentTimeMillis();
        for(Iterator<String> iterator = fruitList.iterator(); iterator.hasNext();){
            fruitName = iterator.next();
        }
        System.out.println("time cost:" + (System.currentTimeMillis() - startTime));
    }

运行结果:

  • ArrayList:16
  • LinkedList:16

4.3 使用foreach

    public static void main(String[] args) {
//        List<String> fruitList = new LinkedList<>();
//        List<String> fruitList = new ArrayList<>();
        for(int i=0;i<1000000;i++){
            fruitList.add("apple"+ i);
        }

        // 遍历fruitList
        String fruitName;
        Long startTime = System.currentTimeMillis();
        for(String name : fruitList){
            fruitName = name;
        }
        System.out.println("time cost:" + (System.currentTimeMillis() - startTime));
    }

运行结果:

  • ArrayList:16
  • LinkedList:32

4.4 结论分析

对于ArrayList,使用三种循环中的任何一种,效果都是差不多的。其实这也很好理解,数组天然对随机访问具有优势。

对于LinkedList,在使用迭代器遍历的时候,效率最高,其次是foreach循环,最不可接受的是使用for循环。

为什么不同的遍历循环会表现出不一样的性能呢?除了ArrayList和LinkedList的数据结构不同外,各个遍历循环的内部机制其实也是不同的。

  • 迭代器

迭代器是创造一个类似指针的东西,其保留的是当前遍历过程中当前读取元素的位置。

在当前元素获取后,就调用next跑到下一个元素的位置,如此,无论是ArrayList还是LinkedList,使用迭代器进行遍历其实就是将所有元素挨个走了一遍。

  • foreach语句

我们编译如上foreach的实验代码后,获取到class文件,然后使用反编译工具进行反编译,得到的结果如下(ArrayList和LinkedList的结果是一样的):

public static void main(String args[])
    {
        List fruitList = new LinkedList();
        for (int i = 0; i < 0xf4240; i++)
            fruitList.add((new StringBuilder()).append("apple").append(i).toString());

        Long startTime = Long.valueOf(System.currentTimeMillis());
        for (Iterator iterator = fruitList.iterator(); iterator.hasNext();)
        {
            String name = (String)iterator.next();
            String fruitName = name;
        }

        System.out.println((new StringBuilder()).append("time cost:").append(System.currentTimeMillis() - startTime.longValue()).toString());
    }

最明显的可以看到,foreach语句变成了迭代器语句,因此可以认为,foreach的遍历,其底层其实使用的就是迭代器。

  • for循环

for循环没有指针,其每次的迭代都是重新寻找当前元素。

对于ArrayList而言,数组优良的随机读写特性使得for循环寻找当前元素几乎不用花时间,整个遍历过程的复杂度为O(n);

但是对于LinkedList就不同了,每个元素的寻找过程都是从链表头或者链表尾开始一个个地寻找,整个遍历过程的复杂度为O(n^2),难怪那么慢呢。

五、使用总结

在学习了数组和链表的特性后,相信我们一定能深刻地认识到:

  • 在需要频繁查找元素的时候,优先使用ArrayList;
  • 在需要频繁插入和删除任意位置元素的时候,优先使用LinkedList;
  • foreach其实就是迭代器的语法糖;
  • 在使用LinkedList的时候,千万不要使用for循环。

相关文章

网友评论

      本文标题:ArrayList和LinkedList——数组VS链表

      本文链接:https://www.haomeiwen.com/subject/ddblaqtx.html