13、HashMap(上)

内容来自王争 Java 编程之美

上两节,我们粗略讲了 JCF 中的各个容器,给你构建了一个系统性的框架图,本节,我们重点讲一下 HashMap
HashMap 在 Java 编程中,使用频率非常高,而且,因为其底层实现比较复杂,在面试中,也经常被面试官拿来考察候选人对技术掌握的深度
本节,我们就从基本原理、哈希函数、装载因子、动态扩容、链表树化等方面,详细剖析 HashMap 的实现原理

1、基本原理

HashMap 容器实现了接口 Map,从功能上讲,它是一个映射,通过键(key)快速获取值(value),键和值具有一一映射关系
HashMap 容器使用起来非常简单,如下示例代码所示
注意,HashMap 容器中存储的键不可重复,但值可以重复,存储重复的键,后面存储的值会覆盖前面存储的值,相当于执行了修改操作

public class Demo13_1 {

    private static class Student {
        public int id;
        public int score;

        public Student(int id, int score) {
            this.id = id;
            this.score = score;
        }

        @Override
        public String toString() {
            return "Student [id = " + id + "; score = " + score + "]";
        }
    }

    public static void main(String[] args) {
        Map<Integer, Student> stuMap = new HashMap<>();

        // 增
        stuMap.put(1, new Student(1, 90));
        stuMap.put(3, new Student(3, 88));
        stuMap.put(19, new Student(19, 97));

        // 删
        stuMap.remove(1);

        // 改
        stuMap.put(3, new Student(3, 100));

        // 查
        Student stu = stuMap.get(3);
        System.out.println(stu); // Student [id = 3; score = 100]
    }
}

HashMap 容器是基于哈希表实现的,对键计算哈希值,并将键和值包裹为一个对象,如下代码中 Node 类所示,存储在哈希表中
接下来的分析,如果不特别说明,我们都是按照 JDK 8 中 HashMap 容器的代码实现来讲解

public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable {

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

        // ... 省略 getter、setter 等方法 ...
    }

    transient Node<K, V>[] table; // Node 数组

    // ... 省略其他属性和方法 ...
}

在《数据结构与算法之美》中,我们讲到,哈希表存在哈希冲突
哈希冲突解决的方法有多种,其中比较常用的就是链表法(也叫做拉链法),这也正是 HashMap 容器所使用的方法,如上代码所示,table 数组便是用来存储链表的
Node 类便是链表中节点的定义,key 表示键,value 表示值,next 为链表的 next 指针(尽管 next 在 Java 中为引用,但在数据结构和算法中,我们一般将 next 称为 next 指针)
hash 为由 key 通过哈希函数计算得到的哈希值,起到缓存的作用,在查询元素时,用此值做预判等,关于这个值的详细介绍,后面会讲到

根据以上对 HashMap 基本实现原理的讲解,我们将开头示例代码的存储结构,绘制出来,如下图所示
键 3 和键 19 存在哈希冲突,对应的节点存储在同一个链表中
image

2、哈希函数

哈希函数是哈希表中非常关键的一个部分,我们来看一下 HashMap 中哈希函数是如何设计的,如下代码所示

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

// 这里只是为了简化逻辑
static final int hash(Object key) {
    if (key == null) return 0;

    int h = key.hashCode();
    return h ^ (h >>> 16);
}

我们发现,哈希函数设计的非常简单,借助键的 hashCode() 函数,将其返回值 h,与移位之后的 h 进行异或操作,最终得到的值作为哈希值

因为 key 经过此哈希函数计算之后,得到的哈希值范围非常大,往往会超过 table 数组的长度 n
因此需要跟 n 取模,才能最终得到存储在 table 数组中的下标(我们把这个下标值简称为 "key 对应数组下标")
因为取模操作比较耗时,所以,在具体实现时,Java 使用位运算实现取模运算,如下代码所示,key 将存储在 table[index] 对应的链表中

int index = hash(key) & (n - 1);

上述计算过程如下图所示,其中,HashMap 容器的数组长度 n 的默认初始值为 16
image
上面粗略展示了 key 的哈希值和对应数组下标的计算方式,接下来,我们再对计算方式的细节做些解释

2.1、key 的 hashCode() 函数

hashCode() 函数定义在 Object 类中,根据对象在内存中的地址来计算哈希值
当然,我们也可以在 Object 的子类中重写 hashcode() 函数,Integer、String 类的 hashCode() 函数如下所示
从代码中,我们可以发现,Integer 对象的哈希值就是其所表示的数值(value)本身

// Integer 类的 hashCode() 函数
public int hashCode() {
    return value;
}

// String 类的 hashCode() 函数
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;
}

2.2、h ^ (h >>> 16)

在 hash() 函数中,为什么不直接返回 h,也就是 key 的 hashCode() 返回值作为哈希值?

这是因为,一般来说,table 数组的大小 n 不会很大,一般会小于 2 ^ 16(65536),而 hashCode() 函数的返回值 h 为 int 类型,长度为 4 个字节
在计算 key 对应的数组下标时,h 跟 n 求模后,h 的高 16 位信息将会丢失,相当于只使用了 h 的后 16 位信息
理论上讲,参与计算的信息越多,得到的数组下标越随机,数组在哈希表中的各个链表中的分布就会越均匀
所以,我们将 hashCode() 函数的返回值与其高 16 位异或,这样所有的信息都没有浪费

2.3、取模 h & (n - 1)

HashMap 容量为 2 次幂的原因:h % n == h & (n - 1)

之所以可以用上述位运算来实现取模运算,有一个极其关键的前提是:HashMap 中 table 数组的大小 n 为 2 的幂次方(如何做到的?我们待会再讲)
比如 2 ^ 4,将其减一之后的二进制串为:1111,跟 h 求与,相当于取模操作

2.4、key 可为 null 值

从 hash() 函数中,我们还可以发现,值为 null 的 key 的哈希值为 0,对应数组下标为 0,所以,值为 null 的 key 也可以存储在 HashMap 中
不过,一个 HashMap 容器只能存储一个值为 null 的 key,这符合 HashMap 容器不允许存储重复 key 的要求

了解了哈希函数之后,我们再回过头去看下,Node 类中的 hash 属性,hash 属性存储的是 key 的哈希值(也就是通过 hash(key) 计算得到的值)
这个值的作用是预判等,提高查询速度,将这个值作为属性存储在节点中目的是,避免重复计算,接下来,我们具体来讲讲它是怎么工作的

  • 当我们调用 get(xkey) 函数查询键 xkey 对应的值(value)时,HashMap 容器先通过 hash(xkey) 函数计算得到 xkey 的哈希值,假设为 xhash
    xhash 跟 table 数组大小 n 取模,假设得到数组下标为 xIndex,也就说明 x 应该出现在 table[xIndex] 对应的链表中
    hash(xkey) -> xhash % n -> xIndex -> table[xIndex]
  • 遍历 table[xIndex] 所对应的链表,查找属性 key 等于 xkey 的节点
    当遍历到某个节点 node 之后,首先会拿 node.hash,与 x 的哈希值 xhash 比较
    如果不相等,则说明 node.key 跟 xkey 肯定不相等,就可以继续比较下一节点了
  • 如果 node.hash 跟 xhash 相等,也并不能说明这个节点就是我们要找的节点
    因为哈希函数存在一定的冲突概率,所以,即便哈希值相等,node.key 也未必就跟 xkey 相等
    因此,我们需要再调用 equals() 方法,比较 node.key 与 xkey 是否真的相等
    如果相等,那么这个节点就是我们要查找的节点
    如果不相等,则继续遍历下一个节点,再进行上述比较
  • node.key 和 xkey 为对象,需要调用 equals() 函数比较是否相等
    node.hash 和 xhash 为整数,直接使用等号即可判等
    后者比前者的执行效率更高
    通过预先比对哈希值,过滤掉 node.key 和 xkey 不可能相等的节点,以此来提高查询速度

上述查找过程对应的 HashMap 类的源码如下所示

// HashMap 类的源码
public V get(Object key) {
    Node<K, V> e;
    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 && ((k = first.key) == key
                || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            // ... 省略部分代码 ...
            do {
                if (e.hash == hash &&
                        ((k = e.key) == key ||
                                (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

3、装载因子

为了方便使用,JCF 提供的容器基本上都支持动态扩容,当容器中容纳不下新的元素时,便会扩容,将数据搬移到更大的存储空间中,HashMap 容器也不例外
你可能会说,HashMap 使用链表法解决哈希冲突,链表可以无限长,不存在无法容纳新元素的情况,但是,当 HashMap 容器中的数据越来越多时
在 table 数组大小不变的情况下,链表的平均长度会越来越长,进而影响到 HashMap 容器中各个操作的执行效率,这种情况下就应该扩容了

具体什么时候扩容,主要由 table 数组的大小(n)和装载因子(loadFactor)决定
当 HashMap 容器中的元素个数超过 n * loadFactor 时,就会触发扩容,其中,n * loadFactor 在 HashMap 类中定义为属性 threshold(阈值)

在 HashMap 中,装载因子 laodFactor 的默认值为 0.75,table 数组的默认初始大小为 16
也就说,当添加元素个数超过 12(16 * 0.75)个时,HashMap 容器就会触发第一次扩容
当然,我们也可以通过带参构造函数,自定义 table 数组的初始大小和装载因子,如下代码所示

// HashMap 类的其中一个构造函数
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " + loadFactor);

    // 装载因子, 默认 0.75
    this.loadFactor = loadFactor;

    // n 为 table 数组的大小, loadFactor 为装载因子, threshold = n * loadFactor 作为扩容阈值
    // tableSizeFor(initialCapacity) 寻找比 initialCapacity 大的第一个 2 的幂次方数
    this.threshold = tableSizeFor(initialCapacity);
}

前面讲到,为了便于使用位运算来实现取模运算,table 数组的大小必须是 2 的幂次方,但是,如果通过构造函数,传入的参数 initialCapacity 不是 2 的幂次方,那该怎么办呢?
上述代码中使用 tableSizeFor() 函数,就是为了解决这个问题,它会寻找比 initialCapacity 大的第一个 2 的幂次方数
比如 tableSizeFor(7) = 8,tableSizeFor(13) = 16,tableSizeFor() 函数的代码实现如下所示

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

为什么上述逻辑可以获取到一个数最邻近的 2 的幂次方数?对此我们不做证明,仅仅通过一个例子,来看下它是如何工作的,如下图所示
image

这里稍微解释一下,以免细心的你会有疑问,tableSizeFor(initialCapacity) 的值赋值给了 threshold,这似乎有些不对
因为 tableSizeFor(initialCapacity) 的值是 table 数组的大小,threshold 是触发扩容的阈值,按理来说,tableSizeFor(initialCapacity) 的值乘以 loadFactor 才应该是 threshold

实际上,这是因为在创建 HashMap 对象时,table 数组只声明未创建,其值为 null,只有当第一次调用 put() 函数时,table 数组才会被创建
但是,HashMap 并没有定义表示 table 数组大小的属性,于是,tableSizeFor(initialCapacity) 的值就暂存在了 threshold 属性中
当真正要创建 table 数组时,HashMap 会类似如下代码所示,先用 threshold 作为数组大小创建 table 数组,再将其重新赋值为真正的扩容阈值

this.table = new T[this.threshold];
this.threshold *= this.factor;

在平时的开发中,我们不要轻易修改装载因子,默认值 0.75 是权衡空间效率和时间效率,精心挑选出来的,除非我们对时间和空间有比较特殊的要求,比如

  • 如果更关注 "时间" 效率,我们可以适当 "减小" 装载因子:这样哈希冲突的概率会更小,链表长度更短,增删改查操作更快,但空间消耗会更大
  • 如果更关注 "空间" 效率,我们可以适当 "增大" 装载因子,甚至可以大于 1
    这样 table 数组中的空闲空间就更少,不过,这也会导致冲突概率更大,链表长度更长,增删改查以及扩容都会变慢

4、动态扩容

当我们调用 put() 函数往 HashMap 容器中添加键值对时,在添加完成之后,会判断容器中键值对的个数是否超过 threshold(阈值 = table 数组大小 * 装载因子)
如果超过,则触发动态扩容,申请一个新的 table[] 数组,大小为原 table 数组的 2 倍,并将原 table 数组中的节点,一个一个的搬移到新的 table[] 数组中

扩容操作会逐一处理 table 数组中的每条链表,当然,对于 JDK 8 中的 HashMap,table 数组中存储的还有可能是红黑树而非链表(这点待会会讲)
在扩容时,红黑树的处理方法,跟链表的处理方法类似,所以,我们拿链表的处理方法举例讲解

因为新的 table 数组大小 newCap 是原 table 数组大小 oldCap 的两倍,所以,一些节点在新 table 数组中的存储位置将会改变,我们需要重新计算其对应的数组下标
但因为每个节点的 key 的哈希值,已经存储在节点的 hash 属性中,所以,不需要调用哈希函数重新计算,只需要将节点 node 中存储的 hash 值跟 newCap 取模即可
取模操作仍然可以使用位运算来替代,也就是 node.hash & (newCap - 1),由此得到节点 node 搬移到新的 table 数组中的位置下标

实际上,在 JDK 8 中,新的位置下标的计算,并非通过 node.hash 跟 newCap 取模得到的,而是做了更进一步的优化

  • 如果 node.hash & oldCap == 0:节点在新 table 数组中的下标不变
  • 如果 node.hash & oldCap != 0:节点在新 table 数组中的下标变为 i + oldCap(i 为节点在原 table 数组中的下标)

如下图举例所示,node.hash 的二进制表示中
从右向左第 5 个二进制位为 0 的的节点,新的数组下标未变
从右向左第 5 个二进制位为 1 的的节点,新的数组下标为原下标 + 16(二进制 10000)
image
扫描 table 数组中的每一条链表,根据节点在新 table 数组中的下标是否更改,将链表中的节点分配到 lo 链表或 hi 链表
lo 链表中存储的是下标值 "未变" 的节点(节点在 table 数组中存储的位置下标为 i,在新的 table 数组中也是 i)
hi 链表中存储的是下标值 "有所改变" 的节点(节点在新的 table 数组中存储的位置下标变为 j)
处理完一条链表之后,将 lo 链表存储到新的 table 数组中下标为 i 的位置,将 hi 链表存储到新的 table 数组中下标为 j 的位置

5、链表树化

尽管我们可以通过装载因子,限制 HashMap 容器中不会装载太多的键值对,但这只能限制平均链表长度,对于单个链表的长度,我们无法限制
如下代码所示,12 个键值对存储在 table 数组中下标为 1 的链表中

// 默认 table 大小为 16
// 16 * 0.75 = 12, 超过 12 个键值对才扩容
Map<Integer, String> map = new HashMap<>();

// key = i * 16 + 1, index = (i * 16 + 1) % 16 = 1
for (int i = 0; i < 12; i++) {
    map.put(i * 16 + 1, "value" + i);
}

当链表长度过长时,HashMap 上的增删改查操作都需要遍历链表(增加键值对的时候需要查看这个键是否已经存在,所以也需要查找),所以都会变慢
针对这种情况, JDK 8 做了优化
当某个链表中的节点个数大于等于 8(此值定义在 HashMap 类的静态常量 TREEIFY_THRESHOLD中),并且 table 数组的大小大于等于 64 时
将会把链表转化为红黑树,我们把这个过程叫做 treeify(树化)
对于长度为 n 的链表,增删改查的时间复杂度为 O(n),而对于节点个数为 n 的红黑树来说,增删改查的时间复杂度是 O(logn),显然,性能提高了很多

  • 需要注意的是,如果 table 数组长度小于 64,即便链表中的节点个数大于等于 8,也不会触发 treeify,而是会触发扩容操作,试图通过扩容,将长链表拆分为短链表
    这样做的原因是,小数据量的情况下,扩容要比 treeify 更简单,更省时间
  • 当红黑树中节点个数比较少时,HashMap 会再将其转换回链表
    毕竟维护红黑树平衡的成本比较高,对于少许节点,使用链表存储反倒会比红黑树高效
    红黑树转换为链表的过程,叫做 untreeify,触发 untreeify 的场景有两个,一个是删除键值对时,另一个是扩容时
  • 在删除键值对时,如果红黑树中的节点个数变得很少,那么就触发 untreeify
    不过,是否触发 untreeify,并非直接由红黑树中的节点个数来决定,而是通过判断其结构来决定
    当红黑树的结构满足如下代码所示的条件时,便会触发 untreeify
if (root == null || (movable && (root.right == null
        || (rl = root.left) == null
        || rl.left == null))) {
    tab[index] = first.untreeify(map);  // too small
    return;
}

通过分析此时红黑树的结构,我们反推得到这样的结论:以上结构的红黑树的节点个数应该处于 [2, 6] 之间
也就是说,触发 untreeify 时,红黑树的节点个数有可能是 2 个、3 个、4 个、5 个、6 个
红黑树结构的不同,导致触发 untreeify 时节点的个数不同,但红黑树中节点个数超过 6 个时,肯定不会触发 untreeify

尽管 treeify 的阈值是 8,但是 untreeify 的阈值却不是 8,而是 [2, 6] 之间的某个数,之所以 treeify 的阈值和 untreeify 的阈值不相等
是为了避免频繁的插入删除操作,导致节点个数在 7、8 之间频繁波动,进而导致链表和红黑树之间频繁的转换(复杂度震荡),毕竟转换操作也是耗时的

我们再来看另一个触发 untreeify 的场景,那就是扩容时,前面讲到,扩容时,每一条链表都会分割为 lo 和 hi 两条链表
同理,每一个红黑树也会分割为 lt 和 ht 两个红黑树,lt 中存储的是下标位置不变的节点,ht 中存储的是下标位置变化的节点
不过,我们在构建 lt 和 ht 之前,会先统计属于 lt 的节点个数 lc,以及属于 ht 的节点个数 hc
如果 lc 小于等于 6(此值定义在 HashMap 的静态常量 UNTREEIFY_THRESHOLD 中),在新的 table 数组中,HashMap 会使用链表来存储下标不变的节点
同理,如果 hc 小于等于 6,在新的 table 数组中,HashMap 会使用链表来存储下标改变的节点

6、课后思考题

1、当调用 put() 函数添加键值对时,在添加完成之后,会检查是否需要扩容,那么为什么是在添加完成之后再扩容,而不是先检查是否需要扩容再添加键值对呢?

2、散列表的哈希冲突解决方法有两种:链表法和开放寻址法,本节讲到的 HashMap 用到了链表法,那么,Java 中有哪些容器或工具类用到了开放寻址法呢?

在后面的章节中,我们会讲到 ThreadLocal
ThreadLocal 底层依赖 ThreadLocalMap 来实现,ThreadLocalMap 也是一个散列表,底层采用开放寻址法来解决哈希冲突
posted @ 2023-05-15 11:41  lidongdongdong~  阅读(45)  评论(0编辑  收藏  举报