HashMap 的实现原理

355 阅读4分钟

本文已参与掘金创作者训练营第三期「话题写作」赛道,详情查看:掘力计划|创作者训练营第三期正在进行,「写」出个人影响力

概述

Java 中的 HashMap 是一种散列表的实现,它继承了 AbstractMap 类,实现了 Map 接口,存储的内容是键值对映射。从它的名字可以看出,HashMap 通过键的 hash 值,存储数据,具有很快的访问速度,键值对的存储是无序的。

HashMap.png

内部数据结构

在 HashMap 的内部,是一个数组,数组中包含一个个桶(Bucket),键值对通过哈希值决定在这个数组中的存储位置。具体的计算方式是 (n - 1) & hash,其中 n 代表数组的长度。如果存储一个键值对时,相应的位置已经存在元素的话,就判断已存在的元素与即将存入的元素的 hash 值和 key 是否相同,如果相同则直接覆盖,否则,就要在这个位置存储多个键值对。

之所以一个位置可以存储多个键值对,是因为这些键值对在数组的每一个位置都是以链表的形式存储的。当遇到哈希冲突的时候,只需要将新的键值对加入到链表中即可。

掘金文章封面.001.jpeg

在 JDK1.8 之后,如果这个链表的长度超过一个阈值(默认是8),则会进行更多的操作。如果数组的大小小于64,会对数组进行扩容;如果大于或者等于64,则链表会被转换为红黑树,这是为了减少搜索的时间。

掘金文章封面.001.jpeg

具体的过程,可以参考 HashMap 源码中的 putVal 方法。

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 {
        Node<K,V> e; K k;
        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);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                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;
}

其中的 treeifyBin 方法源码如下。

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    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);
    }
}

它会完成扩容(调用 resize 方法)或者将链表转换为红黑树的工作。resize 方法在之前的 putVal 方法中也出现过。它有两个职责:

  1. 在第一次向 HashMap 中存放元素时进行初始化。
  2. 在必要的时候对容器进行扩容。

在扩容的逻辑中,会涉及到容量、负载因子、门限制等概念。

扩容

  • 容量是创建 HashMap 时指定的预估元素数量,默认为 16。
  • 负载因子可以理解为数据存放的疏密程度,取值范围默认为 0 到 1,默认为 0.75。
  • 门限制是 容量 * 负载因子 的值。当实际的键值对数量超过门限制时,就需要进行扩容。

因此,当负载因子越接近 0 的时候,门限制越低,数据存放就越稀疏。反之,当负载因子越接近 1 的时候,数据存储越密集。

在日常开发的大部分场景下,都建议保持默认值不变。如果负载因子过低,会导致频繁的库容操作,扩容的过程涉及到 rehash 和数据复制,非常消耗性能,并且数组太「空」也会造成空间的浪费。而如果负载因子过高,会导致过多的 hash 冲突,从而降低 HashMap 的操作性能。

树化

前面介绍 HashMap 结构的时候,已经介绍了树化的条件:

  1. 有一个桶中的元素个数已经达到阈值(默认为8)。
  2. 数组的长度已经达到了阈值(默认为64)。

达到上面两个条件之后,相应位置的链表会被转换为红黑树,由于链表是线性的,当数据达到一定数量时,转换为红黑树提高数据的操作性能。另外,也可以防止人为构造哈希碰撞进行攻击,导致 CPU 被大量占用的安全问题。

总结

HashMap 是 Java 中一个十分重要的数据结构,也是技术面试经常会考察的问题。了解 HashMap 的结构、原理,甚至阅读源代码对 Java 程序员都是十分必要的。