[集合]Java基础面试题(2)

107 阅读27分钟

1. Java有哪些集合

image-20230422200416686.png

Java 容器分为 Collection 和 Map 两大类,其下又有很多子类。Iterable 接口是 Collection 类集合的根接口。实现 Iterable 接口的类都可以使用增强 for 循环。

2. HashMap的数据结构

HashMap 的数据结构是什么样的?

在 Java 中,HashMap 是一种基于哈希表实现的 Map 集合,使用键值对的方式存储数据。在 Java 7,其数据结构由一个数组和一个链表组成。在 Java 8,它由一个数组、一个链表或者红黑树组成。数组中的每个元素都是一个链表的头节点,每个节点存储一个键值对。在向 HashMap 中添加键值对时,首先根据键的 hashCode 值计算出数组中的位置,然后将该键值对添加到对应链表的尾部。

当链表中节点的数量达到一定的阈值(默认为 8)时,且数组长度大于或等于 64 时,链表会将转化为红黑树,以提高查找效率。如果节点数较少(小于等于 6),红黑树将会转化为链表,以节省空间。

在进行查询操作时,HashMap 首先根据键的 hashCode 值计算出数组中的位置,然后遍历对应链表(或红黑树)中的所有节点,比较键的值,直到找到目标节点或遍历完整个链表(或红黑树)。由于哈希表的特性,查询操作的时间复杂度为 O(1)O(1)O(logn)O(log n)

需要注意的是,HashMap 并不是线程安全的,如果在多线程环境下使用,应该采取相应的同步措施或使用线程安全的 Map 实现。

HashMap 内部链表实现代码:

/**
 * Basic hash bin node, used for most entries.  (See below for
 * TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
 */
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;

    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }

    public final K getKey()        { return key; }
    public final V getValue()      { return value; }
    public final String toString() { return key + "=" + value; }

    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }

    public final boolean equals(Object o) {
        if (o == this)
            return true;
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            if (Objects.equals(key, e.getKey()) &&
                Objects.equals(value, e.getValue()))
                return true;
        }
        return false;
    }
}

3. HashMap的put方法

说一下 HashMap 调用 put 方法插入元素过程?

当调用 HashMap 的 put 方法插入元素时,会经历以下步骤:

  1. 首先,根据插入元素的键值对的键的 hashCode 值,计算其在数组中的位置(即桶),也就是调用 hash 方法将键的 hashCode 值进行哈希扰动函数计算,并将结果对数组长度取模。
  2. 如果在对应的桶中已经存在元素,就遍历该桶中的元素,查找是否已经存在相同的键。如果找到了相同的键,就将其对应的值更新为新的值,并返回旧的值;如果没有找到相同的键,就将新的键值对添加到链表(或红黑树)的尾部。
  3. 在添加完新的键值对后,如果链表的长度达到了一定的阈值(默认为 8),就需要判断是否需要将链表转化为红黑树。如果需要,就将该链表转化为红黑树。
  4. 最后,如果添加元素后 HashMap 的大小达到了阈值(数组长度 * loadFactor),就需要对 HashMap 进行扩容操作。具体操作为:创建一个新的数组,将原数组中的元素重新计算哈希值,然后放到新数组中对应的位置。这个过程需要遍历原数组中的所有元素,因此时间开销较大。

需要注意的是,由于哈希冲突的存在,可能会出现多个键的 hashCode 值相等,但它们的键值对实际上存储在不同的桶中的情况。因此,在查找元素时,需要先根据键的 hashCode 值找到对应的桶,然后遍历该桶中的元素,找到对应的键值对。

/**
 * Associates the specified value with the specified key in this map.
 * If the map previously contained a mapping for the key, the old
 * value is replaced.
 *
 * @param key key with which the specified value is to be associated
 * @param value value to be associated with the specified key
 * @return the previous value associated with <tt>key</tt>, or
 *         <tt>null</tt> if there was no mapping for <tt>key</tt>.
 *         (A <tt>null</tt> return can also indicate that the map
 *         previously associated <tt>null</tt> with <tt>key</tt>.)
 */
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

/**
 * Implements Map.put and related methods.
 *
 * @param hash hash for key
 * @param key the key
 * @param value the value to put
 * @param onlyIfAbsent if true, don't change existing value
 * @param evict if false, the table is in creation mode.
 * @return previous value, or null if none
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    
    // 如果table为空或者还没有元素就扩容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 如果首节点为空则创建一个新的节点
    // (n - 1) & hash 才是真正的hash值,也就是存储在的数组的索引,Java 6中是使用indexFor方法
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {  // 产生了hash冲突,处理hash冲突
        Node<K,V> e; K k;
        
        // 如果在首节点和需要插入的节点有相同的hash和key值,用一个Node类变量保存下来
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 如果首节点是红黑树节点类型,则按照红黑树节点的方法添加元素
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);、
        // 说明是链表节点类型    
        else {
            for (int binCount = 0; ; ++binCount) {
                // 遍历到了链表末尾
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 遍历的数目为大于等于8时,binCount从0开始所以是 >= 7,TREEIFY_THRESHOLD = 8
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 如果找到与待插入的元素具有相同的hash和key值的节点,则停止遍历,此时遍历e已经记录了该节点
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        
        // 表示存在相同的元素
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            // 这是钩子函数,由用户定义在节点存在重复的情况的钩子函数
            afterNodeAccess(e);
            return oldValue;
        }
    }
    
    ++modCount;
    // 当节点数大于阈值则扩容
    if (++size > threshold)
        resize();
    // 用户自定义在节点插入之后的钩子函数
    afterNodeInsertion(evict);
    return null;
}

4. HashMap的hash方法

HashMap 如何计算元素 Key 的 hash 值?

HashMap 通过 hash 方法计算元素 Key 的 hash 值:

/**
 * Computes key.hashCode() and spreads (XORs) higher bits of hash
 * to lower.  Because the table uses power-of-two masking, sets of
 * hashes that vary only in bits above the current mask will
 * always collide. (Among known examples are sets of Float keys
 * holding consecutive whole numbers in small tables.)  So we
 * apply a transform that spreads the impact of higher bits
 * downward. There is a tradeoff between speed, utility, and
 * quality of bit-spreading. Because many common sets of hashes
 * are already reasonably distributed (so don't benefit from
 * spreading), and because we use trees to handle large sets of
 * collisions in bins, we just XOR some shifted bits in the
 * cheapest possible way to reduce systematic lossage, as well as
 * to incorporate impact of the highest bits that would otherwise
 * never be used in index calculations because of table bounds.
 */
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • 当 key == null:hash 值为 0,所以 HashMap 的 Key 可以为 null。

    对比 HashTable,Hashtable 对 key 直接调用 hashCode 方法计算 hash 值,若 key 为 null 时,会抛出异常,所以 Hashtable的 key 不可为 null。

// Hashtable的put方法
public synchronized V put(K key, V value) {
    // Make sure the value is not null
    if (value == null) {
        throw new NullPointerException();
    }

    // Makes sure the key is not already in the hashtable.
    Entry<?,?> tab[] = table;
    int hash = key.hashCode();
    int index = (hash & 0x7FFFFFFF) % tab.length;
    @SuppressWarnings("unchecked")
    Entry<K,V> entry = (Entry<K,V>)tab[index];
    for(; entry != null ; entry = entry.next) {
        if ((entry.hash == hash) && entry.key.equals(key)) {
            V old = entry.value;
            entry.value = value;
            return old;
        }
    }

    addEntry(hash, key, value, index);
    return null;
}
  • 当 key != null,则先调用 hashCode 方法返回值记为 h,然后对哈希码进行扰动处理: 将哈希码自身右移 16 位后的二进制数按位异或 ^ 原哈希码得到最终的 hash 值。

5. 如何计算插入元素的位置

HashMap 插入一条元素如何计算插入元素的位置?

通过元素 Key 的 hash 值 & n - 1(数组长度为 n)计算插入元素的索引。

// (n - 1) & hash 才是真正的hash值,也就是存储在的数组的索引,Java 6中是使用indexFor方法
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);

6. 右移16位异或计算

为什么计算 hash 值要右移 16 位然后异或原哈希码?

h >>> 16 是用来取出 h 的高 16 位,也就是高半位:

h=00000100101100111101111111100001h>>>16h=00000000000000000000010010110011h = 0000 \, 0100 \, 1011 \, 0011 \, 1101 \, 1111 \, 1110 \, 0001 \\ h >>> 16 \\ h = 0000 \, 0000 \, 0000 \, 0000 \, 0000 \, 0100 \, 1011 \, 0011 \\

由于和最终和(n - 1)& 运算,length 绝大多数情况小于 2 的 16 次方。所以始终是 hashCode 的低 16 位(甚至更低)参与运算。要是高 16 位也参与运算,会让得到的下标更加散列。所以这样高 16 位是用不到的,如何让高16 也参与运算呢。所以才有 hash 方法。让元素 Key 的 hashCode() 返回值和自己的高 16 位进行 ^ 运算。所以(h >>> 16)得到高 16 位与 hashCode() 进行 ^ 运算。

为了方便验证,假设 length 为 8。HashMap 的默认初始容量为 16。(length - 1)= 7; 转换二进制为 111;假设一个 Key 的 hashcode = 78897121。转换二进制: 100101100111101111111100001,与(length-1)& 运算如下:

00000100101100111101111111100001&00000000000000000000000000000111=000000000000000000000000000000010000 \, 0100 \, 1011 \, 0011 \, 1101 \, 1111 \, 1110 \, 0001 \\ \& \\ 0000 \, 0000 \, 0000 \, 0000 \, 0000 \, 0000 \, 0000 \, 0111 \\ = \\ 0000 \, 0000 \, 0000 \, 0000 \, 0000 \, 0000 \, 0000 \, 0001 \\

上述运算实质是:001 与 111 & 运算。也就是哈希值的低三位与 length 与运算。如果让哈希值的低三位更加随机,那么 & 结果就更加随机,如何让哈希值的低三位更加随机,那么就是让其与高位异或。右位移 16 位,正好是 32 位的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。

7. 为什么用异或计算

为什么要用异或计算 hash 扰动函数的值,而不用 & 和 | 呢?

假设均匀随机(1位)输入,AND 函数输出概率分布分别为 75% 0 和 25% 1。相反,OR 为 25% 0 和 75% 1。 XOR 函数为 50% 0和 50% 1,因此对于合并均匀的概率分布非常有用。

ABA&B000010100111\begin{array}{cc|c} A & B & A \& B \\ \hline 0 & 0 & 0 \\ 0 & 1 & 0 \\ 1 & 0 & 0 \\ 1 & 1 & 1 \\ \end{array}
ABAB000011101111\begin{array}{cc|c} A & B & A | B \\ \hline 0 & 0 & 0 \\ 0 & 1 & 1 \\ 1 & 0 & 1 \\ 1 & 1 & 1 \\ \end{array}
ABAB000011101110\begin{array}{cc|c} A & B & A \oplus B \\ \hline 0 & 0 & 0 \\ 0 & 1 & 1 \\ 1 & 0 & 1 \\ 1 & 1 & 0 \\ \end{array}

8. 为什么要引入红黑树

由于在 Java 7 之前,HashMap 的数据结构为:数组 + 链表。

链表来存储 hash 值一样的 key-value。如果按照链表的方式存储,随着节点的增加数据会越来越多,这会导致查询节点的时间复杂度会逐渐增加,平均时间复杂 度 O(n)。 为了提高查询效率,故在 Java 8 中引入了改进方法红黑树。此数据结构的平均查询效率为 (logn)O(log\,n)

9. 链表转化为红黑树

为什么 Java 8 以后,HashMap 在链表长度大于或等于 8 的时候要变成红黑树?

在 Java 8 以及以后的版本中,HashMap 的底层结构,由原来单纯的的数组加链表,更改为链表长度为 8 时,开始由链表转换为红黑树,我们都知道,链表查询元素的时间复杂度是 O(n)O(n),红黑树的时间复杂度 O(logn)O(log\,n),很显然,红黑树的时间复杂度是优于链表的。因为树节点所占空间是普通节点的两倍,所以只有当节点足够多的时候,才会使用树节点。也就是说,节点少的时候,尽管时间复杂度上,红黑树比链表好一点,但是红黑树所占空间比较大,综合考虑,认为只能在节点太多的时候,红黑树占空间大这一劣势不太明显的时候,才会舍弃链表,使用红黑树,这也是不直接全部使用红黑树的原因。

那为什么要选择阈值为 8 链表转换成红黑树呢?

在理想状态下,受随机分布的 hashCode 影响,链表中的节点遵循泊松分布,而且根据统计,链表中节点数是 8 的概率已经接近千分之一,而且此时链表的性能已经很差了。所以在这种比较罕见和极端的情况下,才会把链表转变为红黑树。因为链表转换为红黑树也是需要消耗性能的,特殊情况特殊处理,为了照顾性能,权衡之下,才使用红黑树,提高性能。

那什么又是泊松分布呢?

泊松分布的概率函数为:

P(X=k)=λkk!eλ,k=0,1,...P(X=k)=\frac{\lambda^k}{k!}e^{-\lambda},k=0,1,...

泊松分布的参数 λ\lambda 是单位时间或单位面积内随机事件的平均发生次数。泊松分布适合于描述单位时间内随机事件发生的次数。泊松分布的期望和方差均为 λ\lambda

特征函数为:

ψ(t)=eλ(eit1)\psi(t)=e^{\lambda(e^{it}-1)}

如果链表节点数大于 8,就一定会转换为红黑树吗?

HashMap 的 treeifyBin 方法:

/**
 * Replaces all linked nodes in bin at index for given hash unless
 * table is too small, in which case resizes instead.
 */
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 先判断table长度是否小于 MIN_TREEIFY_CAPACITY = 64
    // 小于64就扩容,否则就转换为红黑树
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

可以看到在 treeifyBin 方法中并不是简单地将链表转换为红黑树,而 是先判断table的长度是否大于 64,如果小于 64,就通过扩容 的方式来解决,避免转换为红黑树。

为什么要使用红黑树而不使用 AVL 树?

AVL 树和红黑树有几点比较和区别:

  • AVL 树是更加严格的平衡,因此可以提供更快的查找速度,一般查找密集型任务适用 AVL 树。
  • 红黑树更适合于插入修改密集型任务。
  • 通常,AVL 树的旋转比红黑树的旋转更加难以平衡和调试。
  • AVL 树以及红黑树是高度平衡的树数据结构。它们非常相似,真正的区别在于在任何添加/删除操作时完成的旋转操作次数。
  • 两种实现时间复杂度都为 O(logn)O(log\,n),其中 n 是叶子的数量,但实际上 AVL 树在查找密集型任务上更快:因为利用更好的平衡,树遍历平均更短。另一方面,插入和删除方面,AVL 树速度较慢,需要更多的旋转次数才能在修改时正确地重新平衡二叉树。
  • 在 AVL 树中,从根到任何叶子的最短路径和最长路径之间的差异最多为 1。在红黑树中,差异可以是 2 倍。
  • 两个都是 O(logn)O(log\,n) 时间复杂度查找,但平衡 AVL 树可能需要 O(logn)O(log\,n) 旋转,而红黑树将需要最多两次旋转使其达到平衡(尽管可能需要检查 O(logn)O(log\,n) 节点以确定旋转的位置)。旋转本身是 O(1)O(1) 操作,因为只是移动指针。

10. 计算元素的下标

为什么 HashMap 不直接使用 hashCode 方法的返回值处理过后的结果作为数组的下标?

hashCode 方法返回的是 int 整数类型,其范围为 2312311-2^{31} \sim 2^{31}-1,约有 40 亿个映射空间,而HashMap 的容量范围是在 1616(初始化默认值)~ 2302^{30},HashMap 容量通常情况下是取不到最大值的,并且本地内存也难以提供这么大的存储空间,从而导致通过 hashCode 方法计算出的哈希值可能不在数组大小范围内,进而无法匹配存储位置。

所以要对哈希码右移 16 位然后异或原哈希码得到最终哈希值。

为什么 HashMap 的数组长度要保证为 2 的整数次幂呢?

  • 只有当数组长度为 2 的整数次幂时,h & (length - 1) 才等价于 h % length,即实现了元素 key 的定位,2 的整数次幂也可以减少冲突次数,而且位运算更快,可以提高 HashMap 的查询效率。
  • 如果 length 为 2 的整数次幂,则 length - 1 转化为二进制必定是 11111…… 的形式,在与 h 的二进制位与操作时效率会非常的快,而且空间不浪费。如果 length 不是 2 的整数次幂,比如 length 为 15,则 length - 1 为 14,对应的二进制为 1110,在与 h 与操作,最后一位都为 0,而 0001,0011,0101,1001,1011,0111,1101 这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!这样就会造成空 间的浪费。

11. Java 7和 Java 8的HashMap

Java 7 和 Java 8 的 HashMap 数据结构有何异同?

Java 7:

  • HashMap 底层是数组加链表的形式。
  • 数组的默认长度为 16,负载因子为 0.75,也就是 16 * 0.75 = 12(阈值)。当计算出元素的位置在数组中 冲突时,那么会以链表的形式存储新的元素,新的元素插在链表的头部,然后将链表下移,也就是将数组中的值赋值给新来的元素。
  • 当数组中 12 个位置被占据时(也就是达到了阈值),同时新插入的元素的插入位置存在元素,就会进行 2 倍扩容。
  • 并发环境下会产生死锁。

Java 8:

  • HashMap 底层是数组加链表加红黑树。
  • 数组的默认长度为 16,加载因子为 0.75,也就是 16 * 0.75 = 12(阈值)。当计算出元素在数组中的位置相同时,则生成链表,并将新的元素插入到尾部 (主要是为了红黑树问题),假如链表上元素超过了 8 个,那么链表将被改为红黑树,同时也提高了增删查效率。
  • 当数组元素个数达到了阈值,那么此时不需要判断新的元素的位置是否为空,数组都会扩容,2 倍扩容。
  • 并发环境下不会产生死锁。

两个版本的 HashMap 扩容机制有何异同?

Java 7 中整个扩容过程就是一个取出数组元素(实际数组索引位置上的每个元素是每个独立单向链表的头部,也就是发生哈希冲突后最后放入的冲突元素)然后遍历以该元素为头的单链表元素,依据每个被遍历元素的 hash 值计算其在新数组中的下标然后进行交换,即原来哈希冲突的单向链表尾部变成了扩容后单向链表的头部。

Java 8 中 HashMap 的扩容操作有些不一样, 由于扩容数组的长度是 2 倍关系,所以对于假设初始 tableSize = 4 要扩容到 8 来说就是 0100 到 1000 的变化 (左移一位就是 2 倍),在扩容中只用判断原来的 hash 值与左移动的一位(newtable 的长度)按位与操作是 0 或 1 就行,0 的话索引就不变,是 1 的话索引变成原索引加上扩容前数组。

12. 主要成员变量

列举几个 HashMap 重要的成员变量?

  • transient Node[] table:这是一个 Node 类型的数组(也有称作 Hash 桶),可以从下面源码中看 到静态内部类 Node 在这边可以看做就是一个节点,多个 Node 节点构成链表,当链表长度大于 8 的时候并且 table 长度大于或等于 64 的时候转换为红黑树。
  • transient int size:表示当前 HashMap 包含的键值对数量。
  • transient int modCount:表示当前HashMap修改次数。
  • int threshold:表示当前HashMap能够承受的最多的键值对数量,一旦超过这个数量HashMap就会进行扩 容。
  • final float loadFactor:负载因子,用于扩容。
  • static final int DEFAULT_INITIAL_CAPACITY = 1 << 4:默认的 table 初始容量。
  • static final float DEFAULT_LOAD_FACTOR = 0.75f:默认的负载因子。
  • static final int TREEIFY_THRESHOLD = 8: 链表长度大于或等于该参数转红黑树。
  • static final int UNTREEIFY_THRESHOLD = 6: 当树的节点数小于或等于该参数转成链表。

13. 扩容 resize 方法源代码解析

/**
 * Initializes or doubles table size.  If null, allocates in
 * accord with initial capacity target held in field threshold.
 * Otherwise, because we are using power-of-two expansion, the
 * elements from each bin must either stay at same index, or move
 * with a power of two offset in the new table.
 *
 * @return the table
 */
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    
    // 记录扩容前的数组长度和最大容量
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        // 超过最大容量,阈值变为整型最大值
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        // 长度和容量都二倍扩容
        } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1;  // double threshold
    } else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    // 更新新的最大容量位扩容计算后的最大容量
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    
    // 更新扩容后的新数组长度
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        // 遍历旧数组的下标
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            // 如果旧数组对应索引有元素则取出放在e中
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                // 如果旧数组对应索引位置只有一个元素则直接计算新的索引位置
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 如果是树节点那么单独处理
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 说明e有后继结点,链表长度大于1
                else { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 循环处理索引j位置上哈希冲突的链表中的每个元素
                    do {
                        next = e.next;
                        // 判断key的hash值与旧数组长度操作后结果决定是放在原索引处还是新索引处
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        } else {
                            // 放在信索引位置并建立新的链表
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        // 放入原索引处
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        // 放入新索引处
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

Java 7 中扩容操作时,哈希冲突的数组索引处的旧链表元素扩容到新数组时,如果扩容后索引位置在新数组的索引位置与原数组中索引位置相同,则链表元素会发生倒置。而在 Java 8 中不会出现链表倒置现象。 其次,由于 Java 7 中发生哈希冲突时仅仅采用了链表结构存储冲突元素,所以扩容时仅仅是重新计算其存储位置而已,而 Java 8 中为了性能在同一索引处发生哈希冲突到一定程度时链表结构会转换为红黑数结构存储冲突元素,故在扩容时如果当前索引中元素结构是红黑树且元素个数小于链表还原阈值(哈希冲突程度常量)时就会把树形结构缩小或直接还原为链表结构。

14. 红黑树扩容split方法源代码解析

/**
 * Splits nodes in a tree bin into lower and upper tree bins,
 * or untreeifies if now too small. Called only from resize;
 * see above discussion about split bits and indices.
 *
 * @param map the map
 * @param tab the table for recording bin heads
 * @param index the index of the table being split
 * @param bit the bit of hash to split on
 */
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
    // 拿到调用此方法的节点
    TreeNode<K,V> b = this;
    // Relink into lo and hi lists, preserving order
    TreeNode<K,V> loHead = null, loTail = null;
    TreeNode<K,V> hiHead = null, hiTail = null;
    int lc = 0, hc = 0;
    // 从调用此方法的节点开始,遍历整个红黑树节点
    for (TreeNode<K,V> e = b, next; e != null; e = next) {
        // next赋值为e的下个节点
        next = (TreeNode<K,V>)e.next;
        // 同时将旧表的节点设置为空便于垃圾回收
        e.next = null;
        // 如果e的hash值与旧表的容量与运算为0,则扩容后的索引位置和旧表的索引位置一样
        if ((e.hash & bit) == 0) {
            // 表示该节点为第一个节点
            if ((e.prev = loTail) == null)
                loHead = e;
            else
                loTail.next = e;
            loTail = e;
            ++lc;
        // 如果e的hash值与旧表的容量与运算为1,则扩容后的索引位置为旧表的索引位置+oldCap
        } else {
            if ((e.prev = hiTail) == null)
                hiHead = e;
            else
                hiTail.next = e;
            hiTail = e;
            ++hc;
        }
    }
    // 如果原索引位置的节点不为空
    if (loHead != null) {
        // 节点数小于或等于6转换为链表
        if (lc <= UNTREEIFY_THRESHOLD)
            tab[index] = loHead.untreeify(map);
        else {
            // 将索引位置的节点设置为头结点
            tab[index] = loHead;
            if (hiHead != null) // (else is already treeified)
                loHead.treeify(tab);
        }
    }
    // 如果原索引位置+oldCap位置的节点不为空
    if (hiHead != null) {
        if (hc <= UNTREEIFY_THRESHOLD)
            tab[index + bit] = hiHead.untreeify(map);
        else {
            tab[index + bit] = hiHead;
            if (loHead != null)
                hiHead.treeify(tab);
        }
    }
}

15. String适合作为Key的原因

为什么 String 类型更适合作为 HashMap 的 Key?

String 类型的对象对这个条件有着很好的支持,因为 String 对象的 hashCode 值是根据 String 对象的 内容计算的,并不是根据对象的地址计算。下面是 String 类源码中的 hashCode() 方法:String 对象底层是一个 final 修饰的 char 类型的数组,hashCode() 的计算是根据字符数组的每个元素进行计算的,所以内容相同的 String 对象会产生相同的散列码,相较于其他对象速度更快。

public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;
        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

如何使用自定义对象作为 HashMap 的 Key?

重写 equals 方法和 hashCode 方法即可。

16. 哈希冲突

什么是哈希,什么又是哈希冲突?

Hash,一般翻译为“散列”,也有直接音译为“哈希”的,这就是把任意长度的输入通过散列算法,变换成固定长度的输出,该输出就是散列值(哈希值);这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。所有散列函数都有如下一个基本特性:根据同一散列函数计算出的散列值如果不同,那么输入值肯定也不同。但是,根据同一散列函数计算出的散列值如果相同(哈希冲突情况),输入值不一定相同。

哈希冲突是指不同的键值对在哈希表中映射到相同的散列值。

如何处理哈希冲突?

  • 拉链法(Chaining):将哈希表中每个槽都设置为一个链表头,如果出现哈希冲突,则将新的键值对插入到相应的链表中。

  • 开放地址法(Open Addressing):在哈希表中,当某个槽被占用时,会尝试在其他槽中寻找空槽来存放该键值对。常见的开放地址法包括线性探测、二次探测、双重哈希等方法。缺点:容易产生堆积问题;不适于大规模的数据存储。

    • 线性探测:按顺序决定哈希值时,如果某数据的哈希值已经存在,则在原来哈希值的基础上往后加一个 单位,直至不发生哈希冲突。
    • 再平方探测 按顺序决定哈希值时,如果某数据的哈希值已经存在,则在原来哈希值的基础上先加1的平方 个单位,若仍然存在则减1的平方个单位。随之是 2 的平方,3 的平方等等。直至不发生哈希冲突。
    • 伪随机探测 按顺序决定哈希值时,如果某数据已经存在,通过随机函数随机生成一个数,在原来哈希值 的基础上加上随机数,直至不发生哈希冲突。
  • 再哈希法(Rehashing):当哈希表中出现冲突时,通过再次进行哈希计算,得到一个新的散列值,直到不发生哈希冲突,并将该键值对插入到对应的槽中。

  • 建立公共溢出区(Overflow Area):当哈希表中出现冲突时,将键值对存放到一个公共溢出区中,通过额外的信息来确定该键值对的存放位置。

  • 负载因子调整:通过调整哈希表的负载因子来降低冲突的概率。负载因子是指哈希表中键值对的数量与槽数量的比值,通常情况下,负载因子的合适范围为 0.7 到 0.8。

17. HashMap 和 Hashtable

HashMap 和 Hashtable 有什么区别?

  • 继承的父类不同:Hashtable 继承自 Dictionary 类,而 HashMap 继承自 AbstractMap 类。但二者都实现了 Map 接口。
  • 线程安全性不同:Hashtable 中的方法是同步的,而 HashMap 中的方法在缺省情况下是非同步的。在多线程并发的环境下,可以直接使用 Hashtable,不需要自己为它的方法实现同步,但使用 HashMap 时就必须要自己增加同步处理。(结构上的修改是指添加或删除一个或多个映射关系的任 何操作;仅改变与实例已经包含的键关联的值不是结构上的修改。)这一般通过对自然封装该映射 的对象进行同步操作来完成。如果不存在这样的对象,则应该使用 Collections.synchronizedMap 方法来“包装”该映射。最好在创建时完成这一操作,以防止对映射进行意外的非同步访问。
  • 是否提供 contains 方法:HashMap 把 Hashtable 的 contains 方法去掉了,改成 containsValue 和 containsKey,因为 contains 方法容易让人引起误解。 Hashtable 则保留了contains,containsValue 和 containsKey 三个方法,其中 contains 和 containsValue 功能相同。
  • key 和 value 是否允许 null 值:Hashtable 中,key 和 value 都不允许出现 null 值。但是如果在 Hashtable 中有类似 put(null,null)的操作,编译同样可以通过,因为 key 和 value 都是 Object类型,但运行时会抛出 NullPointerException 异常,这是 JDK 的规范规定的。 HashMap 中,null 可以作为键,这样的键只有一个;可以有一个或多个键所对应的值为 null。当 get 方法返回 null 值时,可能是 HashMap 中没有该键,也可能使该键所对应的值为 null。因此,在 HashMap 中不能由 get()方法来判断HashMap 中是否存在某个键, 而应该用 containsKey 方法。
  • 两个遍历方式的内部实现上不同:Hashtable、HashMap 都使用了 Iterator。而由于历史原因,Hashtable 还使用了 Enumeration 的方式。
  • hash 值计算方式不同:哈希值的计算方式不同,Hashtable 直接使用对象的 hashCode 方法返回值。而 HashMap通过哈希扰动函数计算 hash 值。
  • 内部实现使用的数组初始化和扩容方式不同:Hashtable 在不指定容量的情况下的默认容量为 11,而HashMap 为 16,Hashtable 不要求底层数组的容量一定要为 2 的整数次幂,而 HashMap 则要求一定为2的整数次幂。 Hashtable 扩容时,将容量变为原来的 2 倍加 1,而 HashMap 扩容时,将容量变为原来的 2 倍。 Hashtable 和 HashMap 它们两个内部实现方式的数组的初始大小和扩容的方式。

为什么 Hashtable 扩容之后容量变为原来的 2 倍加 1?

首先,Hashtable 的初始容量为 11。index的计算方式为:int index = (hash & 0x7FFFFFFF) 常用的hash 函数是选一个数 m 取模(余数),这个数在课本中推荐 m 是素数,但是经常见到选择 m=2km = 2^k,因为对 2k2^k 求余数更快,并认为在 key 分布均匀的情况下,key % m 也是在 [0,m1][0,m-1] 区间均匀分布的。但实际上,key % m 的分布同 m 是有关的。

证明如下:key % m = key - xm,即 key 减掉 m 的某个倍数 x,剩下比 m 小的部分就是 key 除以 m 的余数。 显然,x 等于 key / m 的整数部分,以 floor(key/m) 表示。假设 key 和 m 有公约数 g,即

key=agm=bgkeyxm=keyfloor(key/m)m=keyfloor(a/b)mkey = ag \\ m = bg \\ key - xm = key - floor(key/m) \\ m = key - floor(a/b)m

由于 0aba0 \le \frac{a}{b} \le a,所以 floor(a/b)floor(a/b) 只有 a + 1 种取值可能,从而推导出 key % m 也只有 a + 1 中取值可能。a + 1 个球放在 m 个盒子里面,显然不可能做到均匀。 由此可知,一组均匀分布的key,其中同 m 公约数为 1 的那部分,余数后在 [0,m1][0,m-1] 上还是均匀分布的, 但同 m 公约数不为 1 的那部分,余数在 [0,m1][0, m-1] 上就不是均匀分布的了。把 m 选为素数,正是为了让所有 key 同 m 的公约数都为 1,从而保证余数的均匀分布,降低冲突率。 鉴于此,在 Hashtable中,初始化容量是 11,是个素数,后面扩容时也是按照 2N+12N + 1 的方式进行扩容, 确保扩容之后仍是素数。

18. 负载因子

负载因子为什么会影响 HashMap 的性能?

我们都知道有序数组存储数据,对数据的索引效率很高,但是插入和删除就会有性能瓶颈,链表存储数据,要一次比较元素来检索出数据,所以索引效率低,但是插入和删除效率高,两者取长补短就产生了哈希散列这种存储方式,也就是 HashMap 的存储逻辑。而负载因子表示一个散列表的空间的使用程度,有这样一个公式:

initailCapacityloadFactor=thresholdinitailCapacity * loadFactor \, = \, threshold

所以负载因子越大则散列表的装填程度越高,也就是能容纳更多的元素。元素多了,链表长度越长,所以此时索引效率就会降低。反之,负载因子越小则链表中的数据量就越小,此时会对空间造成浪费,但是此时索引效率高。