美文网首页
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