1、 前言
经常在面试时,被问到集合的概念,集合 List、Map、Set 等底层设计以及其使用场景与注意细节。但大部分人的回答都是千篇一律,跟网上的答案一模一样,这是致命滴。其实,大家都错了,尤其是网上,更是误导大家,详细原因,且听我来分析。
2、集合 List
2.1 大家心中的 List
在广大的网友心中,List 是一个缓存数据的容器,是 JDK 为开发者提供的一种集合类型。面试时,被问到最常见的就是 ArrayList 和 LinkedList 的区别。
相信大部分网友都能回答上:ArrayList 是基于数组实现,LinkedList 是基于链表实现。而在使用场景时,我发现大部分网友的答案都是:在新增、删除操作时,LinkedList 的效率要高于 ArrayList,而在查询、遍历操作的时候,ArrayList 的效率要高于 LinkedList。这个答案是否准确呢?今天就带大家验证一哈。
2.2 性能比较
首先,我们来看看 ArrayList 和 LinkedList 的父子类。如下图:
大家看到 ArrayList、LinkedList 都继承了 AbstractList 抽象类,而 AbstractList 实现了 List 接口。ArrayList 使用数组实现,而 LinkedList 使用了双向链表实现。接下来,我们就详细地分析下 ArrayList 和 LinkedList 的性能。
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
在源码中,我们知道 ArrayList 除了实现克隆和序列化,还实现了 RandomAccess 接口。大家可能会对这个接口比较陌生,通过代码我们可以发现,这个接口其实是一个空接口,没有实现逻辑,那么 ArrayList 为什么要实现它呢?原来 RandomAccess 接口是一个标志接口,它标志着只要实现该接口,就能实现快速随机访问。
接下来,我们看看一些测试数据,以测试 50000 次为例:
- ArrayList、LinkedList 新增测试
头部:ArrayList.Time 大于 LinkedList.Time
中间:ArrayList.Time 小于 LinkedList.Time
末尾:ArrayList.Time 小于 LinkedList.Time
通过这测试,我们可以看到 LinkedList 新增元素的未必要快于 ArrayList。
由于 ArrayList 是数组实现的,而数组是一块连续的内存空间,在新增元素到数组头部的时候,需要对头部以后的数据进行重排,所以效率很低。而 LinkedList 是基于链表实现,在新增元素的时候,首先会通过循环查找到新增元素的位置,如果要新增的位置处于前半段,就从前往后找;若其位置处于后半段,就从后往前找。故 LinkedList 新增元素到头部是非常高效的。
在中间位置插入时,ArrayList 同样有部分数据需要重排,效率也不是很高,而 LinkedList 将元素新增到中间,耗时最久的,因为靠近中间位置,在新增元素之前的循环查找是遍历元素最多的操作。
而在尾部操作时,发现在没有扩容的前提下,ArrayList 的效率要高于 LinkedList。这是因为 ArrayList 在新增元素到尾部的时候,不需要复制、重排,效率非常高。而 LinkedList 虽然也不用循环查找元素,但 LinkedList 中多了 new 对象以及变换指针指向对象的逻辑,所以要耗时多于 ArrayList 的操作。
public boolean add(E e) {
linkLast(e);
return true;
}
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
- ArrayList、LinkedList 删除测试
头部:ArrayList.Time 大于 LinkedList.Time
中间:ArrayList.Time 小于 LinkedList.Time
末尾:ArrayList.Time 小于 LinkedList.Time
大家会发现 ArrayList 和 LinkedList 删除操作的测试结果和新增的结果很接近,这是一样的道理,我就不赘述了。
- ArrayList、LinkedList 遍历测试
for循环:ArrayList.Time 小于 LinkedList.Time
迭代器:ArrayList.Time 几乎等于 LinkedList.Time
我们可以看到,LinkedList 的 for 循环遍历比不上 ArrayList 的 for 循环。这是因为 LinkedList 基于链表实现的,在使用 for 循环的时候,每一次 for 循环都会去遍历大半个 List,所以严重影响了遍历的效率。而 ArrayList 是基于数组实现的,并且实现了 RandomAccess 接口标志,意味着 ArrayList 可以实现快速随机访问,所以 for 循环非常快。LinkedList 的迭代遍历和 ArrayList 的迭代性能差不多,也不会太差,所以在遍历 LinkedList 时,我们要使用迭代循环遍历。
3、常错点
思考
在一次 ArrayList 删除操作的过程中,有下面两种写法:
public static void removeA(ArrayList<String> l) {
for (String s : l){
if (s.equals("aaa")) {
l.remove(s);
}
}
}
public static void removeB(ArrayList<String> l) {
Iterator<String> it = l.iterator();
while (it.hasNext()) {
String str = it.next();
if (str.equals("aaa")) {
it.remove();
}
}
}
第一种写法错误,第二种是正确的,原因是上面的两种写法都有用到 list 内部迭代器Iterator,即遍历时,ArrayList 内部创建了一个内部迭代器 iterator,在使用 next 方法来取下一个元素时,会使用 ArrayList 里保存的一个用来记录 list 修改次数的变量 modCount,与 iterator 保存了一个叫 expectedModCount 的表示期望的修改次数进行比较,如果不相等则会抛出一个叫 ConcurrentModificationException 的异常。且在 for 循环中调用 list 中的 remove 方法,会走到一个 fastRemove 方法,该方法不是 iterator 中的方法,而是 ArrayList 中的方法,在该方法只做了 modCount++,而没有同步到 expectedModCount。所以不一致就抛出了 ConcurrentModificationException 异常了。
下面是 ArrayList 自己的remove 方法:
public boolean remove(Object o) {
if (o == null) {
for (int index = 0; index < size; index++)
if (elementData[index] == null) {
fastRemove(index);
return true;
}
} else {
for (int index = 0; index < size; index++)
if (o.equals(elementData[index])) {
fastRemove(index);
return true;
}
}
return false;
}
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; // clear to let GC do its work
}
如果有看过阿里 Java 编程规范就知道,在集合中进行 remove 操作时,不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素使用 Iterator 方式,如果并发操作,需要对 Iterator 对象加锁。
结束福利
开源实战利用 k8s 作微服务的架构设计代码:https://gitee.com/damon_one/spring-cloud-k8s
,欢迎大家 star,多多指教。
关于作者
*笔名:Damon,技术爱好者,长期从事 Java 开发、Spring Cloud 的微服务架构设计,以及结合docker、k8s做微服务容器化,自动化部署等一站式项目部署、落地。Go 语言学习,k8s研究,边缘计算框架 KubeEdge 等。