前言
自从上次字节面试凉了之后,我就一直有这个想法,想写个源码系列的博客。无奈最近事情太多,无法真正静下心来写。原本是想暑假来好好写这个系列,但因为下周要由我来负责协会授课,所以只能在这周写完。也好,毕竟只有ddl才有效率嘛(笑哭)。
关于本系列
作为本系列的第一篇文章,我想讲讲我的对于此系列的想法。
首先作为源码系列的文章,我肯定会讲讲源码,当然我也只能是作为一个菜鸟的身份,带着大家一起阅读源码,顺便说说我自己的理解。
当然了,我也不想仅限于源码,我更希望达到一种效果——让概念与实践融合。
所以在讲源码之前,我会把这个数据结构的概念通讲一遍,在这个过程中我也会尽量抛出一些问题,来帮助大家思考理解。
当然,本人技术水平有限,大佬勿喷。
注:本系列的jdk版本为1.8
一、指令与二进制数据
在正式介绍今天的数据结构之前,我想让大家切换一下视角,来到硬件底层来看看我们的程序。
相信大家也都听过,数据结构和算法课上老师一定会说的一句话——“程序就是算法和数据结构”,当然我也相信听了这话的你一定是云里雾里的,感觉很高深,很对,但又说不来怎么个对法。
其实从硬件层面上来看就不难理解了。
在硬件层面,算法对应的就是cpu的一条条指令,数据结构便是对应内存中二进制数字(的存储方式),而计算机最核心的操作不就是拿着二进制数据去执行一条条指令吗?
那么问题来了,我们该如何去拿到内存中的数据呢?
这么多的数据我们又该如何确定数据存储的位置呢?
我们自然而然想到需要有个类似地址的东西来描述内存中数据的位置,这样数据才能被准确访问。
而这个位置便叫做物理地址。
由此引出指针这个概念,指针存储的就是数据的地址,当然这个地址和上面说的物理地址还是有些不同的,指针存储的一般是虚拟地址。
不过呢,现在你只需要知道两件事——
1.数据是通过地址来进行访问的
2.指针存储的就是数据的地址,计算机可以通过指针的地址访问该地址的数据
为什么要讲上面这些内容呢,这是为了让你更好的理解下面的内容。
二、最常用的数据结构——数组
1.理解数组原理
首先,我们来看看数组的声明与创建:
int[] nums=new int[5];
这是一条最简单的用Java写的数组声明与创建。
它主要干了两件事:
1. 声明了一个int类型的一维数组
2. 给这个数组分配了五个int长度的存储空间
那如果我想访问数组中第3个元素怎么办呢?
你只需要写上
nums[2]
即可访问。
那我们来仔细思考一个问题——为什么它就能访问到该数组的第三个元素呢?
换句话说,我们为什么只写nums[2]就能确定第二个元素的确切地址呢?
其实啊,nums数组本身便是一个指针(PS:Java中没有指针这个概念,但实际上除了基本类型外,处处都是指针,具体可以看这篇Java中有没有指针。当然官方叫做句柄,但是我觉得指针更为贴切一点),它指向数组0号元素的地址,而我们之前又声明了数组的类型,再加上数组下标,我们不难算出第三个元素地址为
nums地址+int字节数*2。
专业描述就是:
a[i]_address = base_address + i * data_type_size
(图片来源于极客时间数据结构与算法之美)
而我们常说的——数组支持随机访问,根据下标随机访问的时间复杂度为 O(1),相信大家也就不难理解了吧。
有了数组,我们可以方便的存储同种类型元素,而不用为每个元素进行烦人的命名。
但数组有没有什么不方便的地方呢?
2.数组的优缺点
优点
1.支持随机访问
由于数组顺序存储,所以知道下标即可算出地址,这样根据下标访问数据的时间复杂度就为O(1)
2.同类型存储,避免烦人的命名
当然这个也是所有集合通用的优点
缺点(局限)
1.数组大小无法改变
由于数组元素在内存中的存储方式是连续存储,而大小在一开始便已经确定(这个也很好理解,不确定你怎么知道它之后的空间能不能分配给其他人),所以数组无法扩容。
而很多时候我们无法一开始就确定准确的数组大小,所以这对于某些情况就不太友好。
2.增加删除数组元素操作比较繁琐
除了在数组最后增加删除元素,其他时候增删数组元素都会牵扯到数组元素的移动,因为数组是顺序存储的,当中间的元素增加或删除时,后面的元素需要后移或前移。
总的来说,顺序存储的方式让数组支持随机访问,但也因此造成一些其他的局限。
三、Java中的封装类ArrayList
在理解完数组的原理和特点后,我们再来看看今天的主角——ArrayList。
它是Java对数组这个数据结构的封装,它封装了一些数组常用的操作,让操作更加简便,同时让数组看起来可以自动扩容。
好了,接下来开始我们的源码之旅。
源码阅读
1.源码阅读的方式
当然,在开始源码之前,我想啰嗦几句我关于源码研读的看法。
源码,在我看来就像一个巨大的迷宫,这对于体型庞大的jdk库更是如此。任何一个类都会牵扯到很多父类和接口,类中、类间方法的调用随处可见。如果你从头到尾阅读,那么你很容易迷失在这巨大的迷宫中。
读懂源码,走出迷宫的最好方式便是有一张地图(这个后面会讲)。然后根据地图,我们选择从我们熟悉的方法入手,慢慢研读,遇到不会的可以看看注释。如果还是不懂可以先跳过。很多时候我们首先要做的不是抓住每一个细节,而是看懂这个方法,看懂这个类大概是做什么的,做到心中有数,而很多时候方法的意思也能从方法名称中看出一二。
等你大致阅读了一遍明白了大概的框架后,再着眼细处也不迟。
所以接下来的源码之旅我也会据此展开。
2.ArrayList源码之旅
2.1 “迷宫地图”
ArrayList位于jdk的util包中,所以我们先打开ArrayList的源码。
打开idea,随便输个ArrayList,然后ctrl+点击,这样就进入了ArrayList的源码处,鼠标放置util处,右键选择图,选择第一个
这样我们就生成了第一张地图——继承关系图
是不是有点头晕,别急,这里你只需要大概记住继承关系即可。
然后返回ArrayList源码处,点击左侧的结构
这样我们就有了第二张地图,它也是我们本次的向导,将带领我们此次的源码之旅。
2.2 属性字段
首先我们要看的是属性字段
//版本号,这个可以忽略 private static final long serialVersionUID = 8683452581122892189L; //默认容量,这里为10,后面会提到 private static final int DEFAULT_CAPACITY = 10; //一个空的对象数组 private static final Object[] EMPTY_ELEMENTDATA = {}; //另一个空的对象数组,为什么有两个呢?主要是为了区分后面的用法 private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; //真正存储数据的数据,整个ArrayList就是围绕它展开的,transient表示该属性无法被序列化 transient Object[] elementData; //当前的元素数量 private int size;
有人可能会觉得这会占很多空间,其实不是的,前面几个属性都是static final,即静态常量,它们并不会占用对象的内存空间,当你创建ArrayList时,真正占空间的是elementData数组和size属性。
2.3 常用方法
这里我们主要探究List接口的方法
2.3.1 ArrayList构造函数
/** * 构造一个初始容量为10的空列表。 */ public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } /** * 构造一个具有指定初始容量的空列表。 */ public ArrayList(int initialCapacity) { if (initialCapacity > 0) { this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) { this.elementData = EMPTY_ELEMENTDATA; } else { throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity); } } /** * 构造一个列表,该列表包含指定集合的元素,其顺序由集合的迭代器返回。 */ public ArrayList(Collection<? extends E> c) { Object[] a = c.toArray(); if ((size = a.length) != 0) { if (c.getClass() == java.util.ArrayList.class) { elementData = a; } else { elementData = Arrays.copyOf(a, size, Object[].class); } } else { // replace with empty array. elementData = EMPTY_ELEMENTDATA; } }
这里我们看到ArrayList有三种构造方法:
ArrayList():创建一个默认ArrayList对象。
这里有个很有意思的地方——它一开始并没有创建一个对象数组,而是把默认的空对象数组的指针赋给了它,至于注释里为什么说它是一个初始容量为10的地址呢,这个奥妙在于之后要讲的add方法。现在你只需要知道这里运用懒加载的技巧,所谓初始化并非给这个elementData 数组创建一个默认大小的对象数组,而是把一个空的对象数组指针赋给了它。尽管它这里是个空数组,但实际上你可以把它认为一个默认容量为10的对象数组。
ArrayList(int initialCapacity):这里创建一个大小为initialCapacity的ArrayList对象,这里有个细节的地方,当initialCapacity为0时,它是把之前那个空对象数组的指针赋给了它。
public ArrayList(Collection c):传入其他集合对象,调用toArray方法返回对象数组初始化一个ArrayList,其顺序由集合的迭代器决定。
2.3.2 add方法
这里有两个add方法,我们逐一来介绍。
①boolean add(E e)
第一个add是我最常用的往队尾添加一个元素,返回值表示添加是否成功、
public boolean add(E e) { ensureCapacityInternal(size + 1); // Increments modCount!! elementData[size++] = e; return true; }
添加一个元素到数组队尾,那么ensureCapacityInternal(size + 1)这句话干了什么呢?
点开方法可以看到
private void ensureCapacityInternal(int minCapacity) { ensureExplicitCapacity(calculateCapacity(elementData, minCapacity)); }
calculateCapacity方法作用是计算当前需要的容量大小
private static int calculateCapacity(Object[] elementData, int minCapacity) { if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { return Math.max(DEFAULT_CAPACITY, minCapacity); } return minCapacity; }
从上面可以看到,它会判断当前的elementData 数组是不是DEFAULTCAPACITY_EMPTY_ELEMENTDATA,还记得之前说过ArrayList在执行空构造函数时的操作吗?就是让elementData 对象数组指向了DEFAULTCAPACITY_EMPTY_ELEMENTDATA,这时候执行add方法,他就会进入这个if语句中,返回DEFAULT_CAPACITY(默认容量10)和minCapacity(当前数组所需的最小容量)的最大值。
这就是当时为什么说执行无参构造函数时,虽然只是让elementData 指向一个空对象数组,但实际上可以认为它就是默认容量为10的数组的原因。
回到ensureCapacityInternal(int minCapacity)这个方法上来,在计算完所需容量后它会把这个返回值当做参数传入ensureExplicitCapacity(int minCapacity)方法中。
private void ensureExplicitCapacity(int minCapacity) { //这个暂时不用管它,它是为了防止并发修改时出现错误 modCount++; // 判断当前所需的容量是否大于elementData数组的长度,即判断是否需要扩容 if (minCapacity - elementData.length > 0) grow(minCapacity); }
当minCapacity 大于数组的长度时,说明此时的数组已经装不下现在的元素了,这时候就需要扩容,调用grow(int minCapacity)方法。
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; private void grow(int minCapacity) { int oldCapacity = elementData.length; //正常情况是原来的3/2倍。注:这里用>>会比用/快 int newCapacity = oldCapacity + (oldCapacity >> 1); //当计算好的新容量小于当前所需容量时,直接变成最小所需容量 if (newCapacity - minCapacity < 0) newCapacity = minCapacity; //当新容量大于数组最大长度(Integer.MAX_VALUE - 8)时,执行hugeCapacity方法 if (newCapacity - MAX_ARRAY_SIZE > 0) //这里会对边界情况作出处理,返回数组的边界大小 newCapacity = hugeCapacity(minCapacity); // 调用Arrays.copyOf(最后会调用一个System.arraycopy方法)来进行数组复制,新数组大小为newCapacity,这样便实现了数组扩容 elementData = Arrays.copyOf(elementData, newCapacity); }
这个方法就是扩容当前elementData ,它会把之前的elementData 复制并传入一个更大的数组中,并将elementData 的指针更新。而一般情况下扩容后的大小都是原来大小的3/2,除非到了边界或者当前所需的最小大小大于这个3/2,它会做出一些改变。
一句话来讲就是——grow方法会将当前的elementData 对象数组扩容到适合的大小,一般是原来的3/2倍。
回到最开始的问题——ensureCapacityInternal(size + 1)干了什么?
现在我们明白了,它是确保当前的数组容量能装下接下来的元素,如果不够它会选择扩容。
最后再执行
elementData[size++] = e;
即size自增1,然后将最后一个元素赋值为e。
这样add方法便完成了!
②add(int index, E element)
这个方法和上面类似,所以重复的部分我就不赘述了。
public void add(int index, E element) { //检查该index下标是否合法 rangeCheckForAdd(index); //确保数组容量足够,如果不够则扩容 ensureCapacityInternal(size + 1); //调用arraycopy方法,让index后面的元素全部后移一位,好让新的元素加进来 System.arraycopy(elementData, index, elementData, index + 1, size - index); //赋值 elementData[index] = element; //size自增1 size++; }
和之前类似,做了以下几步:
检查index下标是否合法
确保数组容量足够,如果不够则扩容
调用arraycopy方法,让index后面的元素全部后移一位,好让新的元素加进来
给index下标出的元素赋值
size++
这里我们看到每次插入需要做的便是将index下标后面的元素全部后移,所以效率不是特别高,比较麻烦。
2.3.3 remove方法
remove方法也有两种,一种是根据下标删除,一种是根据对象删除。
①E remove(int index)
根据数组下标删除元素
public E remove(int index) { //检查下标是否合法 rangeCheck(index); //用于检查并发错误的标记变量++ modCount++; //获取index下标的元素 E oldValue = elementData(index); //index离最后一个元素的距离 int numMoved = size - index - 1; //如果index后面存在元素则将index后面的元素前移一位 if (numMoved > 0) System.arraycopy(elementData, index+1, elementData, index, numMoved); //将之前最后一位元素指向null elementData[--size] = null; //返回删除的对象 return oldValue; }
这个方法说明看注释吧,注释说的很清楚了。
②boolean remove(Object o)
根据对象删除元素
public boolean remove(Object o) { //因为对象的判断需要调用equals方法,所以需要判断一下o是否为null if (o == null) { //遍历所有元素 for (int index = 0; index < size; index++) if (elementData[index] == null) { //快速删除index下标的元素 fastRemove(index); return true; } } else { for (int index = 0; index < size; index++) if (o.equals(elementData[index])) { fastRemove(index); return true; } } return false; }
这里就是一次遍历所有元素,如果找到该对象就删除。
不过可以看看这个fastRemove方法,
private void fastRemove(int index) { modCount++; int numMoved = size - index - 1; if (numMoved > 0) System.arraycopy(elementData, index+1, elementData, index, numMoved); elementData[--size] = null; }
可以看到这里和前面非常类似,只不过它这里没有进行边界检查。