在JDK1.8中,HashMap
引入了红黑树结构。当链表(不包括头结点)长度>=8,且数组长度> = 64时,会触发链表转红黑树的逻辑,以提升查询效率。那么为什么是8?
对话
回答这个问题前,先看一组对话。
我:请问HashMap中什么时候会出现红黑树?
小Z:当哈希冲突严重时,链表长度大于8
我:很好,那么什么时候会哈希冲突严重
小Z:当集合中节点足够多时
我:HashMap不是会自动扩容吗?当负载达到0.75时触发扩容,使负载降低到0.75/2=0.375,如此反复,因此负载会稳定在0.375-0.75之间。怎么会冲突严重呢?
小Z:这。。。如果有人攻击呢?使所有key落在一个槽位上?
我:的确,只有hash相同内容不同的key才能叠加到一个槽位上,这便是著名的HASH DOS攻击
因此,回答这个问题前,要先知道什么时候链表长度大于8?
源码描述
其实HashMap
源码里,作者Doug Lea
已经给出了说明
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 requency of nodes in bins follows a Poisson distribution (http://en.wikipedia.org/wiki/Poisson_distribution) with a parameter f about 0.5 on average for the default resizing threshold of 0.75, although with a large variance because of resizing ranularity. 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
大致翻译下:
因为树节点的大小是普通节点的两倍左右,所以只有当桶中的节点足够多的时候才使用红黑树。当因为删除或扩容,导致桶中节点足够少的时候,又会将树节点转换回普通节点。在使用分布良好的哈希函数时,树节点是十分罕见的。理想情况下,哈希函数是随机的,此时桶中节点出现的个数遵循泊松分布。在默认扩容系数为0.75的情况下,泊松分布的参数平均是0.5。尽管因为粒度调整存在很大差异。忽略差异,列表大小k出现的期望是(exp(-0.5) * pow(0.5, k) /factorial(k))。
…
更多:低于千万分之一
文中提到的泊松分布公式为
其中λ表示单位时间内事件发生的次数(可以理解为随机事件的概率),k表示随机事件发生的次数
代入λ=0.5,k=8
可见,hash函数离散良好的情况下,链表长度达到的8的概率不到千万分之一。
计算过程
首先为什么λ=0.5?
记节点总数size=m,数组长度length=n。在扩容系数为0.75的情况下,size/length=m/n=0.75时会触发扩容。扩容后length=2n,此时size/length=m/2n=0.375。因此在扩容机制下,负载始终在0.375~0.75之间。因此取中间值m/n=0.5。即map中平均有数组长度一半的节点数。
在hash函数足够离散的情况下,节点落在任意桶内可以看做一个随机事件,概率p=1/n。
因此,对于任意桶来说,节点有多少次落在该桶内,相当于连续执行m次随机事件,由于概率不变,因此符合二项分布。
下面公式表示进行m次试验,其中有k次成功的概率,单次成功的概率为p
代入m=n/2,p=1/n,上述公式可以改写成
分别取n=64,128,256,k=0,12,…计算,可得
n=64 | n=128 | n=256 | |
---|---|---|---|
k=0 | 0.604 | 0.605 | 0.605 |
k=1 | 0.306 | 0.305 | 0.304 |
k=2 | 0.075 | 0.075 | 0.075 |
k=3 | 0.011 | 0.012 | 0.012 |
k=4 | 0.001 | 0.001 | 0.001 |
… | … | … | … |
结果与源码中很接近。
又由于当二项分布n→∞, p→0时,可以近似推导为泊松公式。所以当数组很大时,就有了源码中的结果。
结论
正常情况下,链表长度大于8的概率不到千万分之1,谁会使用这么大的集合?显然作者并不想使用红黑树。红黑树的作用仅仅是特殊情况下的兜底措施。
如:
- 令
hashCode
==1,这样所有节点都会落在同一个槽位,哈希表退化成链表。 - 令扩容阈值
loadFactor
=10,一个长度为n的数组,最多可以容纳n*10个节点,相当于一个槽位能分到10个,那必然会转红黑树了。 - 防HASH DOS攻击
因此下面这个回答本身没错,只是在HashMap
1.8里实现不了。
红黑树是一种特殊的平衡二叉树,其时间复杂度是O(log n),链表的时间复杂度是O(n)。在n比较小时,二者差距不大,而转红黑树会额外消耗性能,因此阈值不应过小。另外阈值过大,红黑树的存在就失去了意义。因此8是一个合适的临界值。
附录
HASH DOS攻击
hash dos攻击就是利用哈希表在哈希碰撞时产生的链表来攻击的方法,这种攻击会使哈希表退化成链表,使得系统运行巨慢。
具体操作是,批量产生大量hash相同内容不同的key,写入到哈希表中,这些key会落在同一个槽位,使得产生很长的链表。
一个可能的实现是利用String的hashCode方法,在Java中,String的hashCode方法可以表示为
是对字符数组中的每个元素乘以31的k次方再求和实现的,k只和数组长度和字符索引位有关。
如果能找到两个hash值相同的字符串s1和s2,那么在相同的位数时,对他们的任意排列组合,都会获得相同的哈希值。
Java中也确实存在这样的字符串,如
hash(Aa)=hash(BB)=2112
hash(AaAa)=hash(BBAa)=hash(BBBB)=hash(AaBB)=2031744
hash(AaAaAa)=hash(BBBBBB)=hash(BBAaBB)=hash(AaBBAa)=…=1952508096
当有n位时,就有2的n次方种组合
那么想实现10万个key来攻击,只要n=17即可
Java实现的二项分布、泊松分布
1 | public class PoissonTest { |