关键类型
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方法的思路是什么呢?从头开始遍历链表,当找到要删除的节点,将他删除。删除的方法呢?将该节点的前后节点链接起来,类似于下图: