Java 8 HashMap 底层原理

HashMap 数据结构

在 Java 中,HashMap 的数据结构被称为“链表散列”,这是一种基于数组链表的数据结构。它通过将哈希函数的结果映射到数组的索引来存储键值对,同时使用链表来解决哈希冲突。其中:

  • 数组具有较快的查询速度,但插入和删除操作的性能相对较差;
  • 链表的查询性能较差,但插入和删除操作的性能相对较高。

这种方式使得 HashMap 结合了数组和链表(或红黑树)的优点,从而提供高效的键值对存储和查找功能。

JDK 1.8 对 HashMap 数据结构又做了优化,当链表长度超过阈值时,会将链表转换为红黑树进行处理,红黑树相对于链表具有更快的查询性能,特别适用于高冲突场景下的大容量哈希表。示意图如下:

哈希表示意图

哈希表数组与哈希桶

HashMap 中,哈希表是以数组形式存在的。具体来说,HashMap 内部声明了一个 transient Node<K,V>[] table 数组,每个数组元素即一个哈希桶 (Bucket)

哈希表数组的容量 $n$ 始终是 2 的幂次方 ($n = 2^k$),以便支持位运算,$k$ 是一个非负整数。

哈希桶 Node 节点

在哈希冲突不严重时,键值对的存储和查找是通过内部类 Node 来实现的。每个 Node 对象都包含了一个键 (Key)、一个值 (Value),以及指向下一个 Node 对象的指针。当哈希冲突发生时,HashMap 会将键值对存储在相应的 Node 对象中,并通过链表来组织这些 Node 对象。以下是 Node 类的部分源码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
static class Node<K,V> implements Map.Entry<K,V> {
    // 哈希码值
    final int hash;
    // 存储 key,不可变
    final K key;
    // 存储 value
    V value;
    // 链表指针
    Node<K,V> next;
    
    //...
}

哈希桶 TreeNode

当某个桶内哈希冲突比较严重时,桶内的 Node 链表会被转化为以内部类 TreeNode 为节点的红黑树,TreeNode 部分源码如下:

1
2
3
4
5
6
7
8
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // 根节点
    TreeNode<K,V> left;    // 左子节点
    TreeNode<K,V> right;   // 右子节点
    TreeNode<K,V> prev;    // 用于删除时断开下一个连接
    boolean red;
    //...
}

Java HashMap 特点

  1. 散列存储HashMap 使用散列存储方式,通过哈希函数计算键 (Key) 的哈希值,将其映射到一个桶,然后将键值对存储在这个桶中。这样可以实现快速的键值对查找,时间复杂度为 $O(1)$。

  2. 键和值都可以为 nullHashMap 中的键和值都可以为 null

    需要注意的是,如果键为 null,它的哈希值为 0。因此所有的 null 键都会被存储在同一个桶中,从而降低 HashMap 的性能。在使用时最好避免使用 null 作为键。

  3. 非线程安全HashMap 不是线程安全的,因此在多线程环境中使用时需要进行外部同步,或者使用 ConcurrentHashMap

  4. 不保证键值对的顺序HashMap 不保证键值对的顺序,即使键值对按照一定的顺序插入,也不一定会按照相同的顺序遍历它们。

  5. 初始容量和负载因子HashMap 可以指定初始容量和负载因子来优化性能。初始容量指的是哈希表的容量,负载因子指的是哈希表在达到多少填充程度时会自动扩容。

HashMap#get 原理

HashMap#get 源码如下:

1
2
3
4
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

其中的核心实现为 HashMap#getNode

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // [1] 非空判断
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // [2] 判断桶内第一个元素是否是要查询的元素
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 下一个节点非空判断
        if ((e = first.next) != null) {
            // 如果第一节点是树结构,则使用 getTreeNode 直接获取相应的数据
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do { // 非树结构,循环遍历链表节点
                if (e.hash == hash && // hash 相等并且 key 相同,则返回此节点
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    // [3] 找不到匹配项,返回空
    return null;
}
  • [1]:该方法首先判断 table 是否为空,如果 table 不为空,那么方法会通过计算 tab[(n - 1) & hash] 找到相应的桶,并取出桶中的第一个节点 first
  • [2]:接着,方法会先检查 first 是否就是要查找的节点。
    • 如果是,直接返回 first
    • 如果不是,那么方法会遍历链表或红黑树上的所有节点,查找与 key 相等的节点,直到找到或遍历完所有节点。
  • [3]:如果找到了与 key 相等的节点,则返回该节点;否则,返回 null

哈希表的底层实现是一个大小为 2 的次幂的数组。因此在调用 get 方法时,将 Key 的哈希码值转换为数组的下标是非常关键的一步。通过查看 HashMap#getNode 方法的源码,我们可以发现,该方法计算数组下标的算法为 (n - 1) & hash,这个位运算等价于取模运算 hash % n,但性能却远超取模运算。

为什么 (n - 1) & hash 等价于 hash % n

(n - 1) & hashhash % n 等价的原因在于哈希表的长度 $n$ 是 2 的幂次方,即 $n = 2^k$,因此 n - 1 的二进制表示中所有位都是 1,例如当 n = 16 时,n - 1 的二进制表示为 1111

在这种情况下,(n - 1) & hash 实际上相当于将哈希值的二进制表示的低 k 位与 n - 1 进行按位与操作。由于 n - 1 的二进制表示中所有位都是 1,因此该操作的结果等价于将哈希值的低 k 位保留下来,其余位都变成了 0。最终,(n - 1) & hash 的结果就是 hash % n 的值,也就是 hash 值对应的在哈希表中的下标。

为什么哈希表数组的容量要设为 2 的幂次方
位运算可以加速取模的性能。

HashMap#hash 特性

Java 中的每个对象都有一个哈希码 (Hash Code),可以通过调用 java.lang.Object#hashCode 方法或者键类型重写的 hashCode() 方法来获取。其中,Object 类中的 hashCode() 方法会根据对象的内存地址计算出一个整数值作为该对象的哈希码。HashMap 中的键的哈希码就是在键对象的 hashCode() 方法的基础上计算的:

1
2
3
4
5
static final int hash(Object key) {
    int h;
    // key 等于 null,返回 0,否则实际计算
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

HashMap 对键的哈希码做了再处理,具体算法为 (h = key.hashCode()) ^ (h >>> 16),即将键的哈希码 h 与其右移 16 位后的结果进行异或运算,这样可以有效地提高哈希码的随机性和分布性,从而提升查询性能。

我们知道:

  1. 16 位有符号数的最大值为 32767,而 HashMap 的表的容量在大多数情况下不会超过这个值;
  2. HashMap 中哈希表的容量始终是 2 的次幂,因此哈希值对哈希表容量取余,等价于这两个值按位与。

如果直接使用 32 位哈希码,当不同键的哈希码的低位相同、高位不同时,它们对哈希表取模就有可能映射到同一个位置(高位在低容量场景下会被截断),进而拖慢 HashMap 的性能。这种处理方式提升了哈希码的高 16 位对映射过程的影响,降低了哈希冲突的概率。

HashMap#put 原理

新增方法源码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // [1] 哈希表为空则创建表
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // [2] 根据 key 的哈希值计算出要插入的数组索引 i,
    //     如果 table[i] 等于 null,则直接插入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else { // [3] 如果存在 Hash 冲突
        Node<K,V> e; K k;
        if (p.hash == hash && // [3.1] 判断 Key 是否与桶内第一个元素的 Key 相等
            ((k = p.key) == key || (key != null && key.equals(k))))
            // [3.1.1] 如果相等,直接覆盖 value
            e = p;
        else if (p instanceof TreeNode) // [3.2] 否则判断第一个元素是否为红黑树
            // [3.2.1] 是红黑树则调用 tree api 插入键值对
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else { // [3.3] 是链表,则手动插入
            for (int binCount = 0; ; ++binCount) {
                // [3.3.3] 找到了链表末尾
                if ((e = p.next) == null) {
                    // 先将新节点插到链表结尾
                    p.next = newNode(hash, key, value, null);
                    // 发现链表长度到达阈值
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        // 方法中判断表容量是否超过 64,是则转为红黑树,否则只扩容
                        treeifyBin(tab, hash);
                    break;
                }
                // [3.3.2] key 已经存在,跳出循环,执行 onlyIfAbsent 策略
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                // [3.3.1] 否则向下遍历
                p = e;
            }
        }
        // [4] onlyIfAbsent 策略:默认为相同的 key 覆盖 value
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e); // 给 LinkedHashMap 设定的模板方法
            return oldValue;
        }
    }
    ++modCount;
    // [5] 超过最大容量,扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict); // 给 LinkedHashMap 设定的模板方法
    return null;
}

当执行 put(K, V) 方法来插入数据时,如果发生了哈希碰撞并且当前键 (key) 是一个全新的键,首先会将新的数据节点插入到链表的末尾。然后,会检查链表的长度,如果长度大于等于 8 且数组的容量大于 64,则会将该链表转换成红黑树。

HashMap 扩容原理

HashMap 刚被申请时会有一个初始容量(桶数量),随着数据的插入,占用的容量会逐渐增加。当 HashMap 中的已用桶数量达到一定阈值时,就会触发扩容操作,将当前数组的容量扩大一倍,并将所有元素重新分配到新的桶中。在这种情况下,每个元素都需要确定其在新数组中的位置。源码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
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) { // [1] 如果哈希表中已有数据
        // [1.1] 如果当前数组容量大于等于阈值,则不扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // [1.2] 否则扩大容量为当前容量的两倍,但不能超过 MAXIMUM_CAPACITY
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                    oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // [2] 如果哈希表为 null,且设定了 threshold
        // 则使用旧的 threshold 作为初始化容量
        newCap = oldThr;
    else { // [3] 如果哈希表为 null,且 threshold 为 0
        // 则使用默认的初始化容量 16
        newCap = DEFAULT_INITIAL_CAPACITY;
        // 扩容阈值设为 newCap * 扩容因子后的值
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }

    // [4] 如果新的阈值等于 0,也就是上述场景 [1.1]、[2]
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        // 安全设置阈值,防止溢出
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                    (int)ft : Integer.MAX_VALUE);
    }
    // [5] 更新阈值
    threshold = newThr;
    // [6] 创建扩容后的容器
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    // [7] 将新的数组赋值给 table
    table = newTab;
    // [8] 如果哈希表中有数据,则移动数据
    if (oldTab != null) {
        // [9] 根据容量循环数组,复制非空元素到新 table
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null; // 旧引用释放
                if (e.next == null)
                    // [9.1] 如果链表只有一个元素,则直接放入新桶
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    // [9.2] 如果是红黑树,则执行红黑树相关的操作
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // [9.3] 如果是链表,则进行链表复制(JDK 1.8 扩容优化部分)
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 计算索引是否需要移动
                        if ((e.hash & oldCap) == 0) {
                            // 如果不需要移动,插入 low 队列
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            // 如果需要移动,插入 high 队列
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        // 将 low 链表放到新数组的原索引位置
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        // 将新链表放到新数组原索引 + oldCap 处的位置
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

在扩容过程中,HashMap 会将旧数组中的每个桶内的元素依次迭代,并将每个元素根据其哈希值计算出在新数组中的位置。而由于新数组的容量是旧数组的两倍,因此旧数组中的每个桶位都对应着新数组中的两个桶位。具体地说,旧数组中的第 i 个桶位对应了新数组中的两个桶位:下标分别是 ii + oldCap。然后 HashMap 通过高位运算来确定元素是否需要移动。假设元素 e 存储在旧数组的桶位 i 中:

  • 如果 (e.hash & oldCap) == 0,那么它在新数组中的位置仍然是 i
  • 否则,它在新数组中的位置是 i + oldCap

HashMap 通过这种设计,避免了 Rehash 操作,极大地提高了性能。

e.hash & oldCap 数学原理

首先,我们知道 Key 在旧数组中的索引位置,即 e.hash % oldCap 等价于 e.hash & (oldCap - 1)。假设有如下场景:

1
2
3
4
5
6
                 ---------------
e.hash        -> 1 0 1 1  1 1 1 0
oldCap        ->       1  0 0 0 0
oldCap-1      ->       0  1 1 1 1
                 ---------------
index         ->          1 1 1 0

当哈希表容量扩大一倍后,新数组的长度由 1 0000 变为 10 0000,此时元素 e 在新数组中的索引位置如下:

1
2
3
4
5
6
7
8
                 ---------------
e.hash        -> 1 0 1 1  1 1 1 0
newCap        ->     1 0  0 0 0 0
newCap-1      ->     0 1  1 1 1 1
                 ---------------
index         ->       1  1 1 1 0
                       *
                  重点关注最高位

在这个示例中,元素 e 的二进制哈希值的第五位为 1,那么在哈希表容量扩大后,e 在新数组中的索引位置就是 1 1110,即 1110 + 1 0000。想要得到哈希值第五位是否为 1,只需做以下计算,再判断结果是否为 0 即可:

1
2
3
4
5
6
7
                 ---------------
e.hash        -> 1 0 1 1  1 1 1 0
markword      ->       1  0 0 0 0
                 ---------------
index         ->       1  0 0 0 0
                       *
                  重点关注最高位

而这里的 1 0000 恰好是旧容量 (oldCap) 的二进制值。因此,我们可以通过对 e.hasholdCap 进行按位与操作来判断第五位是否为 1,从而决定是否需要将 e 移动到新数组的相应位置。

注释
虽然 JDK 1.8 HashMap 并未对桶内元素进行 Rehash 操作,但是通过高位与运算,将原链表(红黑树)分散在了新数组的两个桶内,依旧起到了缩短链表长度(红黑树深度)的作用,同时性能也较 Rehash 更高。

HashMap 扩容因子为何为 0.75

当哈希表中出现多次冲突导致链表长度过长时,HashMap 会将链表转换为红黑树,以提高查询性能。但是,由于红黑树节点 (TreeNode) 的大小大约是常规链表节点大小的两倍,所以在选择是否进行转换时,应该优先考虑扩容而不是转换(具体表现为源码中只有当哈希表容量超过 64 时才进行链表转树,否则只进行 resize 扩容)。

当设置的扩容因子较大时,扩容的门槛也会相应提高,从而减少扩容的频率,并且占用的空间也会更少。然而,这样做也会增加哈希冲突的概率,从而增加链表转树的可能性。相反地,当扩容因子值较小时,扩容的门槛会降低,哈希冲突的可能性也会比较小,因此操作性能会比较高,但会占用更多的空间。

为了在容量和性能之间达到平衡,HashMap 将扩容因子定为了 0.75。

HashMap 链表转红黑树阈值为何为 8

在分析之前,我们先来了解下为什么 HashMap 需要将链表转换为红黑树。如上文所述,相对于红黑树,链表具有更小的空间占用优势,但在查询时的时间复杂度较高;而红黑树则恰好相反。在设计算法时,通常需要平衡时间复杂度空间复杂度这两个指标,在 HashMap 这里具体表现为:

  • 一方面,我们不希望链表过长,以免影响查询性能;
  • 另一方面,我们也不希望链表太容易转换为红黑树,以避免占用过多的内存空间。

幸运的是,HashMap 在哈希碰撞发生的次数方面符合泊松分布,我们可以利用泊松分布来同时满足这两个要求。

泊松分布指出:在单位时间(或面积、体积)内,某个随机事件平均发生 $\lambda$ 次(期望为 $\lambda$)的情况下,该事件实际发生 $k$ 次的概率遵循泊松分布,其概率方程如下:

$P(X = k) = \frac {(\lambda)^k} {k!} e^{-\lambda}$, $k = 0,1,2,…$

  • $k$:发生的次数
  • $X$:一种关系,含义为“实际发生 $k$ 次”
  • $P$:$X$ 关系发生的概率
  • $\lambda$:数学期望,表示单位时间(或面积、体积)内随机事件的平均发生次数

在哈希表中,我们以哈希桶为单位体积,并将插入操作看做一系列的随机事件,其中每个事件表示将一个 Key 映射到某个哈希桶内。因此:

  • $\lambda$ 表示每个桶内平均存储元素的数量;
  • $P(X = k)$ 表示现实中有 $k$ 个 Key 被映射到同一个哈希桶内(即哈希碰撞了 $k$ 次)的概率。

根据 HashMap 源码注释,当 $\lambda = 0.5$ 时,$k$ 从 0 到 8 得到的概率 $P$ 如下:

1
2
3
4
5
6
7
8
9
* 0:    0.60653066
* 1:    0.30326533
* 2:    0.07581633
* 3:    0.01263606
* 4:    0.00157952
* 5:    0.00015795
* 6:    0.00001316
* 7:    0.00000094
* 8:    0.00000006

由上可知,在 $\lambda = 0.5$ 的情况下,某个哈希桶内发生 8 次碰撞的概率非常小。因此,HashMap 设定只有当链表长度超过 8 时,才会考虑将其转换为红黑树来处理,以平衡链表的低效查询与红黑树的高空间占用。

为什么 HashMap 泊松分布的期望为 0.5

到这里还剩一个最重要的问题没有解决,上文我们直接从源码中知晓了 $\lambda$ 被设定为 0.5,而且泊松分布概率方程准确与否,与频率 $\lambda$ 的选定息息相关,所以 Java 是怎么选定 $\lambda = 0.5$ 的呢?答案与前文提到的扩容因子 0.75 有关。

首先,我们假定一种理想情况:哈希算法能够完美地散列,因此在向 HashMap 中插入数据时,不会发生任何哈希冲突。然后,随着数据的不断插入,HashMap 会触发多次扩容操作。由于扩容因子为 0.75,每次扩容前哈希表内数据量占容器的比值为 0.75,每次扩容后哈希表内数据量占容器的比值为 0.375,所以在数据不断添加的过程中,哈希表内数据量的占比以锯齿状在 0.375 ~ 0.75 间变化:

1
2
3
4
5
6
7
^
|
|   .  .  .    _______ 0.75
|  /| /| /|    _____________0.5625
| / |/ |/ |/   _______0.375
|/
+--------------------------------->

在忽略方差的情况下,哈希表容量占比的期望值约为 0.5625,也就是说,平均每个桶内大约有 0.5625 个元素,Java 的设计师们将其向下取整,选择了 $\lambda = 0.5$,这便是源码中 $\lambda$ 值的由来。

至此,与 HashMap 相关的内容就介绍完了。

0%