HashMap分析

转载:http://www.iteye.com/topic/539465

参考:https://www.ibm.com/developerworks/cn/java/j-lo-hash/?ca=drs-tp4608 

hashmap的数据结构 

  要知道hashmap是什么,首先要搞清楚它的数据结构,请看下图(横排表示数组,纵排表示数组元素【实际上是一个链表】)。 



  从图中我们可以看到一个hashmap就是一个数组结构,当新建一个hashmap的时候,就会初始化一个数组。我们来看看java代码: 

  数组:

/** 
     * The table, resized as necessary. Length MUST Always be a power of two. 
     *  这里需要注意这句话,即(为什么一定是2的幂次方),至于原因后面会讲到 
     */  
transient Entry[] table;  

  链表:

static class Entry<K,V> implements Map.Entry<K,V> {  
        final K key;  
        V value;  
        final int hash;  
        Entry<K,V> next;  
..........  
} 

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

  从hashmap中get元素时,首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。从这里我们可以想象得到,如果每个位置上的链表只有一个元素,那么hashmap的get效率将是最高的,不必再循环“链表”了(比如:一个数组 int[10] A,如果我想去找到索引为2的值,直接A[2]就立马得到了,这速度是最快的)。

       所以接下来的问题就是:怎么尽可能的得到“一个链表上只有一个Entry”???

HashMap中的hash算法

  我们当然希望这个hashmap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。所以我们首先想到的就是把hashcode对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的(加粗的这句话是原作者写的,表达意思有问题,一个运算A取模B = C,得到的结果值C总小于B,这一点是肯定的,怎么能说就平均分布了?? 就HashMap数据结构来看,这种做法的好处是,确保了任何一个key,总能在“有限的table长度”上找到一个“下标”)。但是,“模”运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式那?java中时这样做的, 

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

  首先算得key得hashcode值,然后跟数组的长度-1做一次“与”运算(&),等价于取模运算了。

  有个特点:2的次方 减 1 ,意味着 其二进制值必然形如:高位是连续的零,低位是连续的1(如 00001111),故任何一个数A 和  “这样的数”  做 “与运算”的话,其实就是 对 数A的高位截断,只取低位而已。

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


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

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

  说到这里,我们再回头看一下hashmap中默认的数组大小是多少,查看源代码可以得知是16,为什么是16,而不是15,也不是20呢,看到上面annegu的解释之后我们就清楚了吧,显然是因为16是2的整数次幂的原因,在小数据量的情况下16比15和20更能减少key之间的碰撞,而加快查询的效率。 

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

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

key的hashcode与equals方法改写 

  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(前提是你确实有这样的需要)。 

争论

  以上是原作者的理解,其中也标注了我自己的一些“理解”。但针对文章的“HashMap的Hash算法”这一节有个争论:“table为什么一定要是2的幂次方?”。以下是具体原由:

  首先,有明白一点的是,对一个数取模是“最简单的一种散列的算法”,即:A % B = C,得到的C值 必然散列在 0 到 B这个区间中。

  所以HashMap为了让put进来的元素,散列在 table上,采用了“长度”取模运算,取得“下标”。

  而 HashMap类中indexOf方法,如下:

static int indexOf(int h, int length) {  
       return h & (length-1);  
}  

  是一种只针对 “ 2的幂次方数”  这样的数,取模的变通方式。这种变通刚好等价于对数取模了?

       如果不是“2的幂次方数”这样的数,比如 数值5,使用indexOf方法,是进行的取模运算吗?显然不是!!!

  如果我们就是要这么干呢?即 一个 不是 “2的幂次方数”的数采用调用了“indexOf”方法,便会出现如原作者所说的“增加碰撞的概率”,归根到底是因为,indexOf方法 不是  对  数(一个 不是 “2的幂次方数”的数) 的取模运算呀!!!

  结论(注意因果关系):因为HashMap限制了我们的table的长度必须是“2的幂次方数”,所以我们可以采用“indexOf”这个变通方法来进行“取模运算”。

  如果HashMap不限制长度为“ “2的幂次方数””,indexOf方法就要改写了,如下:

static int indexOf(int h, int length) {  
       return h % length;  //注意是换了“运算符”了 ,将“与运算”改成了“取模运算符”
}  

   如果不限制 “table的length是2的幂次方” ,同时 indexOf方法改写成上述代码,就不一定是 “table的length是2的幂次方”  比  “length不是2的幂次方”    散列分布的更好了。

  还有个问题,针对HashMap的,由于table的length 是“2的幂次方数”,如果恰巧 h & (length -1)中 的 h 基本上是高位变动,而低位不变,势必也会增加“碰撞概率”。故HashMap中对key的hashCode方法返回值,进行了“二次hash的运算”:

/**
     * Retrieve object hash code and applies a supplemental hash function to the
     * result hash, which defends against poor quality hash functions.  This is
     * critical because HashMap uses power-of-two length hash tables, that
     * otherwise encounter collisions for hashCodes that do not differ
     * in lower bits. Note: Null keys always map to hash 0, thus index 0.
     */
    final int hash(Object k) {
        int h = 0;
        if (useAltHashing) {
            if (k instanceof String) {
                return sun.misc.Hashing.stringHash32((String) k);
            }
            h = hashSeed;
        }

        h ^= k.hashCode();

        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

  

 

posted @ 2017-11-15 17:53  郑升  阅读(287)  评论(0编辑  收藏  举报