HashMap源码分析

来源:互联网 发布:网络惊魂2.0 编辑:程序博客网 时间:2024/05/16 00:56

1、hashmap的数据结构 


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

 

 

上图中,X轴方向为HashMap的数组容器,Y轴方向,为每个相应位置的单向链表。每一个元素都是一个包含如图所示四个属性,key,value,hash,next的一种数据结构,即EntrySet,其中的next指向Y轴方向其下一个元素.我们来看看java代码:

 

 

上面的Entry就是数组中的元素,它持有一个指向下一个元素的引用,这就构成了链表。 

 

 

2  HashMap的构造函数

public HashMap(int initialCapacity, float loadFactor) {

 

 

 

loadFactor :加载因子,加载因子与HashMap resize有关。默认为0.75

capacity:容器大小,默认值为16 其大小为上面所说的数据结构中数组的长度。

table:即为上面数据结构图中,X方向的数组(transient Entry[] table;

threshold :resize的临界值,即当HashMap中无素个数达到该值时,HashMap就会调用其resize方法,重新扩充大小。

 

从下面的代码中:

  1. while (capacity < initialCapacity)  
  2.           capacity <<= 1;  

 

可以看出,capacity的值是2的倍数

 

3  hash算法 

 

创建HashMap之后,我们得到了一个空的hashMap容器,我们接下来的可能就是往容器里面放我们的元素了。

  1. public V put(K key, V value) {  
  2.        if (key == null)  
  3.            return putForNullKey(value);  
  4.        int hash = hash(key.hashCode());  
  5.        int i = indexFor(hash, table.length);  
  6.        for (Entry<K,V> e = table[i]; e != null; e = e.next) {  
  7.            Object k;  
  8.            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {  
  9.                V oldValue = e.value;  
  10.                e.value = value;  
  11.                e.recordAccess(this);  
  12.                return oldValue;  
  13.            }  
  14.        }  
  15.   
  16.        modCount++;  
  17.        addEntry(hash, key, value, i);  
  18.        return null;  
  19.    }  

 

   当我们往hashmap中put元素的时候,先根据key的hash值得到这个元素在数组中的位置(即下标),然后就可以把这个元素放到对应的位置中了。如果这个元素所在的位子上已经存放有其他元素了,那么在同一个位子上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。从hashmap中get元素时,首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。

 

具体的实现是:

当你的key为null时,会调用putForNullKey,HashMap允许key为null,这样的对像是放在table[0]中。

如果不为空,则调用int hash = hash(key.hashCode());这是hashmap的一个自定义的hash,在key.hashCode()基础上进行二次hash

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

这样做的目的就是为了改进传统的hash方法,而且尽量保证key的每一位都会影响到最后的hash值,以达到减少hash冲突的目的.再看indexFor方法:

hashMap是根据这个方法定位到某个元素,将要存储在X方向,即table数组的哪个位置。

 

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

 

就一行代码,将key的二次hash值,与长度减一进行与操作,这一步可谓经典,通常我们会用取模的方式来定位数组中的某个位置,我们首先想到的就是把hashcode对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,“模”运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式那?hashMap用这种方法,而且length即capacity的值,面capacity又是2的倍数,减1之后,表示成二进制就全部是1了,那么与全部为1的一个数进行与操作,速度会大大提升了。这就是为什么"capacity的值是2的倍数"

 

很多人都有这个疑问,为什么hashmap的数组初始化大小都是2的次方大小时,hashmap的效率最高,我以2的4次方举例,来解释一下为什么数组大小为2的幂时hashmap访问的性能最高。 

         看下图,左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。两组的hashcode均为8和9,但是很明显,当它们和1110“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!
 

 

所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。 

 

 

位定到具体的列之后,hashMap会遍历该列的所有元素,当以该key的无素已经存在是,会将其value替换,并返回其原值。否则会重新创建一个新的EntrySet并放在table[indexFor(hash, table.length)]的位置上,并将之前该列的链表,设为其next。

 

 

 

 

hashMap的get 方法,是首先通过通过key的两次hash值与数组的长度(capacity)进行于操作,定位到数组的某个位置,然后对该列的链表进行遍历,一般情况下,hashMap的这种查找速度是非常快的,hash值相同的元素过多,就会造成链表中数据很多,而链表中的数据查找是通过遍历所有链表中的元素进行的,这可能会影响到查找速度,找到即返回,这里需要注意的是,返回为null时,你不能判断是找到了,还是在hashmap中存着一个value为null的元素,因为hashmap允许value为null.

 

4  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个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过上面annegu已经说过,即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.75*1000 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。 


5、key的hashcode与equals方法改写 
在第一部分hashmap的数据结构中,annegu就写了get方法的过程:首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。所以,hashcode与equals方法对于找到对应元素是两个关键方法。 

Hashmap的key可以是任何类型的对象,例如User这种对象,为了保证两个具有相同属性的user的hashcode相同,我们就需要改写hashcode方法,比方把hashcode值的计算与User对象的id关联起来,那么只要user对象拥有相同id,那么他们的hashcode也能保持一致了,这样就可以找到在hashmap数组中的位置了。如果这个位置上有多个元素,还需要用key的equals方法在对应位置的链表中找到需要的元素,所以只改写了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对象的equals和hashcode方法,我们可以将任意的业务对象作为map的key(前提是你确实有这样的需要)。