HashMap - tenji/ks GitHub Wiki

关于 HashMap

在 Java 中,HashMap 从 Java 1.2 开始就是 Java 集合的一部分。该类位于 java.util 包中。它提供了 Java Map 接口的基本实现。 Java 中的 HashMap 将数据存储在(键,值)对中,你可以通过其他类型(例如整数)的索引来访问它们。

一、Java 中 HashMap 的继承关系

二、底层数据结构

  • 1.8 以前:数组 + 单链表

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;
    }

    ...
}
  • 1.8 以后:数组 + 单链表 + 红黑树(链表长度超过 8 就转化为红黑树,低于 6 就会退回链表)

/**
 * Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn
 * extends Node) so can be used as extension of either regular or
 * linked node.
 */
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;
    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    }

    /**
     * Returns root of tree containing this node.
     */
    final TreeNode<K,V> root() {
        for (TreeNode<K,V> r = this, p;;) {
            if ((p = r.parent) == null)
                return r;
            r = p;
        }
    }

    ...
}

为什么转变条件 86 会有一个差值?

如果没有差值,都是 8,那么如果频繁的插入删除元素,链表个数又刚好在 8 徘徊,那么就会频繁的发生链表转树,树转链表。

三、复杂度

  • 时间复杂度
    • 插入:O(1)
    • 删除:O(1)
    • 搜索:O(1)
  • 空间复杂度:O(n)

四、源码及关键函数解析

4.1 Java7

hash(扰动函数)

static int hash(int h) {
    // This function ensures that hashCodes that differ only by
    // constant multiples at each bit position have a bounded
    // number of collisions (approximately 8 at default load factor).

    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

get

put

resize(扩容)

4.2 Java8

hash(扰动函数)

static final int hash(Object key) {
    int h;
    // key.hashCode():返回散列值也就是 hashcode
    // ^:按位异或
    // >>>:无符号右移,忽略符号位,空位都以 0 补齐
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

方法流程:

  1. 计算 key 的哈希值:h = key.hashCode()
  2. 哈希值(int 类型,长度为 32 位)和右移 16 位(前 16 位补零)的哈希值做异或运算,得到最终的哈希值并返回。(相当于混合了原哈希值中的高位和低位,增大了随机性)

怎么根据哈希值计算数组坐标?

使用以下方式计算数组坐标:(n 是数组长度)

i = (n - 1) & hash

本质上就是取模运算,而且是使用位运算 & 替代取模运算 %,因为位运算的效率更高。但是需要注意的是,只有在 b 为 2 的 n 次方时,这个公式 a % b = a & (b-1) 才成立,也就是说,只有数组长度是 2 的 n 次方时,使用 & 才会取余成功,这也是为什么,我们对 hashmap 进行扩容时,必须是 2 倍扩容了。

get

方法流程:

  1. 计算 key 的哈希值;
  2. 根据哈希值通过 (n - 1) & hash 计算得到数组坐标;
  3. 判断数组第一个元素是否刚好就是我们要找的(key 值是否一样),如果是的话,直接返回该元素;
  4. 第一个元素如果不是我们要找的,那么需要判断是链表还是红黑树实现
    • 红黑树实现的,就在红黑树中查找
    • 单链表实现的,就在单链表中查找
public V get(Object key) {
    Node<K,V> e;
    // 计算 key 的哈希值
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 第一个元素是否刚好就是我们要找的,直接返回
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            // 元素是 TreeNode 类型,说明是红黑树实现的,在红黑树中查找
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                // 不是红黑树实现的,在单链表中查找
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

put

方法流程:

  1. 第一次添加元素时,先调用 resize() 方法扩容(初始化容量是 16 ,负载因子默认 0.75);
  2. 根据哈希值通过 (n - 1) & hash 计算得到数组坐标;
  3. 如果此坐标元素为空,则新建节点加入,添加完成;
  4. 如果此坐标元素不为空时
    • 如果坐标上的元素和要加入的元素的 key 完全一样,覆盖原有值,添加完成;
    • 如果 key 不一样时,需要判断是链表还是红黑树实现:
      • 红黑树实现时,调用红黑树的插值方法;
      • 单链表实现时,新建节点添加到链表最后面,同时判断是否需要将单链表转成红黑树(链表长度超过 8);

和 Java7 稍微有点不一样的地方就是,Java7 是先扩容后插入新值的,Java8 先插值再扩容,不过这个不重要。

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;
    // 第一次添加元素时,先调用 resize() 方法扩容
    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))))
            // 如果坐标上的元素和要加入的元素的 key 完全一样,覆盖原有值
            e = p;
        else if (p instanceof TreeNode)
            // 元素是 TreeNode 类型,说明是红黑树实现的,红黑树实现时,调用红黑树的插值方法
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            for (int binCount = 0; ; ++binCount) {
                // 插入到链表的最后面(Java7 是插入到链表的最前面)
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 链表长度超过 8,需要将链表转换为红黑树
                    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;
            }
        }
        // e != null 说明存在旧值的 key 与要插入的 key "相等"
        // 对于我们分析的 put 操作,下面这个 if 其实就是进行 "值覆盖",然后返回旧值
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // // 如果 HashMap 由于新插入这个值导致 size 已经超过了阈值,需要进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

resize(扩容)

方法流程:

  1. 确认扩容后的容量(capacity)和阈值(threshold),分别用 newCap 和 newThr 代替;
  2. 根据计算出的 newCap 创建新的桶数组,桶数组 table 也是在这里进行初始化的;
  3. 将键值对节点重新映射到新的桶数组里。
    • 如果是红黑树实现,则需要拆分红黑树;
    • 如果是单链表实现,则节点按原顺序进行分组。(链表拆分成高位和低位两个链表,根据 e.hash & oldCap 的值决定重新映射的索引位置)
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 如果 table 不为空,表明已经初始化过了
    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
        // 对应使用 new HashMap(int initialCapacity) 初始化后,第一次 put 的时候
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        // 对应使用 new HashMap() 初始化后,第一次 put 的时候
        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;
            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);
                else { // preserve order
                    // 需要将此链表拆成两个链表
                    // 链表1存放在低位(原索引位置)
                    Node<K,V> loHead = null, loTail = null;
                    // 链表2存放在高位(原索引 + 旧数组长度)
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        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;
}

为什么链表要拆分成高位和低位两个链表?拆分条件为什么是 (e.hash & oldCap) == 0

HashMap 的扩容都是扩大为原来大小的两倍,假设 HashMap 原来的长度是 8,需要扩容。那么 oldCap = 8, newCap = 16。也就是 8 --> 16,从二进制上看,1000 --> 10000;n - 1 就是 7 --> 15,从二进制上看,111 --> 1111,也就是高位多了一个 1;

而我们计算数组坐标的公式是:i = (n - 1) & hash,那么扩容前和扩容后用这个公式计算的结果,主要是看 key 的哈希值和高位的 1 与运算的结果,如果运算的结果是零(也就是 (e.hash & oldCap) == 0),那么扩容前和扩容后数组坐标是不变的。否则的话,扩容后数组坐标就是变化的,变成:原索引 + 旧数组长度

下面看一个例子。先来回顾一下 hash 求余的过程:

上图中,桶数组大小 n = 16,hash1 与 hash2 不相等。但因为只有后 4 位参与求余,所以结果相等。当桶数组扩容后,n 由 16 变成了 32,对上面的 hash 值重新进行映射:

扩容后,参与模运算的位数由 4 位变为了 5 位。由于两个 hash 第 5 位的值是不一样,所以两个 hash 算出的结果也不一样。上面的计算过程并不难理解,继续往下分析。

假设我们上图的桶数组进行扩容,扩容后容量 n = 16,重新映射过程如下:

依次遍历链表,并计算节点 hash & oldCap 的值。如下图所示

如果值为 0,将 loHead 和 loTail 指向这个节点。如果后面还有节点 hash & oldCap 为 0 的话,则将节点链入 loHead 指向的链表中,并将 loTail 指向该节点。如果值为非 0 的话,则让 hiHead 和 hiTail 指向该节点。完成遍历后,可能会得到两条链表,此时就完成了链表分组:

最后再将这两条链接存放到相应的桶中,完成扩容。如下图:

从上图可以发现,重新映射后,两条链表中的节点顺序并未发生变化,还是保持了扩容前的顺序。

∞、参考链接

⚠️ **GitHub.com Fallback** ⚠️