12-彰显细节:看集合源码对我们实际工作的帮助和应用(集合)

注:源码系列文章主要是对某付费专栏的总结记录。如有侵权,请联系删除。

1 集合类图

集合架构图

从集合类图中,我们可以看出以下几点:

  1. 每个接口做的事情非常明确,比如 Serializable 只负责序列化,Cloneable 只负责拷贝,Map 只负责定义 Map 的接口,整个图看起来虽然接口众多,但职责都很清晰;
  2. 复杂功能通过接口的继承实现,比如 ArrayList 通过实现了 Serializable、Cloneable、RandomAccess、List 等接口,从而拥有了序列化、拷贝、对数组各种操作定义等功能;
  3. 上述类图只能看见继承的关系,组合的关系是看不出来的,比如说 Set 组合封装 Map 的底层能力等。

上述设计的好处是,每个接口能力职责单一,众多的接口变成了接口能力的积累,假设我们想再实现一个数据结构类,我们就可以从已有的这些能力接口中,挑选出能满足需求的能力接口,进行一些简单的组装,从而加快开发速度。

这种思想在我们平常的工作中也经常被使用,我们会把一些通用的代码块抽象出来,沉淀成代码块池,碰到不同的场景的时候,我们就从代码块池中,把我们需要的代码块提取出来,进行简单的编排和组装,从而实现我们需要的场景功能。

2 集合工作中一些注意事项

2.1 线程安全

我们说集合都是非线程安全的,这里说的非线程安全指的是集合类作为共享变量,被多线程读写的时候,才是不安全的,如果要实现贤臣安全的集合,在类注释中,JDK 统一推荐我们使用 Collections#synchronizedXxx 类,Collections 帮我们实现了 List、Set、Map 对应的线程安全的方法,如图:

 Collections#synchronizedXxx

图中实现了各种集合类型的线程安全的方法,我们以 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 集合的一些坑

  1. 当集合的元素是自定义类时,自定义类强制实现 equals 和 hashCode 方法,并且两个都要实现。
    在集合这种,除了 TreeMap 和 TreeSet 是通过比较器来比较元素外,其余的集合类在判断索引位置和相等时,都会使用 equals 和 hashCode 方法,这个在之前的源码解析中已说到,所以当集合的元素是自定义类,我们强烈建议覆写 equals 和 hashCode 方法,我们可以直接使用 IDEA 工具覆写这两个方法,非常方便;
  2. 所有集合类,在 for 循环进行删除时,如果直接使用集合的 remove 方法进行删除,都会快速失败,报 ConcurrentModificationException 错误,所以在任意循环删除的场景下都建议使用迭代器进行删除;
  3. 我们把数组转化成集合时,常使用 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 的原因。

  1. 集合 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 -------------------------------------

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值