集合类

1、集合框架

Java 中的集合框架定义了一套规范,用来表示、操作集合,使具体操作与实现细节解耦

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-W1A3kY4f-1580104009343)(image/collection-structure.jpg)]

2、设计理念

  • 为了保证核心接口足够小,最顶层的接口(也就是Collection与Map接口)并不会区分该集合是否可变(mutability),是否可更改(modifiability),是否可改变大小(resizability)这些细微的差别。相反,一些操作是可选的,在实现时抛出UnsupportedOperationException即可表示集合不支持该操作;

  • 框架提供一套方法,让集合类与数组可以相互转化,并且可以把Map看作成集合;

3、Collection 和 Map

  • 集合框架的类继承体系中,最顶层有两个接口:

    • Collection 表示一组纯数据
    • Map 表示一组key-value对
  • 一般继承自 Collection 或 Map 的集合类,会提供两个"标准"的构造函数:没有参数的构造函数,创建一个空的集合类;有一个类型与基类(Collection或Map)相同的构造函数,创建一个与给定参数具有相同元素的新集合类;

  • Collection 接口主要有三个接口

    • Set 表示不允许有重复元素的集合(A collection that contains no duplicate elements);
    • List 表示允许有重复元素的集合(An ordered collection (also known as a sequence));
    • Queue,JDK1.5新增,与上面两个集合类主要是的区分在于Queue主要用于存储数据,而不是处理数据(A collection designed for holding elements prior to processing)
  • Map 并不是一个真正意义上的集合,这个接口提供了三种集合视角,使得可以像操作集合一样操作它们:

    • 把 map的内容看作key的集合(map’s contents to be viewed as a set of keys):Set<K> keySet(),提供key的集合视角
    • 把map的内容看作value的集合(map’s contents to be viewed as a collection of values):Collection values(),提供value的集合视角
    • 把map的内容看作key-value映射的集合(map’s contents to be viewed as a set of key-value mappings):Set<Map.Entry<K, V>> entrySet(),提供key-value序对的集合视角,这里用内部类 Map.Entry 表示序对
  • Collections 集合的工具类,提供了操作集合类型的静态方法的类;

4、集合的同步容器

  • 同步容器类:Collections.synchronizedXXX 等以及VectorHashtable.

  • 同步容器类的问题:同步容器都是线程安全的,但是在某些情况下可能需要额外的客户端加锁来保护复合操作。容器上常见的复合操作包括:

    • 迭代:反复访问元素,直到遍历完容器中所有元素
    • 跳转:根据指定顺序找到当前元素的下一个元素
    • 条件运算:比如"若没有则添加"

    在同步容器中,这些复合操作在没有客户端加锁的情况下仍然是线程安全的,但当其他线程并发的修改容器时,可能出现意料之外的结果

二、fail-fast机制

1、基本概念

是Java集合的一种错误检测机制,当多个线程对集合进行结构上的改变操作时,有可能会发生 fail-fast机制记住:是有可能,而不是一定;假设有两个线程A,B 要对集合进行操作,当A线程在遍历集合的元素时,B 线程修改了集合(增删改),这个时候抛出 ConcurrentModificationException 异常,从而就会触发fail-fast机制。java.util包下的都所有集合类都是faile-fast

2、fail-fast机制产生的原因

  • ConcurrentModificationException 产生的原因:

    当方法检测到对象的并发修改,但不允许这种修改时就会抛出该异常。同时需要注意的是,该异常不会始终指出对象已经由不同线程并发修改,如果单线程违反了规则,同样也有可能会抛出异常;诚然,迭代器的快速失败行为无法得到保证,它不能保证一定出现错误,但是快速失败操作会尽最大努力抛出 ConcurrentModificationException 异常;ConcurrentModificationException 应该仅用于检测 bug;

  • 以 ArrayList 为例分析fail-fast产生的原因:

    • 查看 ArrayList 的 Itr 的源码可以发现,当迭代器在调用 nextremove 时都会调用 checkForComodification()方法:

      final void checkForComodification() {
          if (modCount != expectedModCount)
              throw new ConcurrentModificationException()}

      该方法检测modCountexpectedModCount是否相等,若不等则抛出 ConcurrentModificationException 异常,从而产生fail-fast机制;

    • 为什么 modCount != expectedModCount,他们的值是在何时发生改变的?

      int expectedModCount = modCount;(JDK1.7.0._79-b15),modCount是在AbstractList 中定义的,为全局变量:protected transient int modCount = 0;

      ArrayList 中无论add、remove、clear方法只要是涉及了改变 ArrayList 元素的个数的方法都会导致modCount的改变,初步判断由于expectedModCount 得值与modCount的改变不同步,导致两者之间不等从而产生fail-fast机制:即期望的值跟修改后的值不等

3、fail-fast 解决方法

  • 在遍历过程中所有涉及到改变modCount值得地方全部加上 synchronized 或者直接使用 Collections.synchronizedList,这样就可以解决;但是不推荐,因为增删造成的同步锁可能会阻塞遍历操作

  • 使用CopyOnWriteArrayList 来替换 ArrayList

    CopyOnWriteArrayList 是 ArrayList 的一个线程安全的变体其中所有可变操作(add、set 等)都是通过对底层数组进行一次新的复制来实现的;

    • 在两种情况下非常适用:

      • 在不能或不想进行同步遍历,但又需要从并发线程中排除冲突时
      • 当遍历操作的数量大大超过可变操作的数量时
    • 为什么CopyOnWriteArrayList可以替代ArrayList

      • CopyOnWriteArrayList 的无论是从数据结构、定义都和 ArrayList 一样;它和 ArrayList 一样,同样是实现 List 接口,底层使用数组实现.在方法上也包含add、remove、clear、iterator等方法;
      • CopyOnWriteArrayList 根本就不会产生 ConcurrentModificationException 异常,也就是它使用迭代器完全不会产生fail-fast机制
    • CopyOnWriteArrayList 所代表的核心概念就是:任何对array在结构上有所改变的操作(add、remove、clear等),CopyOnWriteArrayList 都会copy现有的数据,再在copy的数据上修改,这样就不会影响COWIterator中的数据了,修改完成之后改变原有数据的引用即可.同时这样造成的代价就是产生大量的对象,同时数组的copy也是相当有损耗的

4、fail-safe机制

fail-safe任何对集合结构的修改都会在一个复制的集合上进行修改,因此不会抛出 ConcurrentModificationException;java.util.concurrent包下的集合类都是fail-safe

采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历;

由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原有集合所做的修改并不能被迭代检测到,所以不会触发ConcurrentModificationException

fail-safe机制有两个问题:

  • 需要复制集合,产生大量的无效对象,开销大;
  • 无法保证读取的数据是目前原始数结构中的数据;

如:CopyOnWriteArrayList

三、Map中的 hash() 算法

1、Hash-哈希(散列)

1.1、什么是Hash

  • 就是把任意长度的输入,通过散列算法,变换成固定长度的输出,该输出就是散列值,这种转换是一种压缩;不同的输入可能会散列成相同的输出,所有不可能从散列值来唯一确认输入值;
  • 所有散列函数都有一个基本特性:根据同一散列函数计算出的散列值如果不同,那么输入值也不同。但是根据同一散列函数计算出的散列值如果相同,输入值不一定相同;
  • 碰撞:不同的输入值,根据同一散列函数计算出的散列值相同的现象叫做碰撞;

1.2、常见的散列函数

  • 直接定址法:直接以关键字k或者k加上某个常数(k+c)作为哈希地址;
  • 数字分析法:提取关键字中取值比较均匀的数字作为哈希地址;
  • 除留余数法:用关键字k除以某个不大于哈希表长度m的数p,将所得余数作为哈希表地址;
  • 分段叠加法:按照哈希表地址位数将关键字分成位数相等的几部分,其中最后一部分可以比较短。然后将这几部分相加,舍弃最高进位后的结果就是该关键字的哈希地址;
  • 平方取中法:如果关键字各个部分分布都不均匀的话,可以先求出它的平方值,然后按照需求去中间几位作为哈希表地址;
  • 伪随机数法:采用一个伪随机数作为哈希函数

1.3、碰撞解决方案

衡量一个哈希函数的好坏的重要指标就是发生碰撞的概率以及发生碰撞的解决方案。任何哈希函数基本无法彻底避免碰撞。

常见解决碰撞的方法有以下几种:

  • 开发定址法:就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入;
  • 链地址法:将哈希表的每个单元作为链表的头结点,所有哈希地址为 i 的元素构成一个同义词链表。即发生冲突时就把该关键字链在以该单元为头结点的链表尾部;
  • 再哈希法:当哈希地址发生冲突用其他的函数计算另一个哈希地址,直到冲突不在产生为止;
  • 建立公共溢出区:将哈希表分为基本表和溢出表两部分,发生冲突的元素都放入溢出表中。

2、HashMap中的hash算法

final int hash(Object k)

被引用的方法主要是增加和删除操作

2.1、源码分析

hash方法的功能是根据Key来定位这个K-V在链表数组中的位置的。就是hash方法的输入应该是个Object类型的Key,输出应该是个int类型的数组下标由两个方法来实现:hash(Object k)int indexFor(int h, int length)来实现

final int hash(Object k) {
    int h = hashSeed;;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }
    /**
     * 为了对key的hashCode进行扰动计算,防止不同hashCode的高位不同但低位相同导致的hash冲突
     * 就是为了把高位的特征和低位的特征组合起来,降低哈希冲突的概率
     *
     */
    h ^= k.hashCode();
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

static int indexFor(int h, int length) {
    return h & (length-1);
}

Java之所有使用位运算&来代替取模运算%,最主要的考虑就是效率:X % 2^n = X & (2^n – 1)2^n表示2的n次方,也就是说,一个数对2^n取模 == 一个数和(2^n – 1)做按位与运算

假设n为3,则2^3 = 8,表示成2进制就是1000。2^3 = 7 ,即0111。

此时X & (2^3 – 1) 就相当于取X的2进制的最后三位数。

从2进制角度来看,X / 8相当于 X >> 3,即把X右移3位,此时得到了X / 8的商,而被移掉的部分(后三位),则是X % 8,也就是余数

四、使用集合注意点

1、单个操作与批量操作

在 List 和 Map 大量数据新增的时候,我们不要使用 for 循环 + add/put 方法新增,这样子会有很大的扩容成本,我们应该尽量使用 addAll 和 putAll 方法进行新增,以 ArrayList 为例写了一个 demo 如下,演示了两种方案的性能对比:

@Test
public void testBatchAddPerformance(){
    // 准备拷贝数据
    ArrayList<Integer> list = new ArrayList<>();
    for(int i=0;i<3000000;i++){
        list.add(i);
    }

    StopWatch stopWatch = new StopWatch();
    stopWatch.start("单个 for 循环新增 300 w 个");
    ArrayList<Integer> list2 = new ArrayList<>();
    for(int i=0;i<list.size();i++){
        list2.add(list.get(i));
    }
    stopWatch.stop();

    // 批量新增
    stopWatch.start("批量新增 300 w 个");
    ArrayList<Integer> list3 = new ArrayList<>();
    list3.addAll(list);
    stopWatch.stop();

    System.out.println(stopWatch.prettyPrint());
}

上面输出结果:

StopWatch '': running time = 80895284 ns
---------------------------------------------
ns         %     Task name
---------------------------------------------
075216439  093%  单个 for 循环新增 300 w 个
005678845  007%  批量新增 300 w 个

性能差异原因主要在于批量新增时只扩容一次,而单个新增时,每次到达扩容阈值时都要进行扩容操作,那么在整个过程中会不断的扩容,浪费时间,以ArrayList代码为例:

public boolean addAll(Collection<? extends E> c) {
    Object[] a = c.toArray();
    int numNew = a.length;
    // 确保容量充足,整个过程只会扩容一次
    ensureCapacityInternal(size + numNew); 
    // 进行数组的拷贝
    System.arraycopy(a, 0, elementData, size, numNew);
    size += numNew;
    return numNew != 0;
}

2、批量删除

ArrayList提供了批量删除的功能,如下代码;HashMap等没有提供

/ 批量删除,removeAll 方法底层调用的是 batchRemove 方法
// complement 参数默认是 false,false 的意思是数组中不包含 c 中数据的节点往头移动
// true 意思是数组中包含 c 中数据的节点往头移动,这个是根据你要删除数据和原数组大小的比例来决定的
// 如果你要删除的数据很多,选择 false 性能更好,当然 removeAll 方法默认就是 false。
private boolean batchRemove(Collection<?> c, boolean complement) {
  final Object[] elementData = this.elementData;
  // r 表示当前循环的位置、w 位置之前都是不需要被删除的数据,w 位置之后都是需要被删除的数据
  int r = 0, w = 0;
  boolean modified = false;
  try {
    // 从 0 位置开始判断,当前数组中元素是不是要被删除的元素,不是的话移到数组头
    for (; r < size; r++)
      if (c.contains(elementData[r]) == complement)
        elementData[w++] = elementData[r];
  } finally {
    // r 和 size 不等,说明在 try 过程中发生了异常,在 r 处断开
    // 把 r 位置之后的数组移动到 w 位置之后(r 位置之后的数组数据都是没有判断过的数据,这样不会影响没有判断的数据,判断过的数据可以被删除)
    if (r != size) {
      System.arraycopy(elementData, r,  elementData, w, size - r);
      w += size - r;
    }
    // w != size 说明数组中是有数据需要被删除的, 如果 w、size 相等,说明没有数据需要被删除
    if (w != size) {
      // w 之后都是需要删除的数据,赋值为空,帮助 gc。
      for (int i = w; i < size; i++)
        elementData[i] = null;
      modCount += size - w;
      size = w;
      modified = true;
    }
  }
  return modified;
}

ArrayList 在批量删除时,如果程序执行正常,只有一次 for 循环,如果程序执行异常,才会加一次拷贝,而单个 remove 方法,每次执行的时候都会进行数组的拷贝(当删除的元素正好是数组最后一个元素时除外),当数组越大,需要删除的数据越多时,批量删除的性能会越差,所以在 ArrayList 批量删除时,强烈建议使用 removeAll 方法进行删除。

3、需要避免的坑

  • 集合元素如果是自定义时,尤其是key,最好是重写equals和hashcode方法;

  • 所有集合类,如果使用的for循环迭代,不能使用remove删除元素,否则会报 ConcurrenModificationException;

  • 把数组转为集合,即如果调用的是 Arrays.asList(array) 方法,需要注意以下两点:

    • 数组被修改后,会直接影响到新 List 的值;
    • 不能对新 List 进行 add、remove 等操作,否则运行时会报 UnsupportedOperationException 错误

    因为 Arrays.asList(array) 返回的是 ArrayList,其是 Arrays的一个内部类,其并没有实现add、remove等方法

    public static <T> List<T> asList(T... a) {
        return new ArrayList<>(a);
    }
    private static class ArrayList<E> extends AbstractList<E> implements RandomAccess, java.io.Serializable {}
    // AbstractList,如果子类没有实现这些方法,方法都是抛出异常的
    public void add(int index, E element) {
        throw new UnsupportedOperationException();
    }
    public E remove(int index) {
        throw new UnsupportedOperationException();
    }
  • 集合转为数组时,通常使用的是 toArray方法,注意踩坑

    ArrayList<Integer> list = new ArrayList<Integer>(){{
            add(1);
            add(2);
            add(3);
            add(4);
    }};
    
    // 编译报错,因为 list.toArray() 返回的是 Object[],无法转换,强转也不行
    // Integer[] source = list.toArray();
    
    // list.toArray有参数的方法,传入一个数组,但是数组的长度小于集合的长度,那么source里面的元素都是null;
    // 但是方法的返回值是包含所有元素的
    Integer[] source = new Integer[2];
    Integer[] arr = list.toArray(source);
    System.out.println(Arrays.toString(source)); // [null, null]
    System.out.println(Arrays.toString(arr)); // [1, 2, 3, 4]
    
    // list.toArray有参数的方法,传入一个数组,但是数组的长度等于集合的长度,那么source里面的元素都是集合的元素的
    // 但是方法的返回值是包含所有元素的
    Integer[] source1 = new Integer[4];
    Integer[] arr1 = list.toArray(source1);
    System.out.println(Arrays.toString(source1)); // [1, 2, 3, 4]
    System.out.println(Arrays.toString(arr1)); // [1, 2, 3, 4]
    
    // list.toArray有参数的方法,传入一个数组,但是数组的长度大于集合的长度,那么source里面的元素都是集合的元素的,数组的对于元素为null
    // 但是方法的返回值是包含所有元素的,数组的对于元素为null
    Integer[] source2 = new Integer[6];
    Integer[] arr2 = list.toArray(source2);
    System.out.println(Arrays.toString(source2)); // [1, 2, 3, 4, null, null]
    System.out.println(Arrays.toString(arr2)); // [1, 2, 3, 4, null, null]

    可以查看 ArrayList.toArray方法的源码:

    // List 转化成数组
    public <T> T[] toArray(T[] a) {
        // 如果数组长度不够,按照 List 的大小进行拷贝,return 的时候返回的都是正确的数组
        if (a.length < size)
            // Make a new array of a's runtime type, but my contents:
            return (T[]) Arrays.copyOf(elementData, size, a.getClass());
        System.arraycopy(elementData, 0, a, 0, size);
        // 数组长度大于 List 大小的,赋值为 null
        if (a.length > size)
            a[size] = null;
        return a;
    }

五、JDK7与JDK8集合异同

1、通用区别

List、Set、Map 在 Java8 版本中都增加了 forEach 的方法,方法的入参是 Consumer,Consumer 是一个函数式接口,可以简单理解成允许一个入参,但没有返回值的函数式接口,我们以 ArrayList 的 forEach 的源码为例,来看下方法是如何实现的 :

@Override
public void forEach(Consumer<? super E> action) {
    // 判断非空
    Objects.requireNonNull(action);
    // modCount的原始值被拷贝
    final int expectedModCount = modCount;
    final E[] elementData = (E[]) this.elementData;
    final int size = this.size;
    // 每次循环都会判断数组有没有被修改,一旦被修改,停止循环
    for (int i=0; modCount == expectedModCount && i < size; i++) {
        // 执行循环内容,action 代表我们要干的事情
        action.accept(elementData[i]);
    }
    // 数组如果被修改了,抛异常
    if (modCount != expectedModCount) {
        throw new ConcurrentModificationException();
    }
}

List、Set、Map 在Java8中的方法很多都加入了 default 方法,通过default方法,可以让子类无须实现父接口的default方法

2、ArrayList 区别

ArrayList 无参初始化时,Java7比较早期的版本是直接初始化为10的大小的,Java8是去掉了这个逻辑,初始为空数组,在第一次add的时候进行扩容,容量为10;

参考资料

posted @ 2020-01-27 13:48  阳神  阅读(118)  评论(0编辑  收藏  举报