HashMap 查漏补缺

2,005 阅读8分钟

HashMap 是面试的钉子户了,网上分析的文章也有很多,相信大家对于原理已经烂俗于心了。但最近在看源码时,发现其中一些实现细节其实不太好理解,所以决定以问答的形式在这里记录一下,写的时候尽量把原因说明白。

容量和 size 分别指什么?

容量并不是指 HashMap 所能存储的键值对数量,而是其内部的 table 数组的大小,而 size 是指目前已存储的键值对的数量。table 是一个 Entry 数组。 table 的每一个节点都连着一个链表或者红黑树。

初始容量可以随意设置吗?

可以,但是 HashMap 内部会你设置的 initialCapacity 转换为大于等于它的最小的2的n次方。比如 20 转为 32,32 转为 32等。如果不设置,则为默认值16。需要注意的是,在 Java 8的源码中,并没有在构造方法直接新建数组。而是先将处理后的容量值赋给 threshold,在第一次存储键值对时再根据这个值创建数组。

为什么内部要将容量转换为 2 的n次方?

这样可以提高取余的效率。为了防止链表过长,要保证键值对在数组中尽可能均匀分布,所以在计算出 key 的 hash 值之后,需要对数组的容量进行取余,余数即为键值对在 table 中的 index。 对于计算机而言,二进制位运算的效率高于取余(%)操作。而如果容量是 2 的 n 次方的话,hash 值对其取余就等同于 hash 值和容量值减1进行按位与(&)操作:

// capacity 为 2 的 n 次方的话,下面两个操作结果相同
hash & (capacity -1)
等同于
hash % capacity

那为什么两种操作等同呢? 我们以2进制的思维想一下,如果一个数是 2 的 n 次方,那它的二进制就是从右往左 n 位都为0,n+1 位为1。比如2的3次方就是 1000。这个数的倍数也满足从右往左 n 位都为0,取余的时候抛弃倍数,就等同于将 n+1 位及其往左的所有位归0,剩下的 n 位就代表余数。换句话说,一个数对2的 n 次方取余,就是要取这个数二进制的最低 n 位。 2 的 n 次方减1的结果就是 n 位1,进行与操作后就得到了最低 n 位。

如何将一个值转换为大于等于它的最小的2的 n 次方?

我们先假设一个二进制数 cap,cap 的二进制有 a 位(不算前面高位的0),那么,大于它的最小的2的次方就是2的 a 次方。2 的 a 次方减一的结果就是 n 位1,那我们只要将 cap 的全部 2 进制位变为1,再加1就能得到结果。而为了防止 cap 本身就是2的 n 次方,我们在计算之前先将 cap 自减。

如何将二进制位都变成1呢?下面是源码:

static final int tableSizeFor(int cap) {
    int n = cap - 1; //这一步是为了避免 cap 刚好为2的 n 次方
    n |= n >>> 1; //保证前2位是1
    n |= n >>> 2; //保证前4位是1
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

下面的描述中 n 的位数都不包括前面补位的0。

|= 这个符号不常见,a |= b 就是 a = a|b 的意思。代码中先将 n 无符号右移1位,由于n 的第1位肯定是1,移位后第2位是1,| 操作后前2位就保证是1了。第二步右移了2位再进行|操作,保证了前4位是1,后面的计算类似,由于 n 最多有32位,所以一直操作到右移16为止。这样就将 n 的所有2进制位都变成了1,最后自增返回。

比如一个数 10010,完整过程如下:

//右移一位
00010010 -> 00001001
//进行或操作
00010010 |= 00001001 -> 00011011 //保证前面2位是1
//右移两位
00011011 -> 00000110
//进行或操作
00011011 |= 00000110 -> 00011111 //保证了前面4位是1
//依此类推
...

hash 值是如何计算的

hash 值并没有直接返回 hashcode 的返回值,而是进行了一些处理。 前面提到过,hash 值算出来后需要进行取余操作,影响取余结果的是 hash 值的低 n 位。如果低 n 位是固定的或者集中在几个值,那么取余的结果容易相同,导致 hash 碰撞的发生。由于 hashcode 函数可以被重写,重写者可能无意识地就写了一个不合理的 hash 函数,导致上面这种情况发生。

为了避免这种情况,HashMap 将 hash 值先向右移位,再进行或操作,这样就使高位的值和低位的值融合成一个新的值,保证取余结果受每一个二进制位的影响。Java 7和 Java 8的原理都一样,但源码有细微出入,可能是因为 Java 经过统计发现移位一次就够了吧。

//Java 7
static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
//Java 8
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

扩容后元素如何进行移动

为了防止元素增多后,链表越来越长,HashMap 会在元素个数达到阈值后进行扩容,新的容量为旧容量的2倍。 容量变化后,每个元素用 hash 值取余的结果也会随之变化,需要在数组中重新排列。以前同一条链表上的元素,扩容后可能存在不同的链表上。

在 Java 7 中,重新排列实现得简单粗暴,直接用 hash 根据新容量算出下标,然后设置到新数组中,即相当于将元素重新 put 了一次。但在 Java 8中,作者发现没必要重新插入,因为重新计算后,新的下标只可能有两种情况,要么是原来的值,要么是原来的值加旧容量。比如容量为16的数组扩容到32,下标为1的元素重新计算后,下标只可能为1或17。

这个怎么理解呢?重提一下之前的一句话,一个数对2的 n 次方取余,就是要取这个数二进制的最低 n 位。当容量为16时,取余是取最后4位的值,而扩容到32后,取余变成取最后5位的值。这里增加的1位如果为0,那么余数就没变,如果为1,那么余数就增加了16。如何取增加的这一位的值呢?直接和16进行与操作即可。16的二进制是10000,与操作后如果结果为0,即表示高位为0,否则为1。

根据这个原理,我们只需要将原来的链表分成两条新链放到对应的位置即可,下面是具体步骤:

  1. 遍历旧数组,如果元素的 next 为空,直接取余后放到新数组;
  2. 如果元素后面连了一个链表,那么新建两条链表,暂且成为 hi 链和 lo 链;
  3. 遍历链表,计算每个元素的 hash 值和旧容量与操作的结果,结果为0则将其放入 lo 链末端,不为0放入 hi 链末端;
  4. 将两条链的 head 放到新数组中,其中 loHead 放到原来的位置,hiHead 放到原来的下标加上旧容量处;
  5. 如果是红黑树,进行和上面类似的操作,只是将两条链表换成两棵树。

理解原理后看代码就很简单了:

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
            //新建两条链表
            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) { //结果为0,表示下标没变,放入 lo 链
                    if (loTail == null)
                        loHead = e;
                    else
                        loTail.next = e;
                    loTail = e;
                }
                else { //结果为0,表示下标要加上旧容量,放入 hi 链
                    if (hiTail == null)
                        hiHead = e;
                    else
                        hiTail.next = e;
                    hiTail = e;
                }
            } while ((e = next) != null);
            if (loTail != null) { //lo 链放在原来的下标处
                loTail.next = null;
                newTab[j] = loHead;
            }
            if (hiTail != null) { //hi 链放在原来的下标 加旧容量处
                hiTail.next = null;
                newTab[j + oldCap] = hiHead;
            }
        }
    }
}