注:本文源码取自 Sun jdk
联系与共同点
我们首先来看类的定义:
ArrayList
继承自 AbstractList
,而 LinkedList
是继承自 AbstractSequentialList
,我们再打开 AbstractSequentialList
,可以看到:
可以看到,AbstractSequentialList
是继承自 AbstractList
的,那么这两个父类有什么区别呢?查看 api 文档的第一句话我们可以看到:
这个是 AbstractList
的,翻译过来就是:此类提供 List
接口的骨干实现,以最大限度地减少实现“随机访问”数据存储(如数组)支持的该接口所需的工作。对于连续的访问数据(如链表),应优先使用 AbstractSequentialList
,而不是此类。
这个是 AbstractSequentialList
的,翻译过来就是:此类提供了 List
接口的骨干实现,从而最大限度地减少了实现受“连续访问”数据存储(如链接列表)支持的此接口所需的工作。对于随机访问数据(如数组),应该优先使用 AbstractList
,而不是先使用此类。
两个文档已经写得很清楚了,这里就不做翻译了,再看实现的接口,两者共同实现了 List<E>
, Cloneable
, java.io.Serializable
,List
是必须实现的接口,Cloneable
说明它们可被克隆, Serializable
序列化接口代表它们可以被序列化。这三个接口就不在这里进行扩展了,然后我们来说说不同点 ——
不同点
数据结构
如何查看一个类的数据结构,我们时常是通过类的构造函数来查看类的基本数据结构,首先是 ArrayList
——
三种构造方法大同小异,但是我们都会看到,实际上都是针对 elementData 这个字段进行操作,那么 elementData 又是什么呢?
原来它的本质就是一个数组,再看看第一行注释也写到 ——一个用来保存
ArrayList元素的缓冲数组
。所以实际上 ArrayList
底层实现就是一个数组罢了(此处还有一个知识点,ensureCapacityInternal()
方法会在后面的 ArrayList 的扩容算法中讲解)。可能说到这里你还不信,那么我们不妨再看看我们时常用的 add()
方法 ——
我们可以看到,在459行和478行代码处清晰地写着 elementData[size] = element
,这明摆着就是给数组赋值嘛!了解完 ArrayList
的,我们再来看看 LinkedList
的,同样的,我们先从构造函数开始看起 ——
遗憾的是我们似乎没有看到我们所想要的东西,没关系,那么我们再来看看我们最常使用的 add()
方法,源码如下 ——
再进入 linkLast()
方法看一下 ——
首先将 last 字段赋给一个类型为 Node
的值,然后将传入的 e(也就是我们在 add
方法中传入的值)封装成一个 Node
类型的值,然后就是将该值赋给 l 对象的 next 字段。那么我们到这里就知道了,关键点就在 Node
处了,它一定就是 LinkedList
的基本数据结构 ——
到这里就一目了然了,原来 LinkedList
底层实现就是一个双链表。每一个 Node
结点都是持有前后结点的引用。
继承
ArrayList
实现了RandomAccess
接口LinkedList
实现了Deque
接口
先来看看 RandomAccess
接口,我们戳进去之后发现是个空接口:
别急,我们看看文档里面都描写了什么 ——
翻译过来:
List
实现所使用的标记接口,用来表明其支持快速(通常是固定时间)随机访问。此接口的主要目的是允许一般的算法更改其行为,从而在将其应用到随机或连续访问列表时能提供良好的性能。
将操作随机访问列表的最佳算法(如 ArrayList
)应用到连续访问列表(如 LinkedList
)时,可产生二次项的行为。如果将某个算法应用到连续访问列表,那么在应用可能提供较差性能的算法前,鼓励使用一般的列表算法检查给定列表是否为此接口的一个 instanceof
,如果需要保证可接受的性能,还可以更改其行为。
现在已经认识到,随机和连续访问之间的区别通常是模糊的。例如,如果列表很大时,某些 List
实现提供渐进的线性访问时间,但实际上是固定的访问时间。这样的 List
实现通常应该实现此接口。实际经验证明,如果是下列情况,则 List
实现应该实现此接口,即对于典型的类实例而言,此循环:
for (int i = 0, n = list.size(); i < n; i++)
list.get(i);
的运行速度要快于以下循环:
for (Iterator i = list.iterator(); i.hasNext();)
i.next();
此接口是 Java Collections Framework 的成员。
总结一下:实现了 RandomAccess
接口的类(如 ArrayList
)推荐使用上面的循环来遍历,而没有实现 RandomAccess
接口的类(如 LinkedList
)推荐使用该类使用 iterator()
方法来遍历。RandomAccess
只是一个标记接口,标记这个类更适合使用 for 循环而不是 iterator()
方法来遍历,我们不妨做个实验:
public class Test {
public static void main(String[] args) {
List<String> arrayList = new ArrayList<String>();
List<String> linkedList = new LinkedList<String>();
for (int i = 0; i < 50000; i++) {
String item = String.valueOf(i);
linkedList.add(item);
arrayList.add(item);
}
long firstTime = System.currentTimeMillis();
for (int i = 0; i < linkedList.size(); i++) {
linkedList.get(i);
}
System.out.print("LinkedList for 循环毫秒数:");
System.out.print(System.currentTimeMillis() - firstTime);
System.out.println("ms");
long secondTime = System.currentTimeMillis();
for (Iterator linkIterator = linkedList.iterator(); linkIterator.hasNext(); ) {
linkIterator.next();
}
System.out.print("LinkedList listIterator 毫秒数:");
System.out.print(System.currentTimeMillis() - secondTime);
System.out.println("ms");
long thirdTime = System.currentTimeMillis();
for (int i = 0; i < arrayList.size(); i++) {
arrayList.get(i);
}
System.out.print("ArrayList for 循环毫秒数:");
System.out.print(System.currentTimeMillis() - thirdTime);
System.out.println("ms");
long fourthTime = System.currentTimeMillis();
for (Iterator linkIterator = arrayList.iterator(); linkIterator.hasNext(); ) {
linkIterator.next();
}
System.out.print("ArrayList iterator 毫秒数:");
System.out.print(System.currentTimeMillis() - fourthTime);
System.out.println("ms");
}
}
将 ArrayList
和 LinkedList
中塞入50000个数据,然后分别用 iterator()
和 for 循环遍历并打印结果,结果如下:
LinkedList for 循环毫秒数:2499ms
LinkedList iterator 毫秒数:3ms
ArrayList for 循环毫秒数:1ms
ArrayList iterator 毫秒数:2ms
可以看到,对于 LinkedList
来说,使用 iterator
效率更高,而对于实现了 RandomAccess
接口的 ArrayList
来说,使用 for 循环遍历效率会更高,原因是什么呢?我们不妨看下源码:
我们可以看到,ArrayList
的 get()
方法是直接根据数组下标获取该值
而 iterator()
内部的 next()
方法中,我们可以看到如上述红色箭头所示,在内部对外部的数组进行了复制,所以相比于 for 循环来说,iterator
方法更耗时。
接下来就是 LinkedList
,其 get()
方法如下所示:
我们可以看到,get()
方法是直接根据索引获取结点,然后获取结点的 item 对象,我们再看下第二张图就是获取结点,可以发现,结点的获取每次都是需要重新查找的,这意味着什么?我们需要拿到一个结点的话,先判断 index 是否是 size 的一半,如果是的话就走上面的 if 分支,如果不是的话就走下面的 else 分支,如果是 if 分支的话,我们从0到 index 进行遍历查找该结点,如果是 else 分支的话,我们从 size - 1 反向遍历到 index。这意味着我们每一次查找,都有可能要对 size 的一半进行遍历,如果 size 特别大的话,那么将十分的耗时,那有什么优化的呢?接下来我们就查看一下 LinkedList
的 listIterator()
方法(AbstractSequentialList
默认的 iterator()
方法就是 listIterator()
方法)——
listIterator()
方法对当前结点的前后结点进行了缓存!所以当需要拿下一个结点的时候就并不需要重新查找结点,而是直接将指针后移即可,但这个也仅是适用于顺序遍历而并不适应随机遍历,随机遍历的情况下,对当前结点的前后结点缓存并没有用。
RandomAccess
接口的应用就展现出来了,实现了 RandomAccess
接口的类推荐使用 for 循环遍历,而未实现该接口且实现了 List
接口的类,推荐使用 iterator()
方法——
if(list instanceof RandomAccess) {
for (int i = 0; i < list.size(); i++) {
list.get(i);
}
} else {
for (Iterator i = list.iterator; i.hasNext(); ) {
i.next();
}
}
当然,即使 LinkedList
类使用了 iterator()
方法也没有 ArrayList
的 for 循环效率高,这是底层实现的原因。仅这两者之间对比,ArrayList
更适合随机取值(因为直接根据索引就可以取到值,注意这里是根据索引取值而不是使用 contains()
方法寻找某个值),而 LinkedList
更适合随机插值(虽然插值的过程它也要不停地挪动指针到达目标结点的位置,但是它仅需要断开目标结点前结点和目标结点后结点的指针并插入即可,而数组则需要挪动目标点后面的值,效率低一些)。
ArrayList 的扩容算法
前面提到,ArrayList
中有一个 ensureCapacityInternal()
方法是用来扩容的(可能有小伙伴要问到,为什么 ArrayList
需要扩容,而为什么 LinkedList
又不需要?因为 ArrayList
底层实现是数组,数组的大小是固定的,如果我们放入的元素的个数大于当前数组的长度的话,那么我们就需要扩容了,而 LinkedList
底层实现是双链表,对于链表来说,没有固定大小一说。),那么扩容算法有什么好说的,让它去扩充就好了,有什么需要我们了解的么?根据 阿里巴巴 java 开发手册中第一部分第五小节第九条说到 —— 9. 【推荐】集合初始化时,尽量指定集合初始值大小。说明: ArrayList 尽量使用 ArrayList(int initialCapacity) 初始化。
我们也不妨带着这个问题来看一看扩容算法的源码,以及为什么我们需要尽量初始化它的大小,既然扩容算法发生在 add()
方法中,我们就从 add()
方法着手 ——
扩容算法就是 ensureCapacityInternal()
,我们不妨再进去看看它的源码 ——
在第一行中我们可以看到,如果 elementData 和 DEFAULTCAPACITY_EMPTY_ELEMENTDATA 相等的时候我们就取 minCapacity 和 DEFAULT_CAPACITY 当中的最大值,那么 DEFAULT_CAPACITY 的值是多少呢?查看源码我们可以知道是10(这里我们需要知道的一点就是,只有在使用无参的 ArrayList
构造函数初始化且在第一次调用 add()
方法的时候我们会进入这个 if 语句,而此时传入的参数 minCapacity(也就是 add()
方法中传入的 size + 1)实际大小为1,所以经过这个 if 语句,此时的 minCapacity 取值应该就是10。在其他情况下,我们是不会进入这个 if 语句的)。然后我们再将 minCapacity 传给 ensureExplicitCapacity()
方法。我们再来看看 ensureExplicitCapacity()
方法的源码 ——
我们再进入 grow()
方法看一下 ——
关键点就在这个 grow()
方法中了,我们创建一个 newCapacity 变量,它的大小是多少?原有大小加上原有大小的一半!两个 if 语句,我们先看第一个,如果传入的这个 minCapacity 大小比我们所创建的 newCapacity 大小还要大(举个例子,假如我期望扩容20,但是 newCapacity 大小为15,这不符合我的期望),那么就会将 minCapacity 的值赋给 newCapacity,否则的话 newCapacity 会保持原有大小。最后一行就是对数组的 copy 了。回到最开始的问题上 —— 为什么我们最好在创建 ArrayList
的时候尽量给它一个初始化大小?我们假设一个场景,我知道我需要创建一个 ArrayList
的大小为50,但是我没有给它一个大小,那么会以下情况:
- 初始化大小为10
- 当元素数量大于10的时候,那么就扩容1.5倍,即为15
- 当元素数量大于15的时候,那么就扩容1.5倍,即为22(15+7)
- 当元素数量大于22的时候,那么就扩容1.5倍,即为33
- 当元素数量大于33的时候,那么就扩容1.5倍,即为49(33+16)
- 当元素数量大于49的时候,那么就扩容1.5倍,即为73(49+24)
也就是说当我们明明只需要50大小的数组,最后却获得了一个大小为73的数组,空间浪费了不说,根据 grow()
源码我们还可知,每一次扩容都需要 copy,数组赋值可是非常耗资源的啊!这还只是50,假如是500000呢?我们不妨写个代码测试一下 ——
我们分别创建两个 ArrayList
进行 500000次循环,一个给初始容量,一个不给初始容量,结果如下:
可以看到,执行时间还是相差的特别大的(笔者电脑大概是零几年的,所以跟各位看官的实际测试结果偏差有些大)。所以我们尽量在初始化 ArrayList
的时候调用它的可赋初始化大小的 ArrayList(int initialCapacity)
构造函数。