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);
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY