聊聊Java中的HashMap实现里有哪些实用的技巧

大家对HashMap应该再熟悉不过了,“八股文”中的高频知识点,代码里也没少使用,不过我们有仔细去分析它的实现里有哪些有价值的内容值得我们去提取吗?今天可以一起讨论下。

散列表知识回顾

首先我们先通过两张图来简单地回顾一下散列表的基础知识:

请添加图片描述
请添加图片描述

这里面包含了哈希函数table哈希冲突,而JDK中的HashMap是一个工业级的散列表实现,还包含了动态扩容的能力。下面我们依次来分析这些在JDK里面的实现以及从里面可以学习到哪些技巧(以下分析基于JDK1.8源码进行)。

1 哈希函数

也许有不少一部分人认为HashMap里面的哈希函数实现就是使用的Object对象的hashCode()方法(这在以往校招或者顾问面试中是有不少人回答的答案),不过我们点源码进去很容易就找到了实现:

    /**
     * Computes key.hashCode() and spreads (XORs) higher bits of hash
     * to lower.  Because the table uses power-of-two masking, sets of
     * hashes that vary only in bits above the current mask will
     * always collide. (Among known examples are sets of Float keys
     * holding consecutive whole numbers in small tables.)  So we
     * apply a transform that spreads the impact of higher bits
     * downward. There is a tradeoff between speed, utility, and
     * quality of bit-spreading. Because many common sets of hashes
     * are already reasonably distributed (so don't benefit from
     * spreading), and because we use trees to handle large sets of
     * collisions in bins, we just XOR some shifted bits in the
     * cheapest possible way to reduce systematic lossage, as well as
     * to incorporate impact of the highest bits that would otherwise
     * never be used in index calculations because of table bounds.
     */
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

这里我们发现它没有直接使用hashCode(),乍一看这样一通操作弄得我们一头雾水。这样操作的表面意义就是将hashCode的高16位和它的低16位进行异或,我们用一个简化版的图示说明一下(hashCode是一个int数值,长32位,我们简化成一个8位的示例):
请添加图片描述

异或在逻辑运算中起到的直观作用就是如果使用的异或掩码位是0的话,被异或的原值不变,掩码位是1的话,被异或的原值反转。现在我们学习到了这个哈希函数的“字面意思”,可以看到原hashCode的高位是不变的,低位发生了一些变化。JDK为何要多这一步操作呢?这里和哈希表的设计是有关联的,这个谜底会在第2节里面解开,在这里大家可以仔细看下注释,将注释里面提到的一些技术点作为问题带入到下一小节来寻找答案。

2 table

按散列表数据结构的定义,最终的数据是保存在一个叫做“table”里的,这个table用一个数组就可以实现,在JDK里由成员变量Node<K,V>[] table 负责保存。现在面临的问题是如何将一个int型的hashCode映射进一个有长度限制的table中。按照常规操作我们会想到用取余的方式,即hashCode % table.length。下面我们来看看JDK里面是怎么写的:

//这里是put方法实现代码里面的一个分支,往table里面插入数据,先判断要插入的位置如果是null的话就直接插入 
if ((p = tab[i = (n - 1) & hash]) == null)       //n是table.length
       tab[i] = newNode(hash, key, value, null);   

这里我们看到它计算table的下标用到的是(n - 1) & hash,怎么回事,这个和hash % n是等价的吗?我们可以简单来证明一下(不是很严谨的数学证明),试想一下我们如果在十进制里计算一个数x相对10的n次幂取余,是不是肉眼一看就可以算出结果来,例如:372892949 % 1000 = 949,直接取后三位。那么放在二进制里面,也有同样的规律,即table.length如果是2的n次幂的话,取余这样的除法就可以用逻辑与运算代替了。下面我们用图来看看(n - 1) & hash是怎么求解的,假设n=64(二进制1000000),hash=1101 1011 1001:

请添加图片描述

从上图可以很直观地看到(n - 1) & hash实现的功能就是截取hash的后几位,JDK通过限制table.length必须为2的n次幂的方式将本来一个除法运算用逻辑运算替代了,因为算下标在HashMap里是一个非常高频操作,这样的优化可以很明显提升性能。

现在我们可以来回答第1节末尾中抛出来的问题了,因为在table.length还不是很大的时候,取下标操作一直使用的是hashCode的低位,就像注释中写到的那样,在某些特殊情况下会出现连续的哈希冲突,所以将高16位和低16位进行异或,让hash的高位部分在通常情况下也能参与到映射table下标计算过程中来,这样充分利用整个hashCode,进一步减少哈希冲突的可能性

到这里关于table的核心实现我们已经基本解释完了,不过还有一个小点我觉得也可以拿出来讲讲。我们在HashMap构造方法是可以设置它的初始大小的,比如我们设置100,按照table.length的限制,这个100是不能直接设置成它的大小的,HashMap里面提供了一个方法将100进行了适当的改写,转换成与100相近的2的次幂数,即128。怎么做到的呢?实际这里可以转换成一道简单的算法题:输入一个int型正数n,求使得2^m >= n成立的m(正整数)最小值。HashMap里提供了以下方法来实现:

    /**
     * Returns a power of two size for the given target capacity.
     */
    static final int tableSizeFor(int cap) {
        int n = cap - 1;           //减1是为了防止cap本身就是2的n次幂,如果通过下面的算法会得出2*cap
        n |= n >>> 1;              //将n右移1位与自己相或,这样自己的次高位不论是0还是1,最后都转换成1了,这样得到一个新的最高位和次高位都是1的新值n1
        n |= n >>> 2;              //将n1右移两位与自己相或,同理可以把次3和次4高位转换成1,得到前高四位都是1的新值n2
        n |= n >>> 4;              //将n2右移四位与自己相或,得到前高8位都是1的新值n3
        n |= n >>> 8;
        n |= n >>> 16;             //最多右移16位,可以将一个不论是多少值的int型转换成0000...0001111...111(前面有若干位0,后面有若干位1)
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;               //上面的行式加1就变成了2的n次幂int数
    }

其实咋一看,上面的算法和我们要解决的问题毫无关联,看的一头雾水,不过如果仔细按照它的实现再结合给的注释拿笔在纸上一步步画一下,就很容易了解它其中的奥秘。实际上这个解法是我们上面那道算法题变型的解,在二进制中2的n次方就是1000…000(n个0),这个不好求的话我们可以求2^n - 1,即111…111(n个1),那这道题就转化成了:将任意一个二进制数从它最高位1开始,一直到最低位全部转换为1。

3 哈希冲突

关于哈希冲突这里就不再多聊了,在以往顾问或者校招面试中,基本每个被问到HashMap相关知识的同学都知道它是用的链地址法解决哈希冲突的,并且在JDK1.8以后,当链表长度超过8,会把链表转换成红黑树,当节点个数小于6时,又会从红黑树转换成链表。在源码里使用TREEIFY_THRESHOLD和UNTREEIFY_THRESHOLD这两个常量来控制。在这里我们只要了解红黑树的查找效率是log(n),相比于平衡二叉树,它没有那么严格的平衡要求,从而在插入删除的时候不会有平衡二叉树那样严格的平衡操作,但是其查找效率仍然能落到log(n)的范畴。 从而在HashMap中这种插入删除很频繁的场景中,使用红黑树可以使得其插入删除性能与查找性能得到一个比较好的均衡。

4 动态扩容

JDK里的HashMap是一个工业级的代码实现,必须有非常完美的性能、容错、实用性。因此,它是支持动态扩容的。在put操作的最终实现方法putVal()里,通过下面方法判断是不是要扩容了。

if (++size > threshold)   //size是HashMap里面已经保存的元素个数,threshold是一个阈值,通过计算得到
       resize();    //扩容的具体实现

在resize()方法里会对旧table进行遍历,依次拿出里面所有的元素重新根据hash映射到新的table中。在重新映射的过程中,主要有三种情况,第一是table对应下标的元素只有一个节点,即这个位置还没有哈希冲突,那么就直接使用上面提到的(n - 1) & hash方式计算其新下标;第二如果对应下标的元素是红黑树的话,则要对树进行拆分后重新映射;第三如果对应下标的元素是超过一个元素的链表,则遍历这个链表并依次计算新下标重新映射。在这里我们一起来讨论下情况三的实现吧,这里也包含了JDK实现的一个小技巧。我们来先看下源码(节选自resize()方法):

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) {      //节点的hash和旧table的长度相与,如果是0,则把节点加入到lo链表,如果不是则加入到hi链表
        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;     //j是遍历旧table时当前的下标值,直接把lo链表映射在新table对应的下标下,即和旧table的下标一样,保持不变
}
if (hiTail != null) {
    hiTail.next = null;
    newTab[j + oldCap] = hiHead;        //把hi链表映射在新table中的往后顺移旧table.length的位置

其实从上面代码的表面意思不难理解在resize的时候对链地址中的元素只是拆成了两组,一组还是停留在当前位置,一组则需要放到扩容后的新位置。其背后的意义就有点隐晦了,不是需要对每个元素都重新计算下标吗,为什么在这里按照这样操作也可以?

还记得第2节中我们说到的table的长度一定是2的n次幂吗?在resize方法里,新的table大小是通过newCap = oldCap << 1这句代码计算来的,即每次扩容后的大小是前一次容量大小的2倍(在不超过MAXIMUM_CAPACITY的前提下)。经过这种巧妙的设计配合,可以通过上面这段代码来重新映射,将原本扩容过程中需要大量计算下标的过程简化成了分类过程。我们可以画一画为什么可以这么实现,假设旧table的长度是n=64,则新table 的长度是2n,在旧table下标为j的位置保存的链表里的元素hash为hi(i表示在当前链表中的位置):
请添加图片描述

如上图所示,在n=64时,按照我们第1节的知识,计算hi 所映射到的下标时就是截取其后6位,在2n时,就是取其后7位,那么多取的这一位(上图中红色虚线框)要么是0,要么是1,不会有其他结果了。如果这一位是0,那么按照(n - 1) & hash 哈希函数公式得到的hi在新table中的位置和旧table中的位置是同一个,可以不用移动,假设这个值是j;如果这一位是1,那么这个元素在新table中的位置是j + n(用二进制很容易看出规律)。有了这样的推理,上述代码的实现是不是就很清晰了?所以JDK通过这样巧妙的设计进一步减少了resize函数的计算量。

5 总结

在这里分析了JDK中的HashMap实现里的几个小技巧,它通过限制table.length,限制扩容容量等措施构建了基础特性,然后再根据这些基础特性巧妙地设计了哈希函数、散列规则等,将HashMap的性能优化到了极致。将算术运算等价成逻辑运算能明显节约计算资源,提升代码性能,所以纵观HashMap的实现,全都是运用到了这种优化手段,非常值得我们去学习。

那么这样的优化方法我们可以运用在哪里呢?在分库分表过程中,我们一般也是用取余的方式来进行分片,那么参考HashMap的实现,这里是否是有可以优化的空间?我们按照HashMap的方式来约定分库分表的大小,那么在未来万一涉及到扩库或扩表的话,是不是也可以模仿HashMap的resize过程来进行扩容?

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值