集合知识点整理

Java 中,数组是保存一组对象的最有效的方式,但是数组的大小是固定的,通常在写代码时,我们不知道对象的确切个数,这个时候,JDK 提供的容器类帮我们解决这个问题。

Java 的容器类分为两类:CollectionMap。所有元素序列对象都实现了Collection接口,键值对对象则实现Map接口。在接口和实现之间有一层Abstract的抽象集合类,一方面,它们提供了集合的通用功能,比如toString方法,通用遍历方法,HashMap的几个视图方法,keySetentrySetvalues等;另一方面,这些抽象集合类为想要自定义实现集合的人提供了标准。

ArrayList

  • 本质是一个数组:Object[]
  • 默认初始容量:10
  • 扩容机制:扩容为原来大小的1.5倍。使用方法System.arraycopy,代码位置:
      private void grow(int minCapacity) {
          ...
          int newCapacity = oldCapacity + (oldCapacity >> 1); // 右移一位相当于除以2
          ...
      }
    

LinkedList

一个双向链表,同时提供了首、尾两个节点的数据。

public class LinkedList<E> {
    transient Node<E> first;
    transient Node<E> last;

    private static class Node<E> {
        E item;
        Node<E> next;
        Node<E> prev;
    }
}

随机访问一个数据时,使用二分法,根据给定的位置判断是在前半部分还是后半部分,然后从头或尾部遍历。

点击查看代码
public E get(int index) {
    ...
    return node(index).item;
}

Node<E> node(int index) {
    // assert isElementIndex(index);

    if (index < (size >> 1)) {
        Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {
        Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}

ArrayList 和 LinkedList 的区别

  1. 数据结构不同。ArrayList 基于数组,LinkedList 基于双向链表。
  2. 随机数据访问效率不同。随机访问数据时,数组快于链表。因为数组能根据索引直接访问到每个数据;而双向链表,它通过前驱跟后继指针来维持数据与数据之间的逻辑关系,随机访问一个数据,LinkedList 都要从头还或者尾开始遍历,一直到遍历该索引的位置。
  3. 数据插入与删除效率不同。插入与删除,LinkedList 要快于 ArrayList。因为 LinkedList 插入或删除一个元素只涉及三个节点的指针变化;而 ArrayList 却要集体移动插入或删除位置后的所有元素的位置。
  4. 内存占用不同。因为 LinkedList 的元素除了数据域,还要额外维护两个指针域,它占用内存更大。

对于第 2、3 点,理论上是对的。实际上在如今设备环境下,他俩的性能差别不大。为什么呢?ArrayList 集体移动数据使用System.arraycopy方法(它是一个native方法,直接操作内存)将数组拷贝到新的位置,它的速度是很快的;同样的,LinkedList 随机访问一个数据使用的是二分查找,这大大降低了循环查找的次数,而循环的操作也仅仅是不停指向下一个节点,直到要查找的位置,不涉及什么耗时操作,速度也不会慢。

实际上,我也测试过一千万条数据(超过可能造成内存溢出)下两种集合操作耗时对比,几乎没有差别,因此实际使用时小数据量无需考虑两者性能问题,但是大数据量或者频繁操作的场景,仍然要依据他们各自的特性来选择使用哪种集合。

HashMap

HashMap 是基于哈希表的 Map 接口的实现,以键值对的形式存储。它的特点是:

  • 存取无序
  • 键和值都可以为null
  • 键是唯一的
  • 非线程安全
  • Java8的数据结构为:数组+链表+红黑树。HashMap 的数据载体是一个数组,数组中存放的是链表,当数组长度和阈值达到一定值时为把链表转成红黑树。这个红黑树的数据类型是TreeNode,它继承自链表Node,即数组中存放是的Node或者TreeNode

什么是哈希冲突?

哈希冲突是不同的 key 根据 hash 函数计算得到了相同的结果。在HashMap中,根据 key 的 hash 值计算数组的索引,如果该数组位置已经有值了,说明产生了哈希冲突,这个时候 value 会存入链表中,当链表中的值达到一定数量会将链表转换为红黑树。

那我有一个问题,哈希冲突的时候不是不同的Key计算得到了相同的数组索引,Value被存入一个链表或者红黑树吗,那这个Value又是怎么被取出来的呢?

要知道,外界访问链表,只能从头开始遍历,HashMap通过Key来获取一个Value的时候,就是从头节点开始,循环查找它的下一个节点,直到找到相同的节点返回。红黑树也是一个链式的数据结构,也是从根节点逐层往下查找,找到为止。

为什么引入红黑树?

JDK1. 8 以前HashMap的实现是数组+链表,即使哈希函数取得再好,也很难达到元素完全均匀分布。当HashMap中有大量的元素都存放在同一个桶中的时候,这个桶下有一条长长的链表,这个时候HashMap就相当于一个单链表,加入单链表有n个元素,遍历的时候复杂度就是O(n),完全失去了它的优势。针对这种情况,JDK1.8中引入了红黑树(查找时间复杂度为O(logn))来优化这个问题。当链表长度很小时,即使遍历,速度也很快,但是当链表长度不断变长,引入红黑树能提高查询效率。

为什么是红黑树而不是其他的树?

红黑树是一棵自平衡的二叉排序函树,它提高数据查找效率。为啥平衡树能提高数据查找效率?以平衡二叉树为例,平衡二叉树是一颗排序树(又称查找树),每新增一个节点,就要通过旋转节点来维持树的平衡,也就是树的有序性以及子树高度差的要求。一棵非平衡的二叉树,如果全是右孩子,那跟一个链表没有区别,查找一个数据就得从根开始遍历;如果是平衡的呢,只要跟父节点做一次比较,就能判断下次是走左子树还是右字树,节约至少一半时间。为什么不用其他的平衡树?这就要对比很多其他树的特点了,目前我只能这么回答:数据结构的选择最终都是时间与空间权衡的结果,就是这颗树维持平衡的代价和占用空间大小之间的权衡。

Key重写了 equals 方法,是否也要重写 hashCode 方法?

肯定是要的。HashMap 是这么判断 key 是否重复的:

// p是已有的链表节点, hash是key计算而来
p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))

只有key的哈希值相等、equals相等同时满足时,才会判断为两个key相同。如果HashMap的key只重写了equals方法,不重写hashCode,由此可能导致原本相同的key可能被HashMap判断为不同,导致put进重复的数据,或者get不出来的现象,这是违背HashMap的设计原则的。

HashMap 的初始化容量为什么要求是 2 的 n 次幂?有什么作用?如果不是会怎么样?

当我们根据key的hash函数来确定它在数组中的位置时,如果初始化容量为2的n次幂,可以保证数据均匀的插入,如果不是,会加大哈希冲突,可能造成数组的一些位置空闲,浪费数组空间。当然这些都是为了效率。
如果初始化容量不是2的n次幂,HashMap会把这个初始化容量转换为最接近的一个2的n次幂的数。方法为:

    /**
     * Returns a power of two size for the given target capacity.
     */
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

HashMap的装载因子是什么?

loadFactor加载因子,是衡量HashMap满的程度,表示HashMap的疏密程度,影响hash操作到同一个数组位置的概率。加载因子太大导致查找效率变低,太小导致数组利用变低,存放的数据很分散。官方给出默认加载因子是0.75。

HashMap 的并发问题

Java8 的HashMap在并发场景下可能出现值被覆盖的情况,具体原因可看put的源码:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 这里在多线程场景下会出现值被覆盖的情况
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else ...

可能线程 A 刚执行完(p = tab[i = (n - 1) & hash]) == null,CPU 又切换到线程 B 执行这行代码,导致tab[i] = newNode(hash, key, value, null)执行了两次,造成值覆盖。

简言之,在判断某个哈希桶是否为null时,在并发场景下会出现多个线程对这个条件的判断都成立,导致同一个哈希桶的值被其他线程覆盖。

ConcurrentHashMap

ConcurrentHashMap是一个线程安全的集合,功能和HashMap完全一致,在并发场景下,把HashMap替换为ConcurrentHashMap就好了。

ConcurrentHashMap 线程安全的原理

Java8 中ConcurrentHashMapHashMap的数据结构一样,都是 数组+链表+红黑树,Java8 摒弃了 Java7 中的分段锁设计,使用了CAS + synchronized实现了粒度更细的锁,可以这么理解:Node 数组的每个位置都加了一把锁。因为它锁住的是一个哈希桶,不会影响其他哈希桶的读写,提高了并发度。具体实现思路:

  1. 当塞入一个值的时候,先计算 key 的 hash 后的下标,如果计算到的下标还未有 Node ,那么就通过 cas 塞入新的 Node。
  2. 如果已经有 node 则通过 synchronized 将这个 node 上锁,这样别的线程就无法访问这个 node 及其之后的所有节点。
  3. 然后判断 key 是否相等,相等则是替换 value ,反之则是新增一个 node,这个操作和 HashMap 是一样的。

Java中的集合工具类Collections也提供了线程安全集合的转换方法,如Collections.synchronizedMap,它本质上是一个装饰器,对外提供同步方法,以此保证线程安全,这种性能比较差,一搬不推荐使用。

ConcurrentHashMap 的 key 和 value 为什么不能为空?

ConcurrentHashMapvalue不能为空的原因很好理解,在并发场景下,如果map.get(key) == null,我们无法判断key是不存在还是在get之前被其他线程put进了一个null值,这是一个歧义问题。

至于key为什么不能为null,网上有人就这个问题向ConcurrentHashMap的作者 Doug Lea 提问,他也没有给出具体原因,只说是集合设计时遗留的一个问题,并且他给出了建议,如果在使用ConcurrentHashMap集合时确实有keynull的情况,可以考虑把null值替换为一个空的Object对象。

这是这个问题的原文链接:这道面试题我真不知道面试官想要的回答是什么

ConcurrentHashMap 的 get 需要加锁吗?

不需要加锁。保证 put 的时候线程安全之后,get 的时候只需要保证可见性即可,而可见性不需要加锁。具体是通过Unsafe#getObjectVolatile和用 volatile 来修饰节点的 val 和 next 指针来实现的。

TreeMap

TreeMap是有序的,遍历TreeMap时元素会根据key值排序,默认是升序。它的排序是基于Comparable接口或者指定一个排序接口,因此使用TreeMap时,要么键对象要实现Comparable接口,要么个它传入一个Comparator接口的一个实现,否则使用就会报错。

LinkedHashMap

LinkedHashMap是有序的,它继承HashMap,在哈希桶的基础上增加了两个引用,一个before,一个after,且记录了头结点和尾节点,LinkedHashMap就是以此保证元素的插入顺序的。

具体是怎么实现的呢?

首先,结构上,LinkedHashMap 继承 HashMap, 它共用HashMap 的数据操作方法,LinkedHashMap 的内部类 Entry 继承了 HashMap 的内部类 Node。

其次,在 put 方法添加元素时,LinkedHashMap 重写了 HashMap 的 newNode 方法,使得在新增数据时数组的类型是 Entry,且在 Entry 中额外维护了两个指针,before 和 after。也就是说,HashMap 是一个 Node 数组,LinkedHashMap 则是 Entry 数组。

然后,他会处理元素之间的关系,新增元素都会把元素放到链表的结尾。并通过 before,after 维护元素之间的关系。

HashMap 内部新增了一系列的包访问级的方法供 LinkedHashMap 重写,来帮助 LinkedHashMap 维护它的特性。

HashSet

Set内部是基于Map实现的。

HashSet是基于HashMap实现的,它其实就是一个value为空Object对象的一个HashMap,因此他的特点是元素不可重复。

LinkedHashSet

有序的 Set,继承 HashSet,在构造器中创建的是LinkedHashMap

TreeSet

TreeSet也是基于TreeMap的,TreeMap有序,因此TreeSet也是有序的,并且在构造时需要制定一个排序接口或者TreeSet的元素要实现Comparable接口。

过时的 Vector 和 Hashtable

VectorHashtable都是在方法上添加 synchronized 保证线程安全,并发性能差。对应新的并发集合:CopyOnWriteArrayListConcurrentHashMap

CopyOnWriteArrayList

适用场景:

  • 读操作可以尽可能地快,而写即使慢一些也没有太大关系
  • 读多写少

CopyOnWrite 的含义:创建新副本,读写分离。读和写使用不同的容器,读的数据虽然实时性会差一些,但可以并发读取。
存在的问题:

  • 数据一致性问题:CopyOnWrite 容器只能保证数据的最终一致性,不能保证数据的实时一致性。如果希望写入的数据马上能读到,不要用 CopyOnWrite 容器。
  • 内存占用问题:因为 CopyOnWrite 的写是复制机制,所以在进行写操作时,内存里会同时驻扎两个对象的内存。

Queue

队列,它是一个接口。

Deque

双端队列,它也是一个接口,继承了Queue。他有两个经典实现,ArrayDequeLinkedList。双端队列是指队列的头部和尾部都可以同时入队和出队的数据结构。

PriorityQueue

优先级队列。它的一个典型应用就是任务调度。Java7和Java8有区别。优先队列是一种特殊的队列,它并不是先进先出的,而是优先级高的元素先出队。

posted @ 2023-04-20 07:17  yfhu  阅读(98)  评论(0编辑  收藏  举报