针对于HashMap的(n-1) & hash的研究和拓展思考

Posted 写Bug的渣渣高

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了针对于HashMap的(n-1) & hash的研究和拓展思考相关的知识,希望对你有一定的参考价值。

本文概览:

简单, 直白的看一看为什么 HashMap 中存在 (n-1) & hash 以及 n & hash.
思考题: 在 HashMap 中 hash 和 HashCode 有什么关系, 他们是一个东西吗吗?
HashCode 的两次扰动

本文能解决你的哪些问题?

  • HashMap 中 hash 和 hashcode 的关系分析 (这里很多博主没有理清楚)
  • 为什么插入的时候使用 (n -1) & hash 但是扩容的时候, 将数据进行分散 rehash (), 为什么又要使用 n & hash
  • 你了解 rehash ()? 方法的目的吗, 真的是重新计算 hash 吗? 真的不是计算其他的值吗?
  • HashMap 中的 hash 冲突, 指的是 hash 值完全相同吗??? 亦或者是 hashCode 相同

先来看看思考题吧

HashCode 是什么?Hash 又是什么
>前提: 需要了解, 我们使用 hashmap, 如果要提高他的性能, 是不是想让元素尽量的存储在 hashMap 的数组的不同位置上, 这样才能最大利用 Hash Map 来高效存储数据

看看 HashMap# int hash (),

当我们传入一个 key 的时候, 需要调用这个方法来计算 hash 值

    static final int hash(Object key) 
        int h;
        // 冷知识, key如果==null,存放在表的第一个位置
        // 至于为什么需要 异或运算 ^,先继续看
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    

可以看见, 这个 hash 值, 在 HashMap 是由 hashCode 计算得到的, 所以说 hash != hashCode, 这一点在后面对于理解来说至关重要.
那么 <hash值> 有什么作用呢?
– 计算数据映射到 Hash Map 数组的位置

来看看插入

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) 
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
        // 注意这里n是hashMap数组的长度
            n = (tab = resize()).length;
        // 你只需要看这里,插入的时候,使用(n-1) & hash得到一个i
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
		.... 下面的源码不展示了
    

这个 (n - 1) & hash 得到的是啥? 得到的是元素映射到 hashMap 数组的索引

先思考为什么能映射到数组的索引上, n-1 & hash,
例如 n == 16, 此时 n -1 == 15, hash 为 32 位数字.
此时 (n-1) & hash,
1111
00000000 00000000 00000000 11111111 (共 32 位)
此时结果就是 <hash的后四位吧>, 如果长度扩容到 32 了呢, 32 - 1 == 31, 31 的二进制是五位 <结果就是比较hash的后五位吧>
思考思考, 长度为 16, 四位二进制最高多少, 是不是 15, 同理
也就是说, (n-1) & hash 能让结果落在数组长度范围内

再来帮你梳理一下, n 是哈希表长度, 为二的倍数, 此时 (n-1)一定就是全 1, 只不过长度不一样, 15 是四个一, 31 是五个 1.
只要 n 为二的倍数, 那么 n-1 就是全一, 只不过位数不一样.
结果就是, 我们得到的 hash 是决定数组映射到数组哪一个位置上面的关键, 而最重要的是 hashCode 的后 x 位数字, 数据的插入到哪比较这个

让 hashcoe 的后 x 位变得乱七八糟!!

我们的目的是啥, 想让插入数据平均分布对吧, 那么我们就需要让 hash 变得没有规律, 才会平均的放置到数组的不同位置上.
此时, 就有两个操作, 一个是
h = key.hashCode()) ^ (h >>> 16 ) // h 为 32 位,>>>是右移 16 位, 此时高 16位移动到低 16 位, 然后这个二进制又和 key. HashCode () 进行异或操作, 这里的操作是不是 很乱, 很乱就对了啊, 结果也会乱, 也很随机
然后就是 (n-1) & hash, 我们刚才说了啥, 是不是比较的就是 <hash后几位>, OK, 现在目的达到了, 结果变得很乱, 那么数据的分布也就变得分散了.

上面你已经知道了, 得到一个元素的索引, 其实看的就是他的 hash 的后 x 位,.
这里很重要, 很多博主都没有理清楚, 大家如果能看到这里, 其实也都懂数据结构中的哈希表是什么(这里指的不是 HashMap, 只是普通的哈希表数据结果), 我们所说的哈希冲突, 指的就是哈希冲突, 也就是哈希值相同, 导致两个元素需要放到一个链表上面.
<经过上面的分析,你觉得HashMap中的哈希碰撞,是hash相等吗??,哈希冲突本质上在hashMap中其实是 hash的后x位数字相同,>
举例: 比如说 n == 16, n - 1 == 15 对吧, 此时我给你两个不同的 hash 值, 你来计算看看结果是啥
Hash 1 : … 前面省略 24 位 00001111
Hash 2: … 前面省略 24 位 11111111
结果显而易见, 他们俩 hash 值相同, 但是 ( n-1) & hash 都相等啊, 也就是说两个不同的 hash 值的数据, 如果后 x 位 (这个 x 的长度为 n-1 的二进制长度)

你怎么还没讲到 n & hash 啊, 马上就讲了, 马上就讲了

不过啊, 还得了解一个事情, 就是扩容是如何扩容的?
首先我默认你知道 HashMap 每次扩容两倍? 然后 HashMap 扩容之后还会把一些数据映射到其他位置对吧, 也就是 rehash ()

    final Node<K,V>[] resize() 
		 ... 前面的源码就不看了
        if (oldTab != null) 
            for (int j = 0; j < oldCap; ++j) 
                Node<K,V> e;
                // 遍历HashMap数组上的每个位置
                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);
	                // 如果数组的该位置是链表!!!!!!,就要尝试把他们分开!!,也就是让数据更加分散
	                // 这里一定要看,我没有把这部分放在正文,如果放在正文你可能翻着看源码很麻烦
	                // 先讲一讲下面的步骤,定义两个链表,lowHead低链表,hiHead高链表.lowHead存储 e.hash & oldCap == 0 的元素,也就是链表上的数据,如果其 hash & oldCap 就追加到lowHead链表上
	                // 同理,如果链表上的元素 e.hash & oldCap == 1,那么就追加到hiHead上面
	                // 最后 低链表存储到原位置,而高链表放到 原位置 + 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) 
                                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); // 别忘记这里有一个do-while语句遍历链表中的元素啊,这样才能判断链表的每个元素
                        if (loTail != null) 
                            loTail.next = null;
                            newTab[j] = loHead;
                        
                        if (hiTail != null) 
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        
                    
                
            
        
        return newTab;
    

开始分析啦:
为什么需要把原来产生 hash 冲突的数据, 还要分开呢?
为什么分开是对 e.hash & oldCap 呢??

第一个问题: 不知道你有没有看到我上面的解释, HashMap 中的哈希冲突, 是 hash 的后 x 为相同 (x 的长度为 n - 1 的二进制的长度).
下面的推理如果你不看上面的内容你就不懂哦, 首先直接举例吧 ,HashMap 数组长度为 16, 那么比较 hash 后几位呢, 是不是后四位
如果长度为 31 呢, 是不是比较的后五位.
同理, 如果从 15 扩容到 31, 是不是如果要知道数据的位置, 是不是要通过 (n-1) & hash 才能得到, 但是在扩容之后 n 也增大了两倍, 一个元素的存储位置的判断, 此题由比较 hash 的后 4 位变成了比较后 5 位对不对??? (你可以画一画, 然后联系我上面的推断, 数据存储在数组的位置索引取决于 hash 的后 x 位)
<结论就是,从n到扩大到2n的过程后,也就是从比较后x位到x+1的过程!!>
然后就会产生一个问题, 原本只比较后 4 位, 两个元素的后 4 位相同, 此时产生了哈希冲突.
但是扩容之后呢? ,你确定原本后 hash 四位相同的两个元素,后五位一定相同吗?
<要将一个链表的数据尝试分散的原理是:扩容之后,需要多对比 hash 的更高一位,而这一位,如果 e.hash & oldCap == 0 则代表无论比较后四位还是后五位,都不会影响映射的结果,也就是不会影响 (n-1) & hash, 但是如果 == 1 呢?, 是不是再计算索引的时候, 会多比较一个 hash 中的数字, 此时元素的索引 index = (n -1) & hash 就发生变化了, 需要移动, 而移动多少位, 你可以看看下面

上面讲了好多, 再放一块讲可能就乱了. 你看到这, 已经知道了, 为什么扩容后需要去判断 e.hash & oldCap 了, 如果结果 == 0 的元素, 那么就放到原位, 如果结果 == 1 的元素就移动到 <原位置+oldCap上>
来分析为什么要移动 oldCap:
例如: Hash Map 的数组长度为 16
n -1 = 15 ,此时的插入元素, 比较的是 hash 的后 4 位没问题吧
扩容后
n -1 = 31, 此时插入元素比较的是 hash 的后 5 位没问题吧.
而此时获取元素, 也是 (n-1) & hash 得到的吧, 比较的是后五位得到的, 而原先需要移动 oldCap 的元素有什么特征?
— 没扩容前, 需要比较后四位, 扩容后呢? 比较后五位
然后看看两者的 & 运算得到的元素索引产生什么区别
1111
… 省略 24 位 00001111 == 15

                       11111

… 省略 24 位 00001111 == 15 +
分析: 扩容后, 再 & 并运算中, 多了一个 1, 也就是说扩容后该元素正确位置应该是 <原索引位置 + 10000 & hash> 即可, 这个 10000 的最高位 1 代表的就是扩容后长度 32-1 == 31 的二进制最高位, 但是又仔细想想, 31 的最高位难道不是和 16 的二进制最高位一样吗???

拓展:
rehash 是啥?, 很多博主都是认为 rehash 是进行重新计算哈希, 然后重新定位到一个新的位置上. 也就是我上面说的过程, 但是你看过之后, 真的是重新计算哈希吗?
只是计算索引时, (n -1) & hash 会多比较一位, hash 值并没有重算, 并且并且啊,hash 是存在于 Node 中的, 根本就需要重新 hash
rehash 应该说是 reindex ()

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;
	

总结一下文章的内容吧

  • HashMap 中的 hash 是由 hashCode 经过 hashcode ^ (hash >>> 16)得到的, 而元素的存储位置是由 (n-1) & hash 得到的
  • Hash Map 中的哈希冲突指的是在当前容量下, hash 的后 x 位相同, 即认定为 HashMap 中的哈希冲突,x == 数组长度-1 的二进制长度;
  • (易错)很多博主认为 HashMap 中产生哈希冲突的两个元素是 hashCode 相同或者是 hash 相同, 其实都不对啊, 我看了无数的博客, 大部分博客都是这么写的
  • 在 HashMap 中, hash 冲突指的不是两个元素 hash 值完全相同
  • rehash ()的含义应该是 reinde

x ())

以上是关于针对于HashMap的(n-1) & hash的研究和拓展思考的主要内容,如果未能解决你的问题,请参考以下文章

针对Sharding DB的单点故障,合理构建HA架构

RabbitMQ 权限分离&HA操作文档

HashMap源码分析

LinkedHashMap源码分析

NameNode HA实现原理

HashMap:为什么容量总是为2的n次幂