注:源码系列文章主要是对某付费专栏的总结记录。如有侵权,请联系删除。
1 集合类图
从集合类图中,我们可以看出以下几点:
- 每个接口做的事情非常明确,比如 Serializable 只负责序列化,Cloneable 只负责拷贝,Map 只负责定义 Map 的接口,整个图看起来虽然接口众多,但职责都很清晰;
- 复杂功能通过接口的继承实现,比如 ArrayList 通过实现了 Serializable、Cloneable、RandomAccess、List 等接口,从而拥有了序列化、拷贝、对数组各种操作定义等功能;
- 上述类图只能看见继承的关系,组合的关系是看不出来的,比如说 Set 组合封装 Map 的底层能力等。
上述设计的好处是,每个接口能力职责单一,众多的接口变成了接口能力的积累,假设我们想再实现一个数据结构类,我们就可以从已有的这些能力接口中,挑选出能满足需求的能力接口,进行一些简单的组装,从而加快开发速度。
这种思想在我们平常的工作中也经常被使用,我们会把一些通用的代码块抽象出来,沉淀成代码块池,碰到不同的场景的时候,我们就从代码块池中,把我们需要的代码块提取出来,进行简单的编排和组装,从而实现我们需要的场景功能。
2 集合工作中一些注意事项
2.1 线程安全
我们说集合都是非线程安全的,这里说的非线程安全指的是集合类作为共享变量,被多线程读写的时候,才是不安全的,如果要实现贤臣安全的集合,在类注释中,JDK 统一推荐我们使用 Collections#synchronizedXxx
类,Collections 帮我们实现了 List、Set、Map 对应的线程安全的方法,如图:
图中实现了各种集合类型的线程安全的方法,我们以 synchronizedList
为例,从源码上来看看,Collections 是如何实现线程安全的:
// 父类 SynchronizedCollection 中定义的锁
final Object mutex;
static class SynchronizedList<E>
extends SynchronizedCollection<E>
implements List<E> {
private static final long serialVersionUID = -7754090372962971524L;
final List<E> list;
SynchronizedList(List<E> list) {
super(list);
this.list = list;
}
SynchronizedList(List<E> list, Object mutex) {
super(list, mutex);
this.list = list;
}
public boolean equals(Object o) {
if (this == o)
return true;
synchronized (mutex) {return list.equals(o);}
}
public int hashCode() {
synchronized (mutex) {return list.hashCode();}
}
public E get(int index) {
synchronized (mutex) {return list.get(index);}
}
public E set(int index, E element) {
synchronized (mutex) {return list.set(index, element);}
}
public void add(int index, E element) {
synchronized (mutex) {list.add(index, element);}
}
public E remove(int index) {
synchronized (mutex) {return list.remove(index);}
}
public int indexOf(Object o) {
synchronized (mutex) {return list.indexOf(o);}
}
public int lastIndexOf(Object o) {
synchronized (mutex) {return list.lastIndexOf(o);}
}
public boolean addAll(int index, Collection<? extends E> c) {
synchronized (mutex) {return list.addAll(index, c);}
}
.....
}
从源码中我们可以看到 Collections 是通过 synchronized 关键字给 List 操作数组的方法加上锁,来实现线程安全的。
2.2 集合性能
集合的单个操作,一般都没有性能问题,性能问题主要出现在批量操作上。
2.2.1 批量新增
在 List 和 Map 大量数据新增的时候,我们不要使用 for 循环 + add/put 方法新增,这样子会有很大的扩容成本,我们应该尽量使用 addAll 和 putAll 方法进行新增,以 ArrayList 为例写一个 demo,演示两种方案的性能对比:
// 拷贝数据
ArrayList<Integer> list = new ArrayList<>();
for (int i = 0; i < 3000000; i++) {
list.add(i);
}
// for 循环 + add
ArrayList<Integer> list2 = new ArrayList<>();
long s1 = System.currentTimeMillis();
for (int i = 0; i < list.size(); i++) {
list2.add(list.get(i));
}
System.out.println("单个 for 循环新增 300w 数据,耗时:" + (System.currentTimeMillis() - s1));
// 批量新增
ArrayList<Integer> list3 = new ArrayList<>();
long s2 = System.currentTimeMillis();
list3.addAll(list);
System.out.println("批量新增 300w 数据,耗时:" + (System.currentTimeMillis() - s2));
执行结果:
单个 for 循环新增 300w 数据,耗时:63
批量新增 300w 数据,耗时:6
可以看到,批量新增方法性能是单个新增方法性能的 10 倍,主要原因在于批量新增,只会扩容一次,大大缩短了运行时间,而单个新增,每次到达扩容阈值时,都会进行扩容,在整个过程中就会不断的扩容,浪费了很多时间。批量新增源码:
public boolean addAll(Collection<? extends E> c) {
Object[] a = c.toArray();
int numNew = a.length;
// 确保容量充足,整个过程只会扩容一次
ensureCapacityInternal(size + numNew); // Increments modCount
// 拷贝数组
System.arraycopy(a, 0, elementData, size, numNew);
size += numNew;
return numNew != 0;
}
以上是 ArrayList 批量新增的演示,可以看到整个批量新增的过程中,只扩容一次,HashMap 的 putAll 方法也是如此,整个新增过程只会扩容一次,大大缩短了批量新增的时间,提高了性能。
所以如果有人问你当碰到集合批量拷贝、批量新增场景,如何提高新增性能时,就可以从目标集合初始化方面应该。
这里也提醒我们,在容器初始化的时候,最好能给容器附上初始值,这样可以防止在 add/put 的过程中不断的扩容,从而缩短时间。上章节 HashSet 的源码给我们演示了,给 HashMap 赋初始值的公式为:取括号内的最大值(期望的值/0.75+1,默认值 16)
。
2.2.2 批量删除
批量删除 ArrayList 提供了 removeAll
的方法,HashMap 没有提供批量删除的方法,源码如下:
// 批量删除,removeAll 方法底层调用的是 batchRemove 方法
// complement 默认是 false,false 的意思是数组中不包含 c 中数据的节点往头移动
// true 的意思是数据中包含 c 中数据的节点往头移动,这个要根据你要删除数据和原先数组大小的比例来决定的
// 如果你要删除的数据很多,选择 false 性能更好,当然 removeAll 方法默认就是 false
private boolean batchRemove(Collection<?> c, boolean complement) {
final Object[] elementData = this.elementData;
int r = 0, w = 0;
boolean modified = false;
try {
// 循环整个数组
for (; r < size; r++)
// 当前数组元素是不是要被删除的值,如果不是则移动到数组头
if (c.contains(elementData[r]) == complement)
elementData[w++] = elementData[r];
// 循环完成后,得到的 w 之前的值都是不需要被删除的
} finally {
// 理论上来说经过上面的 for 循环 r 和 size 是相等的
// 如果这里不相等证明上面的 for 循环中 constains 出错了
if (r != size) {
// 把 r 位置之后的数组移动到 w 位置之后
// 也就构成了 w 之前不需要删除的数据和 r 之后没有经过判断的数据之和。这样不会影响没有判断的数据,判断过的数据可以给删除。
System.arraycopy(elementData, r,
elementData, w,
size - r);
// w += 未判断的数据
// 正常情况下未判断的数据(size - r) 为0的,所以 w 之前的数据都不需要被删除,之后的需要。
w += size - r;
}
// 如果 w != size 证明有需要删除的数
if (w != size) {
// 从 w 位置开始循环,w 之后的删除即可
for (int i = w; i < size; i++)
elementData[i] = null;
// 修改 modCount 为被删除元素的个数
modCount += size - w;
// 赋值 size 为剩余不需要被删除的元素个数
size = w;
// 赋值返回值为 true,表示数据有被修改
modified = true;
}
}
return modified;
}
我们看到 ArrayList 在批量删除时,如果程序执行正常,只有一次 for 循环,如果程序执行异常,才会加一次拷贝,而单个 remove 方法,每次执行的时候都会进行数组的拷贝(当删除的元素正好是数组最后一个元素时除外),当数组越大,需要删除的数据越多时,批量删除的性能越差,所以在 ArrayList 批量删除时,强烈建议使用 removeAll 方法进行删除。
2.3 集合的一些坑
- 当集合的元素是自定义类时,自定义类强制实现 equals 和 hashCode 方法,并且两个都要实现。
在集合这种,除了 TreeMap 和 TreeSet 是通过比较器来比较元素外,其余的集合类在判断索引位置和相等时,都会使用 equals 和 hashCode 方法,这个在之前的源码解析中已说到,所以当集合的元素是自定义类,我们强烈建议覆写 equals 和 hashCode 方法,我们可以直接使用 IDEA 工具覆写这两个方法,非常方便; - 所有集合类,在 for 循环进行删除时,如果直接使用集合的 remove 方法进行删除,都会快速失败,报 ConcurrentModificationException 错误,所以在任意循环删除的场景下都建议使用迭代器进行删除;
- 我们把数组转化成集合时,常使用
Arrays.asList(array)
这个方法有个坑,代码演示:
Integer[] array = new Integer[]{1, 2, 3, 4, 5};
List<Integer> list = Arrays.asList(array);
// 坑1: 修改数组的值,会直接影响到原 list
System.out.println("数组被修改之前,集合第一个元素为:" + list.get(0));
array[0] = 10;
System.out.println("数组被修改之后,集合第一个元素为:" + list.get(0));
// 坑2: 使用 add、remove 等操作 list 的方法时,会报 UnsupportedOperationException 错
list.add(6);
执行结果:
数组被修改之前,集合第一个元素为:1
数组被修改之后,集合第一个元素为:10
java.lang.UnsupportedOperationException
at java.util.AbstractList.add(AbstractList.java:148)
at java.util.AbstractList.add(AbstractList.java:108)
Arrays.asList(T[] array)
源码:
public class Arrays {
private static class ArrayList<E> extends AbstractList<E>
implements RandomAccess, java.io.Serializable
{
private static final long serialVersionUID = -2764017481108945198L;
private final E[] a;
ArrayList(E[] array) {
a = Objects.requireNonNull(array);
}
.....
}
.....
}
从源码看到,Arrays.asList 方法返回的 List 并不是 java.util.ArrayList,而是自己内部的一个静态类,该静态类直接持有数组的引用,并且没有实现 add、remove 等方法,这就是 坑1 和 坑2 的原因。
- 集合 List 转成数组,我们通常使用 toArray 这个方法,这个方法很危险,稍微不注意就会踩入大坑,演示如下:
List<Integer> list = new ArrayList<Integer>() {{
add(1);
add(2);
add(3);
add(4);
add(5);
}};
// toArray 无参方法只能返回 Object 类型,无法转换成具体类型
// Integer[] integers = list.toArray(); // 编译不通过
Object[] objects = list.toArray();
System.out.println("无参 toArray: " + JSON.toJSONString(objects));
// 演示数组初始化大小小于原集合,得到数组为全为 null 的情况
Integer[] array0 = new Integer[2];
Integer[] array0r = list.toArray(array0);
System.out.println("array0[2]: " + JSON.toJSONString(array0));
System.out.println("array0r: " + JSON.toJSONString(array0r));
// 演示数组初始化大小正好
Integer[] array1 = new Integer[list.size()];
Integer[] array1r = list.toArray(array1);
System.out.println("array1[list.size()]: " + JSON.toJSONString(array1));
System.out.println("array1r: " + JSON.toJSONString(array1r));
// 演示数组初始化大小大于原集合
Integer[] array2 = new Integer[list.size() + 2];
Integer[] array2r = list.toArray(array2);
System.out.println("array2[list.size()+2]: " + JSON.toJSONString(array2));
System.out.println("array2r: " + JSON.toJSONString(array2r));
执行结果:
无参 toArray: [1,2,3,4,5]
array0[2]: [null,null]
array0r: [1,2,3,4,5]
array1[list.size()]: [1,2,3,4,5]
array1r: [1,2,3,4,5]
array2[list.size()+2]: [1,2,3,4,5,null,null]
array2r: [1,2,3,4,5,null,null]
toArray 的无参方法,无法强制转换成具体类型,这个编译的时候,就会有提醒,我们一般都会去使用带有参数的 toArray 方法,这时就有一个坑,如果参数数组的大小不够,这时候返回的数组值竟然是空的,上述代码 array0 的返回值就体现了这点。我们看源码的实现 ArrayList#toArray(T[] a)
方法:
public <T> T[] toArray(T[] a) {
if (a.length < size)
如果入参 a 的长度小于 size,则返回一个新的长度同 size 的数组,数组类型为入参 a 的类型
return (T[]) Arrays.copyOf(elementData, size, a.getClass());
// 如果入参 a 的长度大于等于 size 时,直接拷贝集合底层数组 elementData 的内容到入参数组 a 中,拷贝的长度为集合长度 size
System.arraycopy(elementData, 0, a, 0, size);
// 如果入参数组 a 的长度大于集合长度 size,则赋值 null
if (a.length > size)
a[size] = null;
// 返回入参数组 a(已拷贝内容)
return a;
}
从上面 ArrayList
源码方法 toArray(T[] a)
看出,只有当入参数组长度大于等于集合长度时,返回的数组同入参数组引用,并且拷贝值进去。如果入参数组 a 长度小于集合长度时,返回一个新创建的数组,入参 a 只是作为返回数组类型来判断的依据。
List 源码 toArray(T[] a) 中也有这样的一段注释:
If the list fits in the specified array, it is returned therein. Otherwise, a new array is allocated with the runtime type of the specified array and the size of this list.
翻译过来的意思就是说:如果返回的数组大小和申明的一致,那么就会正常返回,否则,一个新数组就会被分配返回。
所以我们在使用有参 toArray 方法时,申请的数组大小一定要大于等于 List 的大小,如果小于的话,只能使用返回的新数组。
------------------------------------- END -------------------------------------