HashMap的Hash算法

HashMap的数据结构

要知道hashmap是什么,首先要搞清楚它的数据结构,在java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,HashMap也不例外。HashMap实际上是一个数组和链表的结合体(在数据结构中,一般称之为“链表散列”),请看下图(横排表示数组,纵排表示数组元素【实际上是一个链表】)。

1
2
3
4
5
/** 
* The table, resized as necessary. Length MUST Always be a power of two.
* FIXME 这里需要注意这句话,至于原因后面会讲到
*/
transient Entry[] table;
1
2
3
4
5
6
7
static class Entry<K,V> implements Map.Entry<K,V> {  
final K key;
V value;
final int hash;
Entry<K,V> next;
..........
}

上面的Entry就是数组中的元素,它持有一个指向下一个元素的引用,这就构成了链表。
当我们往HashMapput元素的时候,先根据keyhash值得到这个元素在数组中的位置(即下标),然后就可以把这个元素放到对应的位置中了。如果这个元素所在的位子上已经存放有其他元素了,那么在同一个位子上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。从HashMapget元素时,首先计算keyhashcode,找到数组中对应位置的某一元素,然后通过keyequals方法在对应位置的链表中找到需要的元素。从这里我们可以想象得到,如果每个位置上的链表只有一个元素,那么HashMapget效率将是最高的,但是理想总是美好的,现实总是有困难需要我们去克服,哈哈~

计算key的hashCode

我们可以看到在HashMap中要找到某个元素,需要根据keyhash值来求得对应数组中的位置。如何计算这个位置就是hash算法。前面说过HashMap的数据结构是数组和链表的结合,所以我们当然希望这个HashMap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。

1
2
3
4
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}

 

假设key.hashCode()的值为:0x7FFFFFFFtable.length为默认值16
上面算法执行如下:

再调用indexFor(int h, int length)方法最后返回的index为15

其中h^(h>>>7)^(h>>>4) 结果中的位运行标识是把h>>>7 换成 h>>>8来看。
即最后h^(h>>>8)^(h>>>4) 运算后hashCode值每位数值如下:
8=8
7=7^8
6=6^7^8
5=5^8^7^6
4=4^7^6^5^8
3=3^8^6^5^8^4^7
2=2^7^5^4^7^3^8^6
1=1^6^4^3^8^6^2^7^5
结果中的1、2、3三位出现重复位^运算
3=3^8^6^5^8^4^7 -> 3^6^5^4^7
2=2^7^5^4^7^3^8^6 -> 2^5^4^3^8^6
1=1^6^4^3^8^6^2^7^5 -> 1^4^3^8^2^7^5

算法中是采用(h>>>7)而不是(h>>>8)的算法,应该是考虑1、2、3三位出现重复位^运算的情况。使得最低位上原hashCode的8位都参与了^运算,所以在table.length为默认值16的情况下面,hashCode任意位的变化基本都能反应到最终hash table 定位算法中,这种情况下只有原hashCode第3位高1位变化不会反应到结果中,即:0x7FFFF7FF的i=15。

根据hashCode返回数组中位置

我们首先想到的就是把hashcode对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,“模”运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式呢?java中时这样做的

1
2
3
static int indexFor(int h, int length) {  
return h & (length-1);
}

 

首先算得keyhashcode值,然后跟数组的长度-1做一次“与”运算&。看上去很简单,其实比较有玄机。比如数组的长度是2的4次方,那么hashcode就会和2的4次方-1做“与”运算。很多人都有这个疑问,为什么HashMap的数组初始化大小都是2的次方大小时,HashMap的效率最高,我以2的4次方举例,来解释一下为什么数组大小为2的幂HashMap访问的性能最高。
看下图,左边两组是数组长度为162的4次方),右边两组是数组长度为15。两组的hashcode均为89,但是很明显,当它们和11101110数组长度15-1所得到的2进制)“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,89会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与11101110数组长度15-12进制)进行“与”,那么最后一位永远是0,而0001001101011001101101111101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!

所以说,当数组长度为2的n次幂的时候,不同的key算得的index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。
说到这里,我们再回头看一下HashMap中默认的数组大小是多少,查看源代码可以得知是16,为什么是16,而不是15,也不是20呢,看到上面的解释之后我们就清楚了吧,显然是因为162的整数次幂的原因,在小数据量的情况下161520更能减少key之间的碰撞,而加快查询的效率。

所以,在存储大容量数据的时候,最好预先指定HashMapsize2的整数次幂次方。就算不指定的话,也会以大于且最接近指定值大小的2次幂来初始化的,代码如下(HashMap的构造方法中):

1
2
3
4
// Find a power of 2 >= initialCapacity  
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;

 

HashMap的resize

HashMap中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对HashMap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,所以这是一个通用的操作,很多人对它的性能表示过怀疑,不过想想我们的“均摊”原理,就释然了,而在HashMap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize
那么HashMap什么时候进行扩容呢?当HashMap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当HashMap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。比如说,我们有1000个元素HashMap, 但是理论上来讲new HashMap(1024)更合适,不过上面已经说过,即使是1000HashMap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.75*1000 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。

key的hashcode与equals方法改写

在第一部分HashMap的数据结构中,就写了get方法的过程:首先计算keyhashcode,找到数组中对应位置的某一元素,然后通过keyequals方法在对应位置的链表中找到需要的元素。所以,hashcodeequals方法对于找到对应元素是两个关键方法。
HashMapkey可以是任何类型的对象,例如User这种对象,为了保证两个具有相同属性的userhashcode相同,我们就需要改写hashcode方法,比方把hashcode值的计算与User对象的id关联起来,那么只要user对象拥有相同id,那么他们的hashcode也能保持一致了,这样就可以找到在HashMap数组中的位置了。如果这个位置上有多个元素,还需要用keyequals方法在对应位置的链表中找到需要的元素,所以只改写了hashcode方法是不够的,equals方法也是需要改写的~当然啦,按正常思维逻辑,equals方法一般都会根据实际的业务内容来定义,例如根据user对象的id来判断两个user是否相等。
在改写equals方法的时候,需要满足以下三点:

  1. 自反性:就是说a.equals(a)必须为true。
  2. 对称性:就是说a.equals(b)=true的话,b.equals(a)也必须为true。
  3. 传递性:就是说a.equals(b)=true,并且b.equals(c)=true的话,a.equals(c)也必须为true。

通过改写key对象的equalshashcode方法,我们可以将任意的业务对象作为mapkey(前提是你确实有这样的需要)。

原文作者:annegu(iteye作者)
原文标题:深入理解HashMap

原文作者:marystone(iteye作者)
原文标题:HashMap hash方法分析

posted @ 2019-07-31 21:23  qxwang  阅读(138)  评论(0)    收藏  举报