【Java】ArrayList 和 LinkedList 比较

1,157 阅读9分钟

关键类型

Array

Array(数组)是基于索引(index)的数据结构,它使用索引在数组中搜索和读取数据是很快的。

Array获取数据的时间复杂度是O(1),但是要删除数据却是开销很大,因为这需要重排数组中的所有数据, (因为删除数据以后, 需要把后面所有的数据前移)

缺点: 数组初始化必须指定初始化的长度, 无法自动扩容

List

List—是一个有序的集合,可以包含重复的元素,提供了按索引访问的方式,继承自Collection。

List有两个重要的实现类:ArrayList和LinkedList

ArrayList

ArrayList可以看作是能够自动增长容量的数组,底层的实现是Array

LinkedList

LinkedList是一个双向链表,在添加和删除元素时具有比ArrayList更好的性能,但在get与set方面弱于ArrayList。

LinkedList还实现了Deque接口,Deque接口是Queue接口的子接口,它代表一个双向队列,因此LinkedList可以作为双向队列 ,栈(可以参见Deque提供的接口方法)和List集合使用,功能强大。

LinkedList需要更多的内存,因为ArrayList的每个索引的位置是实际的数据,而LinkedList中的每个节点中存储的是实际的数据和前后节点的位置。

插入操作

既然LinkedList是一个由相互引用的节点组成的双向链表,那么当把数据插入至该链表某个位置时,该数据就会被组装成一个新的节点,随后只需改变链表中对应的两个节点之间的引用关系,使它们指向新节点,即可完成插入(如下图);同样的道理,删除数据时,只需删除对应节点的引用即可

而ArrayList是一个可变长数组,插入数据时,则需要先将原始数组中的数据复制到一个新的数组,随后再将数据赋值到新数组的指定位置(如下图);删除数据时,也是将原始数组中要保留的数据复制到一个新的数组

因此,在添加或删除数据的时候,ArrayList经常需要复制数据到新的数组,而LinkedList只需改变节点之间的引用关系,这就是LinkedList在添加和删除数据的时候通常比ArrayList要快的原因。

因为链表插入的时候首先要找到插入的位置在哪,查找时间复杂度为O(n),数组则是O(1),查找越靠后的索引,链表的速度越慢(但是后面优化了链表查询速度,哪边近从哪边开始遍历),查找中间的时候最慢,O(n/2)的时间复杂度。

扩容操作

LinkedList

不存在扩容 的说法,因为是链表结构。

ArrayList

底层是动态数组,默认的数组大小是10,在检测是否需要扩容后,如果扩容,会扩容为原来的1.5倍大小。原理就是把老数组的元素存储到新数组里面。

在ArrayList的尾部插入和其他位置虽然是不同方法,但是都使用到了ensureCapacityInternal()方法确保数组内部容量,在每次添加操作中都会使用该方法进行容量判断,之后,才会将增加的元素添加到数组中,下面以尾部插入为例;

/**
     * 增加数据元素到集合得末尾
     * 
     */
    public boolean add(E e) {
        /*判断是否扩容,如果原来的元素个数是size,那么增加一个元素之后的元素个数为size + 1,所以需要的最小容量就为size + 1*/
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        elementData[size++] = e;
        return true;
    }

ensureCapacityInternal()将判断委托给ensureExplicitCapacity()处理

/*获取数组最小容量*/
    private static int calculateCapacity(Object[] elementData, int minCapacity) {
        /*如果elementData为空,且minCapacity <= 10,都会以DEFAULT_CAPACITY作为最小容量*/
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            return Math.max(DEFAULT_CAPACITY, minCapacity);
        }
        return minCapacity;
    }

    private void ensureCapacityInternal(int minCapacity) {
        /*ensureCapacityInternal方法委托给ensureExplicitCapacity方法*/
        ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
    }

    private void ensureExplicitCapacity(int minCapacity) {
        modCount++;

        /*如果minCapacity大于elementData的长度,使用grow方法进行扩容*/
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }

下面就是扩容的实现方法grow方法:

/*扩容方法*/
    private void grow(int minCapacity) {
        /*原有数组容量*/
        int oldCapacity = elementData.length;
        /*新的数组容量,下面位运算相当于newCapacity = oldCapacity * 1.5 向下取整*/
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        /*如果新的数组容量小于需要的最小容量,即假设新的数组容量是15,最小需要16的容量,则会将16赋予newCapacity*/
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        /*变量MAX_ARRAY_SIZE = 2147483639 [0x7ffffff7],如果扩容后的新容量大于这个值则会使用hugeCapacity方法
         * 判断最小容量minCapacity是否大于MAX_ARRAY_SIZE,如果需要最小容量的也大于MAX_ARRAY_SIZE,则会以
         * Integer.MAX_VALUE = 2147483647 [0x7fffffff]的值最为数组的最大容量,如果没有则会以MAX_ARRAY_SIZE最为最大容量        
         * MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8,为什么用MAX_ARRAY_SIZE ,源码的中的说法是一些虚拟机中会对数组保留一些标题字段       
         * 使用Integer.MAX_VALUE会造成内存溢出错误
         * */
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        /*确定数组最终的容量newCapacity之后,将原有ArrayList的元素全部拷贝到一个新的ArrayList中*/
        elementData = Arrays.copyOf(elementData, newCapacity);
    }
    private static int hugeCapacity(int minCapacity) {
        /*如果minCapacity小于0,则抛出内存溢出错误*/
        if (minCapacity < 0) 
            throw new OutOfMemoryError();
        return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; 
    }

ArrayList的扩容机制还是相对容易理解的,就是在第一个添加元素时,创建一个长度为10的数组,之后随着元素的增加,以1.5倍原数组的长度创建一个新数组,即10, 15, 22, 33,。。这样序列建立,将原来的元素拷贝到新数组之中,如果数组长度达到上限,则会以

MAX_ARRAY_SIZE 或者 Integer.MAX_VALUE作为最大长度,而多余的元素就会被舍弃掉。

在使用ArrayList时,如果你能预估大小,最好直接定义初始容量,这样能节省频繁的扩容带来的额外开支。

Java 数组最大长度

库函数里的数组最大数量都是指定为Integer.MAX_VALUE-8。按注释所说,8是为对象头预留的,对象头在64位虚拟机下占16个字节,8一定不是指字节数,如果指的是字长,那么这个数字应该是可以更小的。

所以数组最大的大小即为:Integer.MAX_VALUE-对象头占的字长。

以64位开启压缩指针为例:markword占8个字节,klass指针4个字节,数组长度4个字节,一共是16个字节(两个)字长。

public class Hello {
    public static void main(String[] args) {
        Object[] o = new Object[Integer.MAX_VALUE-2];
    }
}

运行:java -Xmx9000m -Xmn10m Hello,不会有任何异常。

假如关掉压缩指针,klass指针占8个字节,对象头一共8+8+4,再加上补齐,一共是3个字长,那么此时最大数组大小就是Integer.MAX_VALUE-3了。

$ java -Xmx9000m -Xmn10m -XX:-UseCompressedOops Hello
Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit
        at Hello.main(Hello.java:4)

32位虚拟机数组的对象头:markword占4个字节,klass指针占4个字节,数组长度占4个字节,一共是3个字长。在不需要对齐的情况下数组最大最小为Integer.MAX_VALUE-3,而在需要对齐的情况下就是Integer.MAX_VALUE-4。

Java数组最大长度有两种限制

  • 一是规范隐含的限制。Java数组的length必须是非负的int,所以它的理论最大值就是java.lang.Integer.MAX_VALUE = 2^31-1 = 2147483647。

  • 二是具体的实现带来的限制。这会使得实际的JVM不一定能支持上面说的理论上的最大length。 例如说如果有JVM使用uint32_t来记录对象大小的话,那可以允许的最大的数组长度(按元素的个数计算)就会是:

(uint32_t的最大值 - 数组对象的对象头大小) / 数组元素大小

于是对于元素类型不同的数组,实际能创建的数组的最大length也会不同。 JVM实现里可以有许多类似的例子会影响实际能创建的数组大小。

对比

时间复杂度

操作数组链表
随机访问O(1)O(N)
头部插入O(N)O(1)
头部删除O(N)O(1)
尾部插入O(1)O(1)
尾部删除O(1)O(1)

因为数组的连续内存, 会有一部分或者全部数据一起进入到CPU缓存, 而链表还需要在去内存中根据上下游标查找, CPU缓存比内存块太多

数据大小固定, 不适合动态存储, 动态添加, 内存为一连续的地址, 可随机访问, 查询速度快

链表代销可变, 扩展性强, 只能顺着指针的方向查询, 速度较慢

使用场景

  • 如果应用程序对数据有较多的随机访问,ArrayList对象要优于LinkedList对象;

  • 如果应用程序有更多的插入或者删除操作,较少的随机访问,LinkedList对象要优于ArrayList对象;

  • 不过ArrayList的插入,删除操作也不一定比LinkedList慢,如果在List靠近末尾的地方插入,那么ArrayList只需要移动较少的数据,而LinkedList则需要一直查找到列表尾部,反而耗费较多时间,这时ArrayList就比LinkedList要快。

ArrayList 和 LinkedList源码

ArrayList

成员变量

ArrayList有两个成员变量,图中可以看到,一个Object的数组,一个int类型的size,用来定义数组的大小。

get()方法

首先检查传入的index,然后返回数组在该index的值。

add()方法

首先确保容量够用,然后将新加入的对象放在数组尾部。

remove()方法

首先确保容量够用,然后计算出需要移动的数量,例如size=10,要删除index=5的元素,则需要移动后面的四个元素,然后调用System.arraycopy()方法,将数组的后面4个依次向前移动一位,然后将数组最后一位置为null。

LinkedList

成员变量

LinkedList本身的属性比较少,主要有三个,一个是size,表明当前有多少个节点;一个是first代表第一个节点;一个是last代表最后一个节点。

get()方法

首先检查传入的index是否合法,然后调用了node(index)方法,那么来看看node()方法。

判断index值是否大于总数的一半。

如果小于,则从first节点向后遍历,直到找到index节点,然后返回该节点的值。

如果大于,则从last节点向前遍历,直到找到index节点,然后返回该节点的值。

add()方法

add方法,直接调用了linklast方法,将传入的值作为最后一个节点链接在链表上。

remove()方法

remove方法的思路是什么呢?从头开始遍历链表,当找到要删除的节点,将他删除。删除的方法呢?将该节点的前后节点链接起来,类似于下图:

参考文章