深入解析 HashMap

451 阅读8分钟

前言

Hash 表(散列表)是一种高效的数据结构,查找效率达到了 O(1)。它本质上是一个数组,根据 Hash 函数 将元素映射到数组的某个位置进行存储。

有可能不同的元素会映射到同一个位置,称为 哈希冲突。哈希冲突无法避免,解决哈希冲突的方法有很多,比如开放地址法和 链表法

其中,链表法是本文介绍的 HashMap 采用的方法,此外,在 JDK8 及以后的版本中,对 HashMap 做了进一步的优化,引入了 红黑树 结构在适当时候替换链表结构。

本文基于 JDK11

原理解析

属性和框架

HashMap 中定义了一些属性,它们的含义如下:

  • int DEFAULT_INITIAL_CAPACITY = 1 << 4:默认的初始容量为 16
  • int MAXIMUM_CAPACITY = 1 << 30:最大容量
  • float DEFAULT_LOAD_FACTOR = 0.75f:默认的负载因子
  • int TREEIFY_THRESHOLD = 8:当链表长度超过 8 时,使用红黑树存储
  • int UNTREEIFY_THRESHOLD = 6:当红黑树元素少于 6 时,转回链表存储
  • int MIN_TREEIFY_CAPACITY = 64:当 HashMap 的容量超过 64 时,才会允许转化为红黑树存储
  • Node<K,V>[] table:Hash 表数组
  • int size:键值对个数
  • int modCount:结构化修改次数

结构化修改是指元素数量的改变或者内部结构的变化,比如 rehash 操作。

  • int threshold:超过该阈值会进行扩容,threshold = capacity * loadFactor

内部结构

Hash 表 tableNode 类型的数组。其中,链表存储的节点类型是 HashMap.Node,红黑树存储的节点类型是 HashMap.TreeNodeTreeNodeNode 的子类。

image.png

哈希函数和索引计算

HashMap 中的 hash 值 计算如下:

// HashMap.class
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

使用异或操作保证了 32 位的哈希值只要有一位发生改变,整个返回值就会改变,一定程度上减少了哈希冲突的次数。


在 HashMap 中还需要计算 key 的索引 i,这是通过它的哈希值 hash 和数组容量 n 计算的,这是一种优化的索引计算方式,相当于对 n 取余,公式如下:

i = (n-1) & hash

扩容后原来在同一索引的元素只有如下两种情况:

  • 保持原索引
  • 数组原长度+原索引

例如,数组原长度 16,扩容后为 32。用二进制表示:

16-1:0000 1111 
32-1:0001 1111

假设 hash(key) = 0000 1010 (第 log216log_216 位为 0),此时,扩容前后索引相同。 假设 hash(key) = 0001 1010 (第 log216log_216 位为 1),此时,扩容前索引为 01010,扩容后索引为 11010,索引增加了 16。


那么如何判断某个 key 扩容后的索引是哪种情况呢?

我们假设数组扩容前的容量为 oldCap,且一定是 2 次幂,所以 oldCap 的二进制第 log2oldCaplog_2oldCap 位一定为 1,所以如果 hash(key) & oldCap 结果为 0,扩容情况索引相同,否则,索引增加 oldCap

这个特点很重要,在 resize() 函数中利用这个特点重新构建扩容后的数组。

重写 hashCode

HashMap 利用 hashCode 来计算索引,如果是引用类型,使用 equals() 方法判断 key 是否相等,为了保证 equals() 相同的对象返回相同的 hashCode(),我们要在重写 equals() 时重写 hashCode()

扩容

当出现以下两种情况时,会进行扩容 resize() 操作:

  • 哈希表为空,相当于是初始化操作,默认容量为 16
  • 键值对数量超过阈值 ,将哈希表数组变为原来的 2 倍

扩容时,会更新阈值 threshold,最后要重新计算索引,构建新的哈希表。

[哈希函数和索引计算] 中,我们知道某个索引的元素,扩容 2 倍后对应的新索引,要么保持原索引,要么增加原数组容量。以链表结构为例,遍历链表,根据 hash(key) & oldCap ,将链表节点分别加入两个不同的链表中,最后将两个链表的头节点赋值给新数组的两个位置。

保持原索引不变的节点构成一个链表,头尾节点分别为 loHead,loTail。索引增加原长度的节点构成一个链表,头尾节点分别为 hiHead,hiTail

这部分的源码如下:

/**
* 重新计算原索引 j 处的链表节点的新索引
* e 初始是该索引处的第一个节点
* oldCap 原数组容量
* newTab 扩容后的数组
*/
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) {
        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;
}
// 增加 oldCap 的所有结点
if (hiTail != null) {
    hiTail.next = null;
    newTab[j + oldCap] = hiHead;
}

初始化

使用构造函数初始化时,容量和负载因子默认是 16 和 0.75,也可以由用户指定。但是 不会创建数组对象

哈希表的数组容量必须是 2 的次幂。如果用户指定的容量不是 2 的次幂,调用 tableSizeFor(initialCapacity) 方法用最接近的 2 次幂的容量代替,并且此时的阈值 threshold 等于数组容量(默认构造函数不会初始化 threshold)。

真正的创建数组对象并更新 threshold 的过程在 resize() 方法中。

红黑树化

为什么使用红黑树?

红黑树 RBT 是一种弱平衡的二叉查找树,不要求像 AVL 树 一样的严格平衡(严格平衡需要更多的旋转次数)。因此,从整体上看,红黑树 查找、删除、添加 更加稳定,性能也更好。

引入红黑树是为了解决 链表长度过长时,查询效率低 的问题。

红黑树和链表的转化

链表转为红黑树存储有两个条件:

  • 数组容量 \ge MIN_TREEIFY_CAPACITY
  • 链表的长度 \ge TREEIFY_THRESHOLD=8

如果数组容量不够,但是链表长度 \ge 8 时,会进行一次扩容操作,仍然使用链表存储。


为什么阈值默认为 8 呢?

一般来说,如果 hashCode 分布均匀,链表的长度不会过长,很少会使用到红黑树存储。在理想情况下,使用随机哈希码,节点在数组中的出现频率符合泊松分布,当加载因子为 0.75、链表长度等于 8 时,节点出现的概率仅为 0.00000006,即几乎不会出现链表长度超过 8 的情况。


红黑树转为链表存储的条件是:

  • 红黑树节点数 \le UNTREEIFY_THRESHOLD=6

当删除元素或者 resize 操作时,可能导致红黑树节点减少,转化为链表存储。

添加 (put) 过程

put 方法实际上调用的是 putVal() 方法,

/**
 * @param hash key的hash值
 * @param onlyIfAbsent 如果为true,不改变重复key的值
 * @return 新添加key之前的值
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict){}

添加键值对 (key,value) 的具体过程:

  • 如果哈希表为空,先进行 resize() 操作。
  • 计算新元素的索引 i,并记录 table[i] 的节点为 p
  • 如果 p 为空(说明还没有存储任何元素),直接创建一个 Node 新节点。否则,说明已经存储了元素,出现了 哈希冲突,进行如下操作:
    • 如果 pTreeNode 实例,调用 putTreeVal() 方法插入到红黑树中
    • 如果 pNode 实例,遍历链表插入到链表尾部,并根据链表的长度判断是否需要转为红黑树
    • (插入过程中)如果发现出现了重复的 key,就结束遍历,直接用新值覆盖旧值,并直接返回旧值
  • (能走到这里说明一定增加了一个元素)修改次数增加 1(modCount++),键值对个数加 1,如果哈希表新的容量达到了阈值,要进行 resize() 操作

putVal() 方法的源码有点多了,这里就不贴了😬。

获取 (get) 过程

get(key) 方法调用 getNode() 方法获取对应的 Node,返回 value

// HashMap.class
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

getNode(hash,key) 方法的过程如下:

  • 计算索引 i,并获取 i 位置的第一个元素 first。如果哈希表为空或者 first 为空,返回 null
  • 如果 first 是要找的元素节点,返回 first
  • 如果 firstTreeNode 类型,调用 getTreeNode(hash, key) 从红黑树结构中查找
  • 如果 firstNode 类型,顺序遍历链表查找
  • (没找到返回 null) 获得当前数组(链表)的首元素first,判断key值是否相等,如果相等,直接返回key的value 如果不是first,判断是否为红黑树结构,如果是,调用红黑树的getTreeNode()方法查询 如果不是红黑树结构,从first开始遍历链表,直到找到key值对应的元素,没有则返回null.

HashMap 的变化

存储结构的变化

JDK8 开始使用 数组+链表+红黑树 结构,之前是 数组+链表

链表插入方式的变化

JDK8 开始出现冲突时,链表使用尾插入,之前是头插入。

头插入在多线程环境下可能导致 链表死循环数据丢失 的问题。

hash 函数的变化

JDK8 之前的 hash 函数:

final int hash(Object k) {
    int h = hashSeed;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }

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

JDK8 开始使用的 hash 函数:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

(扩容时)索引计算的变化

JDK8 之前计算某个 key 的索引会调用 indexFor() 方法:

// <= JDK7 的索引计算
static int indexFor(int h, int length) {
    return h & (length-1);
}

这其实就相当于是对 length 取余操作。

而 JDK8 在扩容时使用 hash(key) & oldCap 直接判断(具体在 [哈希函数和索引计算] 中已经提到了)。

遍历元素

使用 EntrySet 获取键值对集合。EntryMap 中用来描述键值对的接口,而 Node、TreeNode 都是它的实现类。

(如果使用 for 循环的话)必须使用增强 for 循环才能正确地获得键值对:

// 使用增加 for 循环遍历 HashMap
for(Map.Entry<Integer,String> entry : map.entrySet()){
    System.out.println(entry.getKey());
    System.out.println(entry.getValue());
}

增强循环只是一种语法糖,本质上还是使用 迭代器 来实现的,HashMap 中有如下迭代器:

  • HashIterator:迭代器父类,提供默认实现,有以下三个子类
  • EntryIterator:遍历 Entry 的迭代器,对应 EntrySet
  • KeyIterator:遍历 key 的迭代器
  • ValueIterator:遍历 value 的迭代器

遍历所有元素也可以使用 EntryIterator,它通过 EntrySet.iterator() 方法获取到:

// 使用迭代器遍历
Iterator<Map.Entry<Integer, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()){
    Map.Entry<Integer, String> next = iterator.next();
    System.out.println(next.getKey());
    System.out.println(next.getValue());
}

迭代器遍历的过程中,如果要 删除元素,要使用迭代器本身的 remove() 方法 ,而不应使用 HashMapremove() 方法。

类似的,使用 KeySet、KeyIteratorValues、ValueIterator 也可以分别遍历 key 和 value。