ArrayList类的底层实现
ArrayList类的断点调试
空参构造的分步骤演示(重要)
带参构造的分步骤演示
一、前言
大家好,本篇博文是对单列集合List的实现类ArrayList的内容补充。之前在List集合的万字详解篇,我们只是拿ArrayList演示了List接口中的常用方法,并没有对它进行深究。但这正是我们今天要做的内容。
up会利用断点调试(Debug)来一步一步地给大家剖析ArrayList底层的扩容机制到底是如何实现的。空参构造和带参构造初始化ArrayList对象up都会演示到。但是, 重点是空参构造器构造ArrayList对象后,底层扩容机制的详细实现。
注意 : ①解读源码需要扎实的基础,比较适合希望深究的同学; ②不要眼高手低,看会了不代表你会了,自己能全程Debug下来才算有收获; ③点击文章的侧边栏目录或者前面的目录可以进行跳转。 ④本篇博文对ArrayList源码的解读基于JDK17.0的版本,虽然不是主流的JDK8.0,但是经过对比不难发现其底层原理大同小异,所以,就算你用的不是高版本的JDK,本文也对打牢你的基础有一定帮助。良工不示人以朴。 感谢阅读!
二、ArrayList类的底层实现
1.ArrayList类在底层是由数组来实现的,ArrayList类源码中维护了一个Object类型的数组elementData,用于存储ArrayList集合中的元素。
关于transient关键字,transient本身是转瞬即逝的意思,如下 :
被transient关键字修饰的程序元素不可被序列化。
2.当我们使用空参构造来创建ArrayList类对象时,则elementData数组的初始容量为0,第一次添加元素时,将该数组扩容为10,如需再次扩容,则将elementData数组的当前容量扩容为1.5倍。
3.如果使用指定数组初始容量大小的带参构造来创建ArrayList类对象,则elementData数组的初始容量即为传入形参的指定容量,如果需要扩容,则直接将该数组当前容量扩容至1.5倍。
三、ArrayList类的断点调试
0.准备工作 :
up以一下代码为演示,来进行Debug操作,(分别在第13行和第20行设置断点),代码如下 :
package csdn.knowledge.api_tools.gather.list;
import java.util.ArrayList;
/**
* @author : Cyan_RA9
* @version : 21.0
*/
public class ArrayList_Demo {
public static void main(String[] args) {
//演示 : Debug ArrayList空参构造,以及数组扩容的全流程。
//1.通过空参构造创建一个ArrayList类对象
ArrayList arrayList = new ArrayList();
System.out.println("使用空参构造创建的集合对象 = " + arrayList);
//2.利用for循环向集合对象中添加10个元素。(0~9)
for (int i = 0; i < 10; i++) {
arrayList.add(i); //此处有自动装箱
}
System.out.println("添加十个元素后,当前集合 = " + arrayList);
//3.如果按照我们的理论,在向集合对象中添加第11个元素时,底层的数组需要扩容。(1.5倍)
arrayList.add("这是集合的第十一个元素捏.");
arrayList.add("这是集合的第十二个元素捏.");
arrayList.add("这是集合的第十三个元素捏.");
arrayList.add("这是集合的第十四个元素捏.");
arrayList.add("这是集合的第十五个元素捏.");
//4.再次测试ArrayList类底层数组的扩容机制。 (10 ---> 15 ---> 22)
arrayList.add("这是集合的第十六个元素捏.");
System.out.println("添加十六个元素后,当前集合 = " + arrayList);
}
}
1.空参构造——分步骤Debug(详细阐释)(重要)
0°开始Debug。
首先,我们进入Debug界面,并在无参构造调用行(此处为第13行),跳入ArrayList类无参构造,如下GIF图所示 :
1°初始化底层elementData数组为空数组。
跳入ArrayList无参构造,我们可以看到,它将用于存储集合元素的elementData数组进行了初始化。等号后面的这一大堆直译过来就是 "默认容量空的elementData数组" ,可以根据Ctrl + b/B查看ArrayList中该常量的源码,如下 :
可以看到,这个所谓的 "默认容量空的elementData数组" 确实名副其实,真是一个空数组,而且与ArrayList中用于存储集合元素的elementData数组一样都是Object类型。
接下来,我们跳出这个无参构造,进入for循环,并跳入第一个元素的add方法。
2°对add方法中的实参进行自动装箱。
第一次跳入add方法,会跳到valueOf方法中,对要添加的int类型进行装箱操作。如下图所示 :
我们不用管他,直接选择跳出,并准备第二次跳入add方法。
第一次跳出add方法后,该行代码仍然标注着高亮,表示此行代码还未执行完毕。我们第二次跳入add方法。
3°进入add方法底层。
第二次跳入add方法,我们来到了真的"add"方法,如下 :
其中,形参列表的"e"代表了你要添加的元素,根据我们上面给出的代码,for循环要向集合对象中添加0~9这十个元素,这是for循环第一次循环,要添加元素0,因此可以看到,此时 e = 0。
modCount属性用于保存你修改集合的次数,用来防止有多个线程修改它,多个线程修改时会抛出异常,这里我们不用管它。
可以看到,跳入的add方法中,还调用了一个形参列表不一样的"add"方法,我们暂时将这个"add方法中的add方法"称为内层add方法,内层add方法传入了三个形参,分别是"当前要添加的元素(此时e = 0),"初始化后的空数组elementData", 以及"当前集合中的元素个数size"。显然,内层add方法更加底层,当然,我们要追进去看看。
4°进入grow方法。
更底层的add方法(内层add方法),如下 :
可以看到,内层add方法中首先是一个if条件语句的判断,if条件语句结束后,才将e(当前e = 0)添加到了elementData数组中,并且为size属性 + 1(集合中的元素个数从0 --> 1,多了1个)。
但是,要知道我们的elementData数组之前可是初始化为了一个空数组阿,是啥都放不下的。显然,它在if条件语句中被动了手脚。我们来仔细看看这个if条件语句,判断条件是"当前集合中的元素个数是否等于数组的大小(长度)", 啥意思呢?就是说咱们现在不是正要向集合中添加元素么——(前面我们也看了,向ArrayList集合中添加元素,底层其实就是向ArrayList类中的elementData数组中添加元素)——如果判断条件成立,说明当前集合的元素个数与底层elementData数组的大小相等,也就是说数组已经满了,再想添加元素就要扩容🌶!
我们再回到实际,因为我们正在添加第一个元素,所以目前集合中元素的个数 = 0;底层数组为空,所以目前数组的大小 = 0,0 = 0,满足判断条件。所以,肯定要进入这个grow方法。grow方法,见名知意,就是对数组进行扩容的方法。"elementData = grow();",显然grow方法最终是返回一个Object类型的数组,这样才能赋值给elementData数组(其实就是更改了elementData的引用)。我们也是一路高歌,继续跳入grow方法中看看是咋回事。如下图所示 :
果然,grow方法返回了一个Object类型的数组。不过比较操蛋的是grow方法与前面的add方法类似,都™长了层包皮,没关系,我们继续追进去看看。(注意 : 既然外层的grow方法是返回Object类型的数组,说明return后面的grow方法肯定要是返回一个Object类型的数组。不过,内层的grow方法传入了一个形参是size + 1,size表示当前集合中元素的个数,第一个元素还没加进去,所以size + 1 = 0 + 1 = 1.)
5°进入grow方法底层。
内层的grow方法如下图所示 :
是不是傻眼了😂?什么玩意儿!别急,不要因为走得太远而忘记了我们为什么出发。我们一路追追追,只有一个目的——elementData数组目前为空,要添加第一个元素0进入,必须先扩容,而内层grow方法要返回一个Object类型的数组。所以,现在我们就找内层grow方法中有扩容操作的代码就行,别的暂时不看!
可以看到,首先第一条语句,是把当前数组的长度(0)赋值给了oldCapacity变量,这条语句有啥用呢,我们暂时不管。继续往下看,有一个if-else的多重条件语句,判断条件是"当前数组的长度是否大于0,或当前数组是否不为空",显然,elementData数组目前还是个空呢,两个条件均不满足,于是if语句中的内容现在不执行,直接跳到else语句。
else语句中,注意观察,出现了return语句!显然,这里很可能就是返回Object类型数组的地方。我们来看看是不是。
返回的Object数组的长度,是调用Math类的max函数的返回值,我们之前在常用类中讲过Math类,max函数可以返回两个数中的较大值。第一个数"DEFAULT_CAPACITY",直译过来就是"默认的容量",其实不用我说你们也能猜出来,和我们一开始初始化elementData数组时遇到的那"一大堆"一个拉撒,一丘之貉,这里也不追进去看了,但是注意,当我们鼠标悬停在这个常量上面是,会显式它的值 = 10;第二个数是minCapacity(最小容量),这个变量是哪里来的呢?欸,你往上翻翻看,不就是内层grow函数的形参么!那这个形参是哪儿来的呢?欸,瞧你这记性,翻到上面外层grow函数,可以看到当时我们传入的是(size + 1),即0 + 1, 等于 1。minCapacity表示——要把该元素添加进去,所需数组的最小长度,我们这才添加第一个元素阿,那minCapacity当然是1了。那两个数中,显然10 > 1,内层grow函数最终返回的就是"new Object[10]"。
6°逐层返回,第一次扩容elementData数组完毕(0 ——> 10)。
然后,内层grow函数执行完毕,返回外层grow函数,如下 :
然后,再返回内层add函数,如下 :
接着,内层add函数中,if条件语句中的方法将要执行完毕。
可以看到,右上角的提示信息中,已经由原来的"Object[0]" 变成了"Object[10]",这也可以验证我们上文"ArrayList类的底层实现"中提到的——当我们使用空参构造来创建ArrayList类对象时,则elementData数组的初始容量为0,第一次添加元素时,将该数组扩容为10。
接着往下执行,可以看到第一个元素0已经被添加进了elementData数组,如下图所示 :
内层add函数执行完毕,返回外层add函数,如下 :
可以看到,size(当前集合中元素的个数)已经由0变成了1,说明我们添加成功了。接着返回到我们的测试类中,如下 :
7°向集合添加第二个元素(不需要扩容)。
第一次扩容完成后,elementData数组的长度由0扩到了10,因此,for循环中后续几个元素的添加都不再需要扩容。以第二个元素的添加为例,up再来带大家过一遍,加深印象,当然,这次主要是体验一下流程,不会像第一次一样那么细了。
如下图所示,随着循环变量i的自增,for的第一次循环顺利结束,第二次循环开始,向集合中添加第二个元素1 :
可以看到,首先还是老规矩,先将int类型的数据1做了装箱处理。
接着,我们再次跳入外层add方法,如下图所示 :
注意看右上角,e(即我们要添加的元素)已经是1了,因为代码里的for循环中,就是要向集合添加0~9这十个元素,现在是第二个元素1。继续往下执行,我们跳入内层add方法,如下图所示 :
首先注意看右上角,s变量(当前集合中的元素个数)变成了1,因为我们之前已经添加过1个元素了么。执行内层add方法,if条件判断语句中,当前集合中元素的个数显然小于elementData数组的长度(1 < 10),因此不进入grow方法,而是直接将第二个元素放入elementData数组中索引为1的位置,同时再对size变量进行加1操作。如下图所示 :
可以看到,第二个元素也已经成功添加到了elementData数组中,而且size变量也变成了2。
之后,依然是先跳出内层add方法,再跳出外层add方法,一直跳回去,如下GIF所示 :
8°将集合中的元素添加到10个元素。(第一个临界点)
之后的8次循环均与第二次循环相同,我们直接一笔带过,如下GIF图所示 :
9°集合的第二次扩容开始。
因为第一次对elementData数组扩容,默认只从0扩到了10。而for循环结束后,我们已经向集合对象中添加了十个元素,即ArrayList底层的elementData数组已被装满。现在我们想添加第十一个元素,就需要对elementData数组进行第二次扩容了。
我们还是先跳入add方法,看看会发生什么,如下图所示 :
可以看到,由于从第十一个元素开始,均为String类型,因此不需要"装箱"的操作,而是直接跳到了外层add方法。同样地,我们继续跳入内层add方法,如下图所示 :
注意看内层add方法中的if条件语句,IDEA已经给出了提示" = true",没错,当前集合中已有的元素个数s = 10,而当前elementData数组的长度 = 10,10 = 10,满足判断条件。因此这时候要第二次进入grow方法对数组进行扩容了。好,我们跳入外层grow方法,如下图所示 :
注意看这时内层grow方法的实参,minCapacity : size + 1,即要想把第十一个元素放入集合中,所需集合的最小容量是size + 1 = 11,即所需elementData数组的最小长度是11。好的,接下来我们跳入内层grow方法,如下 :
仍然是先将当前elementData数组的长度(10)赋值给了oldCapacity变量;接着,if条件语句,判断条件是"当前数组的长度是否大于0,或当前数组是否不为空",与我们第一次扩容不同,第一次是从0 ——> 10,现在elementData数组已经不为空了。因此,可以看到IDEA也是再次给出了提示" = true"。
接着往下走,if语句体中,我们发现它由两部分构成。
首先,第一部分,是一个newCapacity局部变量的定义,见名知意,显然它代表了我们内层grow方法要返回的新数组的长度。而为newCapacity赋值的是另一个底层方法newLength,我们先不管它,继续往下看。
第二部分,是一个return语句,显然,这个return语句中要返回扩容后的Object类型的新数组。
好的,框架清晰后,我们再看细节。既然第一部分要获取扩容后新数组的长度,那么显然我们是要追进去这个底层方法看看的。但是,进去之前,我们不妨先来看看它的形参列表,一共传入了三个数——①当前数组的长度;②所需新数组的最小长度 - 就数组长度(= 数组的最小增长量);③旧数组右移1位。这里稍微说一下这个"oldCapacity >> 1"是什么意思,其实涉及到了C语言的一些基础——位运算。这里面涉及到了二进制的知识,当然我们也就废话少说,直接告诉你结论:"<<"表示左移,每左移1位相当于*2;">>"表示右移,每右移一位相当于/2。那么此处的"oldCapacity >> 1"就表示将旧数组的长度的值(10)右移一位,相当于10 / 2,= 5。因此,此处调用的newLength方法的实参就分别为①10;②1;③5。
OK,搞清楚这些后,我们追进去newLength方法一探究竟,如下图所示 :
可以看到,newLength方法内部的结构还是比较清晰的 : 一个变量的赋值语句,一个if-else的复合条件语句。我们一个一个来看。
首先,"prefLength",直译过来就是"预设长度"的意思,见名知意,显然它和我们新数组的长度关系密切。继续看代码,为prefLength赋值的是"旧数组的长度 + 数组最小增长量和prefGrowth之间的最大值"。这个prefGrowth不知道大家能不能想到,前面我们传入三个实参的时候,第三个实参不是"oldCapacity >> 1"吗?欸,就是它!我们也可以将鼠标悬停在它上面,IDEA会显示出它的值,如下图所示 :
可以看到,确实 = 5,和我们前面推理的一致。而minGrowth和prefGrowth一个1,一个5,肯定5大呀。所以,最终赋值给prefLength的值 = 10 + 5 = 15。欸,有没有发现,正好验证了我们前文中所提到的——当我们使用空参构造来创建ArrayList类对象时,如需再次扩容,则将elementData数组的当前容量扩容为1.5倍。估计到这里大家也能理解为什么是1.5倍了,就和前面那个位运算">> 1"有关,本身再加上它的一半,可不是1.5倍么。
继续往下执行,if条件语句中,要判断新数组的长度是否合法,要满足大于0并且小于一个"一大堆"的玩意儿。我们还是将鼠标悬停在那"一大堆"上,看看它的值是多少,如下所示 :
哎呀我去,行了,不用看了,咱就一个测试的数组,能跑那么大吗?
🆗,newLength方法结束,返回内层grow方法。如下图所示 :
还是那句话,不要因为走得太远而忘记了我们为什么出发。newLength方法只是为了获取新数组的长度,我们也进去看了,也知道是个啥了,就是旧数组的1.5倍。最后返回新数组,还是内层grow方法来完成。
此处用到了Arrays的copyOf方法。copyOf方法大家不用深究,只需要知道它的功能是将原数组中指定长度的内容拷贝到新数组中,并且,若指定的长度大于原数组长度,则多出来的部分以默认值填充,最终返回的是新数组。使用该方法扩容数组,可以在保留原数组中内容的同时,又达到扩容的目的。此时,我们新数组的长度15显然大于原数组长度10,因此我们猜测,最后返回的数组的效果就是[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, null, null, null, null, null]。(第11个元素未加入前)
我们接着Debug,跳出内层grow方法,如下所示 :
接着再跳出外层grow方法,如下 :
在内层add方法中,我们继续执行,如下如所示 :
可以看到,扩容后elementData数组的情况与我们预测的一模一样。多出的4个元素均以默认值null取代了。而且,当前集合中元素的个数也从10变成了11。
10°集合的第二次扩容结束。
接着,我们跳出内层add方法,一直跳到演示类的代码中,如下GIF所示 :
11°将集合中的元素添加到15个。(第二个临界点)
之后的第12到第15个元素的添加,与第十一个元素的添加大同小异,只不过在内层的add方法中,我们不再进入grow方法(即数组不需要扩容),而是直接将元素添加到elementData数组中。因此,接下来4个元素的添加,我们一笔带过(有兴趣的小伙伴儿可以自己下去Debug一下),如下GIF演示图 :
12°集合的第三次扩容开始。
第二次扩容结束后,底层的elementData数组由10扩容到了15。而经过我们的一通操作过后,elementData数组又满了。现在我们想向集合中添加第16个元素,就要进行集合的第三次扩容。从第二次扩容开始,之后的每次扩容在底层都与第二次扩容原理一样,并且每次都扩容到当前集合容量的1.5倍。
好的,同样地,我们先跳入外层add方法。如下 :
接着,再跳入内层add方法。如下 :
可以看到,if条件语句的判断中,又提示" = true"了,因此,我们还要再跳入外层grow方法中。如下 :
显式当前集合中元素的个数为size = 15。继续跳入内层grow方法 :
内层for循环中if语句的判断条件也为true。继续跳入newLength方法 :
可以看到,新数组的预设长度是22,恰好等于15 + 15 / 2 = 22,也再次印证我们之前的结论——扩容到1.5倍。
13°集合的第三次扩容结束。
好滴,接着我们再逐层返回,一直跳回到内层add方法中,如下GIF图所示 :
继续执行内层add方法中的代码,如下图所示 :
可以看到,elementData数组的长度成功地由15扩容到了22。
接下来,我们逐层返回,一直返回到演示类中,如下GIF图所示 :
🆗,无参构造的分步骤Debug演示,就到这里结束了。相信只要你把这一套流程吃透,自己可以Debug下来。那么其他情况下的扩容流程你也可以轻松举一反三了。
3.带参构造——分步骤Debug(详细阐释)
0°前言 :
如果利用带参构造来初始化ArrayList对象,那么它底层的扩容机制,其实与无参构造初始化ArrayList对象时大同小异。唯一不同的一点在于,使用带参构造初始化ArrayList对象,底层的elementData数组在一开始不会置空,而是将其初始化为调用带参构造时中实参指定的长度。之后的扩容流程与空参构造初始化对象时无异。因此,up这里就不会像之前空参构造时演示得那么细了。
up以ArrayList_Demo2为例,代码如下 : (13行和22行设置断点)
package csdn.knowledge.api_tools.gather.list;
import java.util.ArrayList;
/**
* @author : Cyan_RA9
* @version : 21.0
*/
public class ArrayList_Demo2 {
public static void main(String[] args) {
//演示 : 测试通过带参构造初始化ArrayList集合时,其底层的数组扩容机制。
//1.创建ArrayList集合对象
ArrayList arrayList = new ArrayList(4);
System.out.println("刚创建的集合 = " + arrayList);
//2.向集合中添加元素(先来4个)
for (int i = 0; i < 4; i++) {
arrayList.add(i); //此处涉及到了自动装箱。
}
//3.再次向集合中添加元素。(扩容:4 ——> 6)
arrayList.add("这是第五个元素捏");
arrayList.add("这是第六个元素捏");
System.out.println("添加六个元素后,集合 = " + arrayList);
//4.再次向集合中添加元素。(扩容:6 ——> 9)
arrayList.add("这是第七个元素捏");
System.out.println("添加七个元素后,集合 = " + arrayList);
}
}
1°开始Debug。
如下图所示,进入Debug界面:
2°集合的第一次扩容(初始化)。
接着,我们跳入ArrayList的带参构造,如下图所示 :
可以看到,elementData数组被初始化为了长度为4的数组,4正是我们调用带参构造时指定的长度。其实,可以看到ArrayList类的该带参构造是一个if-else if-else的符合条件语句。因为我们指定的长度4 > 0,所以它直接进入if控制的语句中,即将一个长度为4的新数组赋值给了elementData数组(其实就是改变了elementData引用的指向)。如果带参构造传入的实参为0,它就会当作空参构造来执行。如果 < 0,就会抛出一个异常对象。
接着,我们跳出带参构造。如下图所示 :
注意,我们可以看到底层的elementData数组已经被初始化为4的长度。
3°向集合中添加第一个元素。
继续Debug,跳入for循环的add方法。同之前一样,这里我们要添加int类型的数据进入几何,底层会有装箱的过程,我们直接跳出即可,如下GIF图所示 :
接着,我们再次跳入add方法中,如下图所示 :
可以看到,这不就是上面演示的外层add方法吗?是的,也就是说,从elementData数组的初始化后,带参构造集合在扩容时,底层所有的操作都同无参构造一致。
因此,下面的演示up就多以GIF图来呈现了。其实只要上面那个无参的你会了,这个过一遍就🆗了。
接下来,我们完成集合中第一个元素的添加,如下GIF图所示 :
4°将集合中的元素添加到4个。(第一个临界点)
由于底层的elementData数组初始化时长度为4。因此前四个元素的添加均无二致。我们一笔带过就好,如下GIF图演示 :
5°集合的第二次扩容。
当前集合的元素已达4个,要想向集合中添加第五个元素,需要再次进行扩容。扩容机制同上面的演示一样,这里不再赘述。如下GIF图演示 :
6°将集合中的元素添加到6个。(第二个临界点)
如下GIF图所示 :
7°集合的第三次扩容。
经过两次扩容,集合的容量从0 ——> 4 ——> 6,那么第三次扩容,就应该是6的1.5倍 = 9了。我们来看看是不是,如下GIF图所示 :
可以看到,第三次扩容后,elementData数组的长度确实从6扩到了9。如下图所示 :
四、总结
🆗,以上就是我们ArrayLIst源码分析的全部内容了。其实,如果你看过JDK8.0的源码,不难发现两个在底层的实现上有着异曲同工之妙。当然,就up个人主观来看,两者的差异主要体现在了方法的形参和方法的位置上。话说回来,非常建议大家跟着up一起Debug一下,看源码,通过Debug去了解源码的结构,有助于提升大家对于源码的阅读力。 感谢阅读!