内容一样的String在HashMap中散列的过程和结果

探究String和HashMap中的Hash()问题

  • 先来认识一下相关的源码
  1. 首先String本身是final的,其字符串的实现原理就是实现了CharSequence接口,并且将字符串内容保存在了自身内部的一个final的char数组中,另外,String内部有个私有的int常量值,hash值,注意该值是直接作为内部量保存的,默认值为0。其实,哪怕第一次接触String,也应该想到,保存hash量,就相当于一个缓存,保证了在计算出hash值后,以后每次获得hash值的时间复杂度为O(1)。

 

  1. 看String内部的重载构造方法(他有很多构造方法)之一,该方法参数为一个String实例(以下简称该参数为original),看方法内容,可以明显的看出,该方法将original的char数组直接赋给了String,并且将Hash值也进行了传递。

 

  1. 来看String内部的hashCode()方法,首先明确的是,该方法的返回值就是int类型的hash值。当String内部的hash值不为0(不是初始值的时候),方法没有任何运算直接返回内部的hash值,时间复杂度O(1)。但是如果调用方法的时候,内部维护的hash值为0(默认初始值),那么就要进行String重写的计算Hash值的逻辑,计算出hash值,并且保存在其内部的变量this.hash中。由此,得出一个结论,String内部的Hash值,最多只计算一次,当且仅当内部hash值为初始值的时候,一旦计算完毕,将其保存,以后所有调用hashCode方法的地方,直接返回。

 

  1. 此时来分析这个hashCode()中的计算hash值的算法,算法内容比较简单,直观的看到就是根据内部的常量char数组进行简单的数学运算得到一个逻辑值。这显然不应该是我们花心思的地方,我们来研究一下这个算法为什么这样设计。

首先这个算法的数学表达式是这样的:

 

 

①首先选择这样的一个算法计算,最直观的感受就是其让String的char数组中每一个char都参与到了对Hash值的计算。并且最有意思的是,每一个char元素参与算法的维度各不相同,在数学表达式上可以直接看出,s[i]*31^[n-(i+1)]。这是上述数学多项式中的每一项的计算方式,当i值不同的时候,也就是对应char数组中每一个单独的char元素的时候,其所对应的31^[n-(i+1)]也是不同,因为31^[n-(i+1)]本身就是一个与i相关的线性表达式。于是,这条表达式的目的就出来了,让每一个char元素都影响最终的计算结果,并且每一个元素的影响力(乘法维度)还都不同,目的是,减少不同String的hash值的冲突率。

 

  1. 简单探究了数学表达式之后,我们来研究这个奇怪的数字31,世界那么大,为什么这个多项式的魔数选择了31?

①首先,需要知道的是,肯定是选择奇数,首先排除偶数。因为对于任意偶数来说,都可以转换成2^n*x。例如12=2^2*3。而对于程序来说,对2^n的乘法操作就相当于向左移位n次,在底层二进制世界中,向左移位必然会涉及到末位补0(最右端)。如此一来,任意数跟一个偶数向乘的结果中,最后n位都是一样的,且都为0。(这个n就是上述分析中该偶数所转换的2的次幂)。显然,这样做直接舍弃末尾n位的散列,其均匀性大大降低。

 

②我们确定了舍弃偶数选择奇数的时候,在数学量级上,其实还是有无穷多个数值供我们选择,此时,对于具体的业务分析,可以肯定的是,String中的hash值是int类型的,而字符的ASCII码值都是较小的,所以当选取的这个数较小的时候,造成的结果必然是计算出的最终hash值会都会比较小,这样的散列结果也会造成大部分的String解析的hash值最终集中在一个较小的范围内,显然设计上是不理想的。那么当选取的值比较大的时候呢?毫无疑问,此时的Hash值虽然解决了集中性的问题,但是必然由于Hash值过大,会造成数值越界,要知道在数学表达式中是对这个选取值的次幂进行操作的,其最大项的计算必然会非常大,显然此时也是不理想的。所以,最终的选择必然是不算大也不算小的一些值。

 

③顺着上面的思路,不算大,不算小的奇数也有很多个,假如这个不算大不算小的范围在一个区间[25,50]内,也有很多选择值的,那是不是所有的值都合适呢?此时需要注意的是,31恰好是32-1,而32恰好是2^5,这种选择所带来的计算机运算时相当合适的,因为我们可以进行转换31*X = (32-1)*X = 32*X-X。这样的好处是什么呢?32*X可以直接使用移位运算,由此,上述表达式可以直接使用移位运算和减法来代替乘法,这是一种优化。

④其实,还有一个数学层面的原因,那就是我们的选择其实在奇数的范围上还可以更小,那就是选择素数(质数),用素数作为散列的大小是比较明智的,因为这样能保证在数组大小,散列的乘数和可能的数据值之间不存在公因子。

  1. 现在来看String内部重写的equals方法:

 

①首先该方法先比较引用,即java对象实例的地址,如果地址一致的话,是直接返回true的,此时无任何时空消耗,时间复杂度O(1)

 

②equals的核心实现逻辑,就是比较内容是否一致,所以当地址不一致后,就要对比内容(地址一致的话,内容肯定一致)。所以图中标注的第二步和第三步进行了内容的比较,实现算法通俗易懂,利用两个各自的char数组,循环遍历对比两个数组中相同索引处是否一致,一旦不一致,直接返回false。所以说,当两个不同地址的String进行equals判断的时候,时间复杂度是O(n),由于char数组是直接引用的String内部的char数组,没有额外的消耗,所以其空间复杂度为O(1)。

 

③这里说一个有趣的事情,就是String的equals方法的参数是最上层基类Object,也就是说调用String的equals方法,其可以传递任意类型的参数,而不出现错误,只是在非String实例的时候,直接返回false。

 

  • 再来分析HashMap对key为String时的处理

 

  1. 直接来看HashMap的get()方法

这里面很简单的逻辑,设计两个核心方法:

①先来看这个hash()方法

在这里,我们来看当key为String时候的情况:首先,调用String的hashCode()方法,上述仔细的分析过,hashMap中又做了一层处理,即h^(h右移16位),注意,h始终是一个int类型的,四字节三十二位。

 

②这又出现了有趣的方法h^(h>>>16)。现在我们来研究这个方法的作用。首先要知道这个符号“^”是干嘛的,在数学表达式中,或者我们常规理解中,他是次幂运算,但是在java中,他是逻辑位运算,具体运算规则是“按位异或”。异或的规则很简单,二进制下,相同的数字得出结果为0,不相同的数字得出结果为1。此时回过头来看这个表达式的设计,直观的感受就是,int下的32位,让高16位和低16位进行一种“异或”的逻辑运算。

 

③想要了解这样设计的目的,首先要看一下当计算完Hash值之后要干嘛,很显然,当HashMap计算完hash值之后,并不是直接去找对应位置的,中间穿插了一步取模运算。首先,这样做也是有其考虑的,不妨设想一下,如果直接以hash值找下标,那么hash值不同是肯定不会冲突的,但是做了一步取模后,不同的Hash值对数组长度取模后的结果是可能冲突的,为什么这么做,很简单,Hash值的范围太大,数组长度有限。

 

④此时不妨以hashMap的基础数组长度16为例,来一点一点深入分析。

首先,数组长度选择2的次幂数值,这样除了可以使用与运算取模的优化外,还有其数学意义上的考虑,因为(2^n - 1)这样的数在二进制下恰好为最后n位全部为1。以16为例,16是2^4,那么数组最大小标是15,其二进制恰好为(28个0)1111。当一个Hash值与这个低n位(计算机科学将其称为“低位掩码”)的值做与运算的时候,hash值的前28位的值就一点用都没有了,因为无论你前28位的值是多少,跟你做与运算的值都是0,其结果也必然是0。而低位掩码后n位又全是1。于是,结果就出来了,这个与操作其实就是截取了hash值的最后n位,而直接忽略了前(32-n)位的值。这其实也从本质上解释了为什么与操作的结果和取模运算一致。

⑤理解了上述原因,敏感的嗅觉告诉我们,前(32-n)位直接被忽略,这将大大增加我们的冲突率,直接减少了前(32-n)位存在的意义。HashMap也好,任何类的HashCode()方法也好,设计的本质就是增加随机率,增加自身hash值的特异性,回顾之前的String的hashCode()方法,其让每一个字符参与到运算中,并且每一个位置的维度还不一样,这就大大增加了其最后结果是专属于自己的。我们最理想的结果就是希望这个hash函数最后的结果是一对一的,当然了,做不到一对一,当一对n的时候,我们希望这个n值越小越好。所以上述的问题,直接将前(32-n)位放弃,这就大大降低每个对象进行hash算法的结果的特异性,解决办法就是我们必须让被抛弃的32-n位参与进来,那么怎么做呢?哈哈,上述的表达式的目的就是如此h^(h>>>16)。

 

⑥此时再来分析h^(h>>>16)。高16位和低16位进行运算,目的是为了,让高16位参与到对最后结果的运算中,这就会大大增强自身Hash值的特异性,从而避免hash冲突。那么这样运算的结果中其实还是有16位逻辑值的,当数组长度为默认16(2^4)的时候,还是有12位参与不到结果中,不过需要注意的是,我们的数组长度不总是16的,当数组长度越大的时候,其h^(h>>>16)所降低的冲突率越好,最理想的就是数组长度为2^12。显然这只是个理想值,因为这样的数组有些太大了。

 

⑦对技术的追求告诉我们,⑥中分析的的确是个问题,即使做了h^(h>>>16)(以下简称扰动算法),当数组长度不是太长的时候,还是会有高位的浪费,那为什么不继续做扰动算法,再进行一次缩减将结果缩减到8位,甚至是4位,其实这个考虑是合理且正确的,阐述一个事实就是,以上所有的分析都是基于java8进行的,令人意外的是,java7中就解决了这个疑惑,java7中hashMap的hash算法中将原实例的hash值进行了多次扰动,现在来看算法:

首先我们根本没有要纠结具体的扰动逻辑,只知道他进行这几次扰动的目的和java8是一致的,就是想让hash值上的所有位都参与进来,增强特异性,从而减少hash冲突,但是在经历这么多次扰动后,是会有额外的计算消耗的。并且,需要知道的是,二进制下做一次异或运算的确能让两个数字参数进来,但是做多次异或运算的意义并不大,因为最后的结果只有1或者0,虽然让多位数字参与进来,但是结果并不会因为参数位变多而更加散列,举个简单的例子,四个0和四个1的异或结果是一致的,哪怕本身四个1和四个0的这四位数字完全不一样,但是结果却是一样的。所以,多次异或的意义几乎没有,并且多出来额外的消耗。

 

  • 本文的重中之重h^(h>>>16)或许并没有实现其想要实现的目的

①仔细回想h^(h>>>16)这个扰动算法,我们仍旧以数组长度16位(其实这个逻辑跟数组长度多少并没有很大的关系)例子进行说明。当数组长度16时,即2^4,其低四位掩码位四个1,其余全部为0。当一个hash值进行完这个扰动算法后,决定其最后索引位置的一共八位,即高八位中的后四位(下面简称aaaa)和低8位中的后四位(下面简称bbbb)。我们来看,当aaaa^bbbb之后,肯定得到一个四位的计算结果(简称cccc),这个cccc的范围只有[0,15],毫无疑问,我们不妨把这种异或运算当成一种映射关系,我们从概率学上分析这个映射关系,二进制下的单位数字只有0和1,并且两个二进制单位数组异或只有四种情况(00.01.10.11),其结果也只有两种情况(0.1),令人遗憾的是,每个结果的概率是一致的,都是二分之一。那么,我们可以得出结论,当aaaa和bbbb异或得到的结果映射到cccc之后,得到具体的某一个cccc的概率是一致的,都是十六分之一。也就是说,在理想状态下,这种扰动算法毫无意义,虽然看似有八位参与到了计算,实际上冲突率并没有变。

②通俗的对①做个解释,就是即使现在八位参与到了结果的计算,但是这八位计算的最终结果肯定是16个结果中的一个,你的结果区间并没有变,并且,得到这16个结果中的其中一个的概率还是一样的,所以说,冲突率是不变的。

③或许你会疑惑,假如原来两个实例的hash值aaaa是一致的,但是bbbb不一致,这样在没有扰动算法的时候,其hash结果一样,产生了冲突,但是经过扰动算法后,这两个hash值就不一样了啊,减少了冲突啊。。。。但是,遗憾的是,假如原来两个hash值的aaaa不一致,bbbb也不一致,但经过扰动函数后,他们也可能变得一致了啊。

④那么,这个扰动算法是否真的没有必要了呢,百思不得其解后,得出一个自认为有说服力的解释:我们上述的理论是建立在扰动算法前的hash值有可能是任意的,其均匀分布在int整形的取值范围中,这种情况下,其经过扰动算法后映射的低n位,的确是均匀映射的,并不能降低冲突率。但是,我们实际的应用中一个hashMap中的Hash值数据是随机且少量的(其实就是非均匀性的,随机的,并且数据量也不会太大),在这种情况下,采用八位参与就比四位参与要能降低冲突率了。其实从一些已有的实验证明,这种扰动函数的测试跟数据样本有很大的关系,并且在样本较好的情况下,其提升还是可观的。

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值