hashMap 底层原理

hashMap 的底层结构

  • 在jdk1.7:数组 + 链表
  • 在jdk1.8:数组 + 链表 + 红黑树

属性

hashMap 有很多属性,可以帮助理解hashMap结构

属性 含义 默认值
DEFAULT_INITIAL_CAPACITY 数组默认长度 1 << 4
MAXIMUM_CAPACITY 数组最大长度 1 << 30
DEFAULT_LOAD_FACTOR 扩容因子 0.75
TREEIFY_THRESHOLD 链表转红黑树阈值 8
UNTREEIFY_THRESHOLD 红黑树转链表阈值 6
MIN_TREEIFY_CAPACITY 转红黑树数组最小长度 64

为什么数组长度是2的倍数?

先计算key的hash值,根据hash值确定其落在数组的哪个bucket,这个是怎么计算的呢?hashMap使用了很巧妙的算法。
首先确保数组的长度是2的倍数,作为演示假设长度为16,16的二进制表示为(按8位长度):0001 0000,将其减1,变成 0000 1111,正好凑出了4个1;假设要添加的key的hash值为 1101 1100。将两者逻辑与

0000 1111
1101 1100
----------------- 逻辑与
0000 1100

发现上面的规律了吗?结果的后4位取决于hash值!为什么说是正好4个1?因为数组长度是16,4位有16种组合可能,加上hash可以让结果随机平均的分配的特点,正好可以让key可以随机但平均的分配进这个 table 数组。
这不是巧合,因为2的n倍,需要 n+1 个二进制表示,减1正好是 n 个 1,借助逻辑与“都为1才为1”的特点,完成了这个算法。这就是为什么数组table要是 2的倍数。

p = tab[i = (n - 1) & hash] //在put方法中就有用这个计算方法确定新添加的key应该在数组中的位置

数组最大长度为什么是1 << 30

根据上面的推导,数组长度需要2的倍数,而数组的长度用int类型表示,32位的int类型的最高位又是符号位,所以满足条件的只能在第31位上放1,二进制第31位是1,就是2的30次方。
或者这么理解:1占用了第1位,符号位占用了第32位,那么中间就只剩下了30位,最多也就右移30位。

链表转红黑树,为什么是8?

这个在 hashMap 源码里说明了原因:

Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million

翻译一下:

  • 红黑树占用空间大小大于是普通链表的两倍
  • 在理想的情况下,随机hash的分布情况符合泊松分布
  • 在同一个桶出现9次及以后的概率非常小

为什么扩容因子是0.75?

在时间和空间权宜之后做出的考虑。
如果扩容因子设定为1,那么扩容阈值比较高,会导致链表比较长。而我们知道链表的时间复杂度是 O(n),因此会降低 hashMap 的性能。
如果扩容因子设定为0.5,那么阈值过低会导致扩容较为频繁,扩容又是比较耗费性能的事情。而且浪费了剩下一半的空间。

怎么找到大于初始化容量的2的倍数的最小值?

如果在new HashMap时指定了初始化容量,那么底层会自动找大于该初始化容量的最小的2的倍数作为数组table的长度。
具体依靠 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;
}

怎么理解上面的方法?例如我们初始化指定了容量为17,二进制表示为 0001 0001

0001 0001
0000 1000   右移 1 位
-------------  逻辑或
0001 1001

0001 1001
0000 0110  右移 2 位
------------  逻辑或
0001 1111

参考上面的运算可以总结规律,17的二进制有效最高位1(右数第5位)再跟着运算(右移并逻辑或)在一点点“填充”其后面的位数。填充完毕后再加1,就是32了。
再考虑到表示table数组的容量使用的是int类型,最大也就31位(最高位符号位),1、2、4、8、16右移后正好适合第31位为1的最大值容量。
那为什么一开始需要减1?考虑初始容量正好是2的倍数,那就是扩大了一倍,16变成了32,32变成了64。根据规律,减1并不影响最后的结果,而2的倍数减1后,依然是原来的倍数。

hashMap计算hash为什么要左移16位?

当数组 table 的长度比较小,例如16只需要4位表示,而hash值有32位,只取了hash值最后的4位,没有充分利用hash的随机性,可能会导致分配结果不平均,所以左移16位并逻辑异或,是为了让hash值的高位也参与到计算中。

return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

从上面的代码中可以看到,hashMap是允许null作为key的。

如何扩容,要不要重新hash?

扩容只会是原来长度的2倍。

会不会重新hash

不会。因为扩容后的长度是原来的2倍,可以推出原来的node在新数组的位置只有两种可能:原位或原位+长度。
假设原数组长度还是16,16的二进制表示为(按8位长度):0001 0000,将其减1,变成 0000 1111,hash值为:1101 1100

0000 1111
1101 1100
----------------- 逻辑与
0000 1100

扩容后新数组长度32,二进制减1后变成:0001 1111

0001 1111
1101 1100
----------------- 逻辑与
0001 1100

会发现最后的结果,就最前面有变,而这只取决于hash值。如果右数第5位是0,那么位置不变;如果右数第5位是1,那么就是原位+16,16就是第5位也就是2的4次方。
这个不是凑巧,简单理解一下就能会发现这是很巧妙的算法。

那怎么确定一个key到底需不需要+16呢?只需要将hash和16逻辑与即可,就可以知道第5位逻辑与后到底是不是1

0001 0000
1101 1100
----------------- 逻辑与
0001 0000

如果是0,表示原位。非0,表示原位+16。仔细品,又是个巧妙的算法。

得到一个新数组newTab后,长度是原来数组oldTab的2倍,然后把oldTab的元素转移到newTab上。这里会有 4 种情况

没有元素

最简单,就跳过不用管。

单个Node

因为每个Node对象不仅存着key 和 value,还存在key的hash值,所以只需要计算hash值在newTab中的位置即可。

链表

如果是链表,遍历链表重新计算其在新数组的位置。

红黑树

也需要遍历计算其在新数组的位置。

Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab; //将table改成newTab,此时hashMap为空
if (oldTab != null) { // 如果原来数组为空,那就没有必要折腾,扩容本来就很费性能
  for (int j = 0; j < oldCap; ++j) {  // 只需要遍历原数组
    Node<K,V> e;
    if ((e = oldTab[j]) != null) { // 如果当前节点为空,跳过
      oldTab[j] = null;  // e引用节点后,原数组就可以置空了
        if (e.next == null)  //next为null,表示是单node
          newTab[e.hash & (newCap - 1)] = e; // 直接把node移动过去
        else if (e instanceof TreeNode)  // 是个红黑树
          ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
        else { // 是个链表
          // 划分低高位链表,直接将这两条链表挂到newTab上即可
          // 低位链表位置不变;高位链表位置+数组长度
          Node<K,V> loHead = null, loTail = null;  // 低位链表
          Node<K,V> hiHead = null, hiTail = null;  // 高位链表
          Node<K,V> next;  // 下一个node
            do {
              next = e.next;
              if ((e.hash & oldCap) == 0) { // 低位
                if (loTail == null)
                  loHead = e;  // 如果为null,表示低位链表为空,将头尾指向同一个node
                else
                  loTail.next = e;
                  loTail = e;
              }
              else {  // 高位
                if (hiTail == null)
                  hiHead = e; // 如果为null,表示高位链表为空,将头尾指向同一个node
                else
                  hiTail.next = e;
                hiTail = e;
              }
           } while ((e = next) != null);  // 循环遍历到最后
           if (loTail != null) {
             loTail.next = null;
               newTab[j] = loHead;
           }
           if (hiTail != null) {
             hiTail.next = null;
             newTab[j + oldCap] = hiHead;
           }
         }
      }
  }
}

jdk8为什么改成尾插法?

第一个很容易想到的问题,尾插法容易计数,等达到成树的阈值8时,就可以直接转成红黑树。
还有一个隐藏的问题,jdk7的头插法在多线程扩容时存在循环链表的问题。在线程1扩容数组,并将链表转移到先数组的时候,线程切换;线程2扩容完成了头插法,就会发现原先的顺序倒转了;回到线程1,继续进行头插法,就又倒转顺序(就是node的next指向),形成了循环链表。

hashMap的线程安全问题?

当put一个值,已经计算出了hash值并定位了位置,没有等将其挂载到数组table上,cpu时间片到期切换到另外一个线程,它同样put一个值,恰巧挂载在同一个位置上。回到刚才线程,完成挂载,会发现第二个线程put的值被覆盖丢失了。
解决:

HashMap hashMap = new HashMap();
Conllections.conCurrentMap(hashMap);
posted @   homea  阅读(76)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
点击右上角即可分享
微信分享提示