ArrayList初始化与扩容原理
ArrayList初始化
来看如下代码:
1 | public static void main(String[] args) { |
首先:执行List
1 | 常量池位于方法区,方法区位于堆内存 |
无参构造函数如下:
1 | public ArrayList() { |
很简单,就一行代码,继续看一下this.elementData和DEFAULTCAPACITY_EMPTY_ELEMENTDATA分别是什么,下面是ArrayList中的一些属性:
1 | private static final int DEFAULT_CAPACITY = 10;// 默认初始化的容量 |
和String一样,底层是数组,唯一的区别是String底层是char[]数组,而这儿是Object[]数组,也就是说该数组可以放任何对象(所有对象都继承自父类Object),执行完构造函数后,如下图。
static修饰的变量,常驻于方法区,我们不需要new,JVM会提前给我们初始化好,这个特性在实际开发过程中,经常拿来做缓存。static变量又叫类变量,不管该类有多少个对象,static的变量只有一份,独一无二。fianl修饰的变量,JVM也会提前给我们初始化好。transient这个关键字告诉我们该对象在序列化的时候请忽略这个元素。
继续执行:List
new的时候考虑到了缓存,为了避免反复的创建无用数组,所有新new出来的ArrayList底层数组都指向缓存在方法区里的Object[]数组。
继续执行Person person1 = new Person(“张三”)
继续执行list1.add(person1),看源码ArrayList是怎么处理add的:
1 | public boolean add(E e) { |
调用add方法的时候,首先是调用了ensureCapacityInternal方法。这个方法接收一个int类型的参数,表示的是当前数组中的元素个数加1。现在是空数组,所以size就是0,也就说传入的是1。
我们在图里的ArrayList对象里补上它,成员变量初始化的size为0。
接下来看看ensureCapacityInternal里面做了什么事。
1 | private void ensureCapacityInternal(int minCapacity) { |
接下来看看ensureCapacityInternal里面做了什么事。
1 | private void ensureCapacityInternal(int minCapacity) { |
这个方法里面又调用了两个方法,一个calculateCapacity方法,然后将calculateCapacity方法的返回值作为参数再调用ensureExplicitCapacity方法。先看看calculateCapacity方法做了什么。
1 | private static int calculateCapacity(Object[] elementData, int minCapacity) { |
它首先看看当前数组是否为空,如果为空,那么就返回DEFAULT_CAPACITY和minCapacity当中较大的一个。DEFAULT_CAPACITY就是10,minCapacity就是刚才传入的size + 1,也就是1,所以就返回10。然后将10作为参数传给ensureExplicitCapacity:
1 | private void ensureExplicitCapacity(int minCapacity) { |
这里有个变量自增了,看看下面的判断。现在这个minCapacity是刚才传入的10,底层数组的length是0,所以调用了grow方法。再去看看grow方法:
1 | private void grow(int minCapacity) { |
直接看最后一句,进入代码:
1 | public static <T> T[] copyOf(T[] original, int newLength) { |
再看copyOf()方法:
1 | public static <T, U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) { |
System.arraycopy()方法是native修饰的本地方法。
由于数组内容目前为空,相当于没有拷贝。原来只是创建了一个默认长度为10的Object[]数组,如下图:
再看add()的代码,其中:
1 | elementData[size++] = e; |
相当于:
1 | elementData[size] = e; |
很简单,size现在是0,把传进来的这个e(这里是person1),放到list1的elementData[]下标为0的数组里面,同时size加1,如下图:
注意看红框里,虽然我们list1里的elementData数组的长度是10,但是size是1,size是逻辑长度,并不是数组长度。
现在debug一下,验证我们图里的内容:
本节最开始代码执行结果如下:
1 | list1.size()1 |
看一下size()方法的源码:
1 | public int size() { |
就是返回size的值,而不是数组的长度,所以String里叫length()而List里面叫size()。
ArrayList底层数组扩容原理
看如下代码与图(去掉了一些无用的细节如方法区、常量池等):
1 | public static void main(String[] args) { |
debug可以看到与上图一致:
执行结果也在我们期望中:
1 | perArr.length:5 |
再往数组里加添加一个叫“周八”的person对象:
1 | perArr[5] = new Person("周八"); |
执行会报数组下标越界异常。在Java中,数组一但在堆内存中创建,长度是固定的。
那要往数组里加一个“周八”用户,只能重新new长一点的新的数组,把原来数组的元素复制过去:
1 | Person[] newPerArr = new Person[10];//数组扩大一倍,防止后面出现更多用户 |
把老数组的元素循环一下,赋值给新的数组,debug看一下:
“周八”已经有了。以上代码虽然简单,但还不是最优雅的,有经验的程序员一般会这么写,该段代码执行结果和上面那段代码一样:
1 | Person[] newPerArr = new Person[10]; |
到此,便可知道ArrayList里的底层数组扩容是怎么实现的了。ArrayList初始化一节中我们知道当ArrayList如果不指定构造个数的话,第一次往里面添加元素时底层数组会初始化一个长度为10的数组,我们再回顾一下昨天的源码,再来看一下ArrayList里的源码,当添加第11个元素时:
1 | private void ensureExplicitCapacity(int minCapacity) { |
再看grow()方法:
1 | private void grow(int minCapacity) { |
使用无参构造方法,这里oldCapacity是0,newCapacity的初始值是1.5倍的oldCapacity,所以也是0。接下来因为newCapacity减minCapacity是0减10,所以小于0,那么newCapacity就是10。最后调用Arrays.copyOf方法,初始化数组,将一个旧数组中的数据拷贝到新数组中。
这既是初始化的过程,其实也是扩容的过程。add的时候,先将数组中的元素个数size加1传给ensureCapacityInternal方法,如果当前elementData为空,就是初始化,返回10,初始化一个大小为10的数组。如果elementData不为空,就返回size + 1的值,将其传入ensureExplicitCapacity方法,判断size + 1的值减去当前数组的长度是否大于0。比如我现在elementData的length为10,现在已经存了9个元素,即size + 1等于10,此时10减10是不大于的0,不执行grow方法。当存了10个元素的时候,10加1等于11,才会执行grow方法。也就是说,当数组满了才会执行扩容操作。扩容的时候,是扩容为当前数组容量的1.5倍,然后底层调用一个本地方法将旧数组数据转移到新数组。
下面代码:
1 | int newCapacity = oldCapacity + (oldCapacity >> 1); |
相当于:
1 | int newCapacity = oldCapacity + (oldCapacity / 2); |
但前者性能更好。
ArrayList还提供了其它构造方法:
1 | public ArrayList(int initialCapacity) { |
以传进来的个数作为元素个数创建底层数组,首先判断传入的 initialCapacity 是否大于0,大于0就new一个长度为 initialCapacity 的数组;如果等于0,那么 elementData 还是空数组;如果小于0,直接抛异常。
所以当我们在写代码过程中,如果我们大概知道元素的个数,比如一个班级大概有40-50人,我们优先考虑List
总结
- 初始化:调用无参构造初始化时只是创建了一个空数组,等调用add方法的时候才会调用扩容方法,初始化一个长度为10的数组。
- 扩容:当数组中存满了元素时,创建一个容量为原先的1.5倍的新数组,将元素copy到新数组的方法底层调用的是System类的一个本地方法。
元素的删除与插入
ArrayList中的删除
如下代码添加10个用户:
1 | Person[] group = new Person[10]; |
比如我们要把“周八”这个人从数组中删除,如图:
我们只能循环数组,找到“周八“的下标5,由于数组没有提供删除方法,我们只能把下标为5的位置赋值为null(造成了数组空洞),“周八”这个Person对象已经没有引用指向它了,JVM的垃圾回收机制会在适当的时候回收它。但数组的长度还是10。下次当我们再循环查找某人时,稍不注意就会报空指针异常,虽然我们可以写非空去判断,但还是不太友好,我们把null后面的所有元素引用复制一下,往前拷贝一份,把null这个空给填上,如下图:
复制后:
null之后的ref引用都按顺序复制了一份到原来的null的位置,原有的引用被覆盖,但perArr[9]里的引用的指向还是不变(注意,是复制不是挪动)。此时perArr[8]、perArr[9]指向的是同一个对象,这显然不是我们所要的结果,再处理一下,我们把perArr[9]的引用赋值为null。如下图:
问题似乎解决了,但数组长度还是10,还需要自行维护一个size来记录长度,以上数组复制的代码,我们都要自己去写,好在ArrayList这个类已经实现了,数组拷贝工作交给它就好,我们只需要调用ArrayList这个类提供的remove删除元素就行,至于底层数组怎么拷贝,元素怎么删除由ArrayList对象本身去处理。我们来看一看ArrayList的两种元素删除方式,首先是按照下标删除:
1 | List<Person> perList = new ArrayList<>(); |
先看看删除前的元素,debug如下:
perList里面已经有了10个元素,执行一下这两句remove操作,再看一下debug的情况:
下标为5的“周八”已经删除掉了,下标为5以后的元素也按照我们之前的猜想往前移了一位,数组最后一个位置也置为null了。但“孙七”没有删掉,打印出来的个数也是9。
看一下两种删除方式的源码:
1 | public E remove(int index) {//删除指定位置的元素 |
基本上和我们图中的分析一致,并采用size来记录元素的真实个数,这段代码里还调了一个方法rangeCheck()方法:
1 | private void rangeCheck(int index) { |
就是检查底层数组下标是否越界。再看另外一种删除方式:
1 | public boolean remove(Object o) { |
再看一下fastRemove()方法:
1 | private void fastRemove(int index) { |
和上面用下标删除方式一致。
如果写了一个类(Person),需要这个类完美的支持List,必需按照List的规范来写代码,上面代码中的孙七是我们新neW出来的,不是我们数组里的孙七。由于我们没有重写equals,默认继承Person父类Objecte的equals方法,判断是否指向同一个引用,而不是比较值。
重写完equals和hashCode方法,执行一下再debug看一下:
孙七已经删除掉了,孙七后面的所有人也向前复制了一格,末位置为null,size也是8了,如下图:
图中的“孙七”、“周八”已经没有引用指向它们,JVM虚拟机会在适当的时候进行回收。
在ArrayLIst中,如果底层数组长度为n,当我们用下标方式去删除元素时,如果删除的是最后一个元素,不会触发数组底层的复制,时间复杂度为O(1)。如果删除第i的元素,会触发底层数组复制n-i次,根据最坏情况,时间复杂度为O(n)。
由此看来,在ArrayList中删除指定元素的效率似乎不是太高,删除元素会造成底层数组复制。
ArrayList中的插入
还有一个add方法,public void add(int index, E element) ,从指定位置添加元素:
1 | public void add(int index, E element) { |
执行如下代码:
1 | List<Person> perList = new ArrayList<>(); |
当执行到System.arraycopy()这个方法时,如下图:
注意,此处并不是依次移动元素到下一格,是依次复制插入位置及后面的数组元素,到后面一格,不是移动,因此,复制完后,arr[2]、arr[3]指向对一个对象:
在代码执行完System.arraycopy()方法,debug验证如下:
最后,在堆内存中创建李莫愁这个对象,把arr[2]的引用指向它:
再debug一下:
最后我们来说说ArrayLIst这个对象里添加的时间复杂度,如果我们不指定位置直接添加元素时(add(E element)),元素会默认会添加在最后,不会触发底层数组的复制,不考虑底层数组自动扩容的话,时间复杂度为O(1) ,在指定位置添加元素(add(int index, E element)),需要复制底层数组,根据最坏打算,时间复杂度是O(n)。
再来看读取元素,下面代码是获取List中下标为2的元素:
1 | Person person = perList.get(2); |
源码如下:
1 | public E get(int index) { |
读取元素和数组长度无关,直接从底层数组里去拿元素。
下面将李莫愁替换:
1 | perList.set(2, new Person("小龙女", 22)); |
源码如下:
1 | public E set(int index, E element) { |
很简单,就是往指定位置放入元素,并返回原来的元素,如下图:
图中“李莫愁”已经没有引用指向它了,JVM会在合适的时候回收它,底层数组第2个位置已经换成了“小龙女”,debug验证一下:
总结
在ArrayList中,底层数组存/取元素效率非常的高(get/set),时间复杂度是O(1),而查找,插入和删除元素效率似乎不太高,时间复杂度为O(n)。
当ArrayList里有大量数据时,这时候去频繁插入/删除元素会触发底层数组频繁拷贝,效率不高,还会造成内存空间的浪费,这个问题在另一个类:LinkedList里有解决方案。而查找元素效率不高,在HashMap里有解决方案。
参考资料:
清浅池塘 ArrayList初始化、ArrayList底层数组扩容原理、三顾ArrayList、ArrayList的时间复杂度
贪挽懒月 Java源码解读 — ArrayList