29

jdk1.8中的hashmap作了很多改进:红黑树的引入,链表尾插,以及底层数组长度保持2的次幂。本文专注于分析2次幂设定的原因,且听我慢慢道来……

与“取余”等价的算法

众所周知,hashmap是数组链表结构:hash算法用于将key散列,经计算分散到数组槽中;而两个key算出了同样的值,即产生hash冲突时,就需要将槽中的单个节点升级成链表。由于get时需要对链表其进行遍历,链表越长检索效率越差。那么,计算出的key值落点越平均,hash冲突的可能性越小

key值落点的计算方式为,key的hash值数组长度取余操作,记作key.hascode % array.length。从数学角度考虑,保持array.length为质数会使得计算结果更均衡,hashTable就是这么做的(数组初始值11)。但 hashmap 中 array.length 偏偏选择了2的次幂,是个合数……何故?完全出于性能考虑!
先给出结论——当 array.ength长度是2的次幂时,key.hashcode % array.length等于key.hashcode & (array.length - 1)。下面重点看下这个结论是怎么得出来的。

举个例子:
假如 array.length = 2^4 = 16,二进制10000。这个数减去1的结果是1111,也就是array.length -1 = 1111。
(下面这段中的数字都是二进制)
再假设一个key的值为10011011001(很随意写的一个数),与1111做 & 操作,得到的结果是1001(高位部分1001101都舍去了)。而1001必然是一个小于10000的数,对于一个小于10000的数而言,1001 % 10000得到的就是1001自己。
那么刚刚舍弃的高位部分1001101 0000(后面补上了四个0000)就一定能被10000整除吗?答案是肯定的:因为10011010000可以拆成10000000000+10000000+1000000+10000,这几个数都能通过10000的n次左移得到,也就相当于这几个数都能被10000整除。那他们的和,也就是10011010000,一定也可以被10000整除。
因此,最终结论就是:10011011001 & ( 10000 - 1 ) = 10011011001 & 1111 = 1001 = 10011011001 % 10000

放张简图再唠叨一遍以示总结,加深下印象:
clipboard.png

再强调一次:当 array.ength长度是2的次幂时,key.hashcode % array.length等于key.hashcode & (array.length - 1)

好,如果你读懂了例子部分,相信你已经基本明白这个结论是站得住脚的(虽然不是纯数学型的讲解)。那么hashmap的作者Doug Lea大神,为什么如此执着于用&操作替换%操作呢?
因为对于二进制生物计算机来说,& 的效率要高于 %!(与、或、非都可看作二进制基本操作,同或、异或次之,+ - * ÷ % 等都基于前面的)

扩容时方便定位

这还不算完,好处不止这一处。
当hashmap需要扩容,重新计算链表元素的hashcode,以进行元素的重新定位时,依然能从“ 数组2次幂 ”的这个设定中借力!

hashmap数组扩容时,新数组length = 原数组length * 2,沿用前面的例子(array.length = 2^4 = 16,二进制10000),array.length 乘以 2 ,即二进制左移一位,由 10000 变成 100000。此时需要重新计算数组槽中的元素位置,如果槽中是链表,链表中每个元素都需要重新计算位置(这里不考虑红黑树)。

计算的公式不变,key.hashcode & (array.length - 1),由于数组的翻倍(10000->100000),导致 array.length - 1 发生了改变(1111->11111)。此时,扩容前原本被舍弃的高位部分的最后1位,也将参与计算。
clipboard.png

在扩容这个历史的拐点,这一位就显得很特别:如果这个位置是0,余数计算的结果将保持不变,意味着扩容后此元素还在这个槽中(槽编号没发生改变);如果这个位置是1,余数计算结果就变成了原槽索引 + 原array.length
也就是说,hashmap扩容的元素迁移过程中,由于数组大小是2次幂的巧妙设定,使得只要检查 “ 特殊位 ” 就能确定该元素的最终定位。

给出一个较完整的扩容示意图进行说明:
clipboard.png

  • 扩容前

红绿黄三个元素,由各自的hashcode取余后都淤积在数组槽13,组成链表形式

  • 扩容后

红、绿二星所表示的元素的hashcode“ 特殊位 ”为0,取余依然定位在槽13;而黄星表示的元素,hashcode“ 特殊位 ”为1,取余后结果 = 原槽索引 + 原数组大小 = 13 + 16 = 29。(这个结果也和图中黄星的hashcode二进制低位值11101一致)

总结

对hashmap而言,数组长度始终保持2次幂有两点好处:

  1. 能利用 & 操作代替 % 操作,提升性能
  2. 数组扩容时,仅仅关注 “特殊位” 就可以重新定位元素

性能,性能,还是性能……


青鱼
268 声望25 粉丝

山就在那里,每走一步就近一些