浅谈HashMap如何解决hash冲突

在Java编程语言中,最基本的结构就是两种,一种是数组,一种是模拟指针(引用),所有的数据结构都可以用这两个结构进行构造,HashMap也是其中一种。

当程序试图将多个key-value放入HashMap中时,如以下代码片段为例:

HashMap<String,Object> m=new HashMap<String,Object>(); 
m.put("a", "rrr1"); 
m.put("b", "tt9"); 
m.put("c", "tt8"); 
m.put("d", "g7"); 
m.put("e", "d6"); 
m.put("f", "d4"); 
m.put("g", "d4"); 
m.put("h", "d3"); 
m.put("i", "d2"); 
m.put("j", "d1"); 
m.put("k", "1"); 
m.put("o", "2"); 
m.put("p", "3"); 
m.put("q", "4"); 
m.put("r", "5"); 
m.put("s", "6"); 
m.put("t", "7"); 
m.put("u", "8"); 
m.put("v", "9");

HashMap采用一种所谓的“Hash算法”来决定每个元素的存储位置,当程序执行map.put(String,Object)方法的时候,系统将调用String的hashCode()方法得到其hashCode值——每一个对象都有hashCode()方法,都可通过调用该方法获取其hashCode值。得到这个对象的hashCode值之后,系统就会根据该hashCode值来决定该元素的存储位置。

hashMap的put()方法源码如下:

public V put(K key, V value) {  
        if (key == null)  
            return putForNullKey(value);  
        int hash = hash(key.hashCode());  
        int i = indexFor(hash, table.length);  
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {  
            Object k;  
            //判断当前确定的索引位置是否存在相同hashcode和相同key的元素,如果存在相同的hashcode和相同的key的元素,那么新值覆盖原来的旧值,并返回旧值。  
            //如果存在相同的hashcode,那么他们确定的索引位置就相同,这时判断他们的key是否相同,如果不相同,这时就是产生了hash冲突。  
            //Hash冲突后,那么HashMap的单个bucket里存储的不是一个 Entry,而是一个 Entry 链。  
            //系统只能必须按顺序遍历每个 Entry,直到找到想搜索的 Entry 为止——如果恰好要搜索的 Entry 位于该 Entry 链的最末端(该 Entry 是最早放入该 bucket 中),  
            //那系统必须循环到最后才能找到该元素。 (后续有相关解释说明) 
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {  
                V oldValue = e.value;  
                e.value = value;  
                return oldValue;  
            }  
        }  
        modCount++;  
            //进行元素添加
        addEntry(hash, key, value, i);  
        return null;  
    }  

上面的程序中用到了一个重要的接口:Map.Entry,其实每个Map.Entry()就是一个key-value对,对于上面的程序可以看出:当系统要存储HashMap中的key-value的时候,完全没有考虑Entry中的value值,仅仅只是对key进行计算,从而决定每个Entry的存储位置。

这也说明了前面的结论:我们完全可以把Map集合中的value当成key的附属,当系统决定了key的存储位置之后,value就随之保存即可。为了保证可以出现冲突,我们可以故意构造hash冲突,因为HashMap的初始大小为16,并且在hashMap中存放了16个元素,并且屏蔽了resize()方法,不让他进行扩容,这个时候就可能会出现hash冲突,底层结构的Entry[] table数据结构如下:

HashMap里面的bucket中出现了单链表形式。当对数据进行存储的时候,就要讲到散列值冲突的问题,解决方法一般有两种:

  • 链表法:将相同的hash值对象组织成一个链表放在hash值对应的槽位
  • 开放地址法:通过一个探测算法,当某个槽位已经被占据的情况下,继续查找一个个可以使用的槽位

而HashMap采用的是链表法,链表是单向链表,形成单向链表的核心代码如下:

void addEntry(int hash, K key, V value, int bucketIndex) {  
    //如果bucketIndex索引处没有Entry对象,那么e则为null,那么新放入的对象指向了null,就有这一个新放入的对象,暂时没有形成Entry链
    //如果已经有了一个对象,那么新添加的对象会指向为原有的对象
    //形成new对象->old对象这种关系,就形成了一个Entry链
    Entry<K,V> e = table[bucketIndex];  
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);  
    if (size++ >= threshold)  
        resize(2 * table.length);  

上面的代码很简单,设计原理就是:系统总是将新添加的Entry对象放入table数组的bucketIndex索引处——如果bucketIndex处已经有一个Entry对象了,那么新添加的Entry对象指向原有的Entry对象(从而产生一个Entry链),如果bucketIndex索引处没有Entry对象,也就是说e的值为null,表示新放入的Entry对象指向了null,也就没有产生Entry链。

HashMap里面没有出现hash冲突时,没有形成单链表时,HashMap查找元素是很快的,get()方法能够直接定位到元素,但是出现单链表之后,单个bucket里存储的不是一个Entry,而是一个Entry链,系统只能按照顺序遍历每个Entry,直到找到你想搜索的那个Entry为止——如果要搜索的元素位于Entry链的最末端(说明该Entry是最早放入bucket中的),那系统需要循环到最后才能找到该元素。

当创建HashMap的时候,有一个默认的负载因子(load factor)(存储率:默认存储16个元素,达到0.75,就是16*0.75=12个的时候就会触发resize()操作,如果负载因子过大,使用率高,但是较容易发送hash冲突,较小的话又比较浪费存储空间),其默认值为0.75,这是时间与空间的一种折中:增大负载因子可以减少hash表(就是Entry数组)所占用的空间,但是会增加查询的时间开销,而对于HashMap而言,查询是最频繁的操作(HashMap的get()和put()方法都要用到查询),减小负载因子会提高数据查询的性能,但是会增加hash表所占用的内存空间。

HashMap简述:

HashMap基于hash表的Map接口实现。此实现提供了所有可选的映射操作,并允许使用null值和null键(除了不同步和允许使用null之外,HashMap类与HashTable大致相同)。此类不保证映射的顺序,特别是他不保证改顺序恒久不变。

由于HashMap不是线程安全的,如果想要使用线程安全的HashMap,可以通过Collections类的静态方法synchronizedMap以获取线程安全的HashMap

 Map map = Collections.synchronizedMap(new HashMap());

HashMap的数据结构:

HashMap的底层主要是基于数组和链表来实现的,他之所以有相当快的查询速度主要是因为他是通过计算散列码来决定存储位置,HashMap中主要是通过key的HashCode来计算hash值的,只要hashCode相同,那么计算出来的hash值就一样,如果存储的对象多了,那就有可能会出现不同的对象所计算出来的hash值是相同的,也就是所谓的hash冲突。hashMap是通过单链表来解决hash冲突的,

图中:紫色的部分代表hash表,也可以称为hash组,数组的每个元素都是单链表的表头,链表是用来解决hash冲突的,如果不同的key映射到了数组的同一位置,那么就将其放入这个单链表中。

我们来看下HashMap的Entry类的代码

 

 

    /** Entry是单向链表。    
     * 它是 “HashMap链式存储法”对应的链表。    
     *它实现了Map.Entry 接口,即实现getKey(), getValue(), setValue(V value), equals(Object o), hashCode()这些函数  
    **/  
    static class Entry<K,V> implements Map.Entry<K,V> {    
        final K key;    
        V value;    
        // 指向下一个节点    
        Entry<K,V> next;    
        final int hash;    

        // 构造函数。    
        // 输入参数包括"哈希值(h)", "键(k)", "值(v)", "下一节点(n)"    
        Entry(int h, K k, V v, Entry<K,V> n) {    
            value = v;    
            next = n;    
            key = k;    
            hash = h;    
        }    

        public final K getKey() {    
            return key;    
        }    

        public final V getValue() {    
            return value;    
        }    

        public final V setValue(V newValue) {    
            V oldValue = value;    
            value = newValue;    
            return oldValue;    
        }    

        // 判断两个Entry是否相等    
        // 若两个Entry的“key”和“value”都相等,则返回true。    
        // 否则,返回false    
        public final boolean equals(Object o) {    
            if (!(o instanceof Map.Entry))    
                return false;    
            Map.Entry e = (Map.Entry)o;    
            Object k1 = getKey();    
            Object k2 = e.getKey();    
            if (k1 == k2 || (k1 != null && k1.equals(k2))) {    
                Object v1 = getValue();    
                Object v2 = e.getValue();    
                if (v1 == v2 || (v1 != null && v1.equals(v2)))    
                    return true;    
            }    
            return false;    
        }    

        // 实现hashCode()    
        public final int hashCode() {    
            return (key==null   ? 0 : key.hashCode()) ^    
                   (value==null ? 0 : value.hashCode());    
        }    

        public final String toString() {    
            return getKey() + "=" + getValue();    
        }    

        // 当向HashMap中添加元素时,会调用recordAccess()。    
        // 这里不做任何处理    
        void recordAccess(HashMap<K,V> m) {    
        }    

        // 当从HashMap中删除元素时,会调用recordRemoval()。    
        // 这里不做任何处理    
        void recordRemoval(HashMap<K,V> m) {    
        }    
    }

HashMap其实就是一个Entry数组,Entry数组中包含了键和值,其中next也是一个Entry对象,用来处理Hash冲突,从而形成一个链表。

HashMap源码分析:

1,先看HashMap的关键属性:

//存储元素的实体数组
transient Entry[] table;

//存放元素的个数
transient int size;

//临界值   当实际大小超过临界值时,会进行扩容threshold = 加载因子*容量
int threshold; 

//加载因子
final float loadFactor; 

//被修改的次数
transient int modCount;

其中loadFactor加载因子是表示Hash表中元素的填满程度

若:加载因子越大,填满的元素越多,好处是空间利用率高了,但是冲突的机会加大了,链表的长度可能会越来越长,查找的效率低下,反之,加载因子越小,填满的元素越少,好处是冲突的机会小了,查询速度快了,但是空间浪费了(很多的空间还没用,就要开始扩容了)。

冲突的机会越大,则查找的成本越高。

因此必须在“冲突的机会”和“空间利用率”之间寻找一种平衡,这种平衡本质上是数据结构中的“时-空”矛盾的平衡与折中。

其实如果内存足够,并且想要提高查询速度的话,可以将加载因子设置小一点,相反如果内存紧张,并且对查询速度没有要求的话,可以将加载因子设置大一点,但是一般情况下都不去设置,使用默认值0.75就好。

2,构造方法

下面看HashMap的几个构造方法:

public HashMap(int initialCapacity, float loadFactor) {
        //确保数字合法
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                              initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                              loadFactor);

        // Find a power of 2 >= initialCapacity
        //初始容量
        int capacity = 1;   
        //确保容量为2的n次幂,使capacity为大于initialCapacity的最小的2的n次幂
        while (capacity < initialCapacity)  
            capacity <<= 1;

        this.loadFactor = loadFactor;
        threshold = (int)(capacity * loadFactor);
        table = new Entry[capacity];
       init();
   }

    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
   }

    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
        table = new Entry[DEFAULT_INITIAL_CAPACITY];
       init();
    }

我们可以看到在构造HashMap的时候,如果我们指定了加载因子和初始化容量的话,就会调用第一个方法,否则的话就是会使用默认的。默认情况下,初始化容量为16,加载因子为0.75,其中代码中有一行确保容量为2的n次幂的方法,使capacity(容量)为大于initialCapacity(初始化容量)的最小的2的n次幂,至于为什么要把容量设置为2的n次幂,后续在看。

3,存储数据

我们再看下HashMap存储数据的过程是如何的,看下HashMap的put方法:

public V put(K key, V value) {
     // 若“key为null”,则将该键值对添加到table[0]中。
         if (key == null) 
            return putForNullKey(value);
     // 若“key不为null”,则计算该key的哈希值,然后将其添加到该哈希值对应的链表中。
         int hash = hash(key.hashCode());
     //搜索指定hash值在对应table中的索引
         int i = indexFor(hash, table.length);
     // 循环遍历Entry数组,若“该key”对应的键值对已经存在,则用新的value取代旧的value。然后退出!
         for (Entry<K,V> e = table[i]; e != null; e = e.next) { 
             Object k;
              //如果key相同则覆盖并返回旧值
              if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { 
                  V oldValue = e.value;
                 e.value = value;
                 e.recordAccess(this);
                 return oldValue;
              }
         }
     //修改次数+1
         modCount++;
     //将key-value添加到table[i]处
     addEntry(hash, key, value, i);
     return null;
}

上面程序中可以看出,其实每一个Entry都是一个key-value对,当系统存储key-value的时候,完全没有考虑Entry中的value值,仅仅是根据key来决定每个Entry的存储位置。

这也说明了之前的结论:我们完全可以把Map中的value当成key的附属,当系统决定了key的位置之后,value也就随之保存。

之后我们看下putForNullKey(value)方法:

private V putForNullKey(V value) {
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            //如果有key为null的对象存在,则覆盖掉
            if (e.key == null) {  
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
           }
       }
        modCount++;
        //如果键为null的话,则hash值为0
        addEntry(0, null, value, 0); 
        return null;
    }

注意:如果key为null的话,hash值是0,对象存储在数组中索引为0的位置,即table[0]。

我们再看下是如何根据key的HashCode值来计算hash码的,下面是计算hash码的函数:

    //计算hash值的方法 通过键的hashCode来计算
    static int hash(int h) {
         //此函数可确保hashCode仅在以下方面有所不同
         //每个位位置的常数倍数是有界的
         //碰撞次数(默认为8个碰撞系数)。
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

得到hash码之后,就会根据hash码去计算应该存储在数组中的索引,计算索引的函数如下:

//根据hash值和数组长度算出索引值
static int indexFor(int h, int length) { 
        //这里不能随便算取,用hash&(length-1)是有原因的,这样可以确保算出来的索引是在数组大小范围内,不会超出
         return h & (length-1);  
}

我们重点说下这块:我们一般对hash表进行散列,很自然的会想到用hash值对length取模(即触发散列法),HashTable中也是这样实现的,这种方法基本上能保证元素在hash表中散列的比较均匀,但是取模会用到除法,效率会低一点,而相对于HashMap中是通过h&(length-1)的方法进行取模,也同样实现了均匀的散列,但是效率会高很多,这要是HashMap对HashTable的一个改进。

接下来,我们分析下为什么Hash表的容量一定要是2的整数次幂。首先,length为2的整数次幂的话,h&(length)就相当于对length进行取模,这样既保证了散列的均匀,同时也提升了效率;其次,length为2的整数次幂,是偶数,这样length-1为奇数,奇数的最后一位是1,这样既保证了h&(length-1)的最后一位可能是0,也可能是1,(这取决于length的值),即与后的结果可能是奇数也可能是偶数,这样便可以保证散列的均匀性,而如果length为奇数的话,很明显length-1为偶数,他的最后一位是0,这样h&(length-1)的最后一位肯定是0,即只能是偶数,这样任何hash值都只会被散列到数组的偶数下标位置上,这样就会浪费了近一半的空间,因此,length取2的整数次幂,是为了使不通的hash值发生碰撞的概率小,这样也能使元素比较均匀的散列在hash表中。

这看上去很简单,其实是有玄机的,我们举个例子进行说明:

假设数组长度分别为15和16,优化后的hash码值分别为8和9,那么&运算后的结果如下:

       h & (table.length-1)                     hash                             table.length-1
       8 & (15-1):                                 0100                   &              1110                   =                0100
       9 & (15-1):                                 0101                   &              1110                   =                0100
       -----------------------------------------------------------------------------------------------------------------------
       8 & (16-1):                                 0100                   &              1111                   =                0100
       9 & (16-1):                                 0101                   &              1111                   =                0101

从上面的例子可以看出:当他们和15-1(1110)“与”的时候,产生了相同的结果,也就是说他们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到数组中的同一个位置形成链表,那么查询的时候就需要遍历这个链表,得到8和9,这样就降低了查询的效率。同事,我们也可以发现,当数组的长度为15的时候,hash值会与15-1(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组中可以使用的位置比数组的长度小了很多,这意味着进一步增加了碰撞的概率,降低了查询的效率。而当数组的长度为16的时候,即为2的n次方,2n-1得到的二进制数的每个位上的值都为1,这使得在低位上进行&的时候,得到的和原hash的低位相同,加之hash(int h)方法对key的hashCode的进一步优化,加入了高位运算,就使得只有相同的hash值的两个值才会被放到数组中的同一个位置上形成链表。

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

根据上面put方法的源码可以看出,当程序试图将一个key-value对放入HashMap中的时候,程序首先根据该key的HashCode()返回值决定该Entry的存储位置:如果两个Entry的key的hashCode()值相同,那么他们的存储位置相同,如果这两个Entry的key通过equals比较之后返回true,那么新添加的Entry的value值就会覆盖原有的Entry中的vaule,但是key不会覆盖,如果量Entry的key通过equals方法比较返回false,那么新添加的Entry就会与原有的Entry形成Entry链,而且新添加的Entry位于Entry链的头部——具体需要看addEntry()的方法说明:

void addEntry(int hash, K key, V value, int bucketIndex) {
        //如果要加入的位置有值,将该位置原先的值设置为新entry的next,也就是新entry链表的下一个节点
        Entry<K,V> e = table[bucketIndex]; 
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        if (size++ >= threshold) //如果大于临界值就扩容
            resize(2 * table.length); //以2的倍数扩容
    }

参数bucketIndex就是indexFor函数计算出来的索引值,取出数组中索引为bucketIndex的Entry对象,然后用hash,key,value构建一个新的Entry对象放到索引为bucketIndex的位置,并将该位置原先的对象设置为新对象的next,构成链表。

之后判断put后的size是否达到了临界值,如果达到了临界值就要进行扩容,HashMap扩容是扩容为原来的2倍。

4,调整大小

resize()方法如下:

重新调整HashMap的大小,newCapacity是调整后的单位:

void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
       }

        Entry[] newTable = new Entry[newCapacity];
        //用来将原先table的元素全部移到newTable里面
        transfer(newTable);
        //再将newTable赋值给table
        table = newTable;  
        //重新计算临界值
        threshold = (int)(newCapacity * loadFactor);
    }

transfer方法,是将HashMap的全部元素都添加到新的HashMap中,并重新计算在新的数组中的索引位置

当HashMap中的元素越来越多的时候,Hash冲突的几率也就越来越高,因为数组的长度是固定的,所以为了提高查询的效率,那么就需要多HashMap进行扩容,数组扩容的这个操作也会出现在ArrayList中,这是一个常用的操作,而再HashMap数组扩容之后,最消耗性能的点就出现了:原始数组中的数据必须要重新计算其在新数组中的位置,并放进去,这就是resize

那么HashMap是什么时候进行扩容呢?当HashMap中的元素个数超过数组大小loadFactor时,就需要进行扩容,loadFactor的默认值是0.75,这是一个折中值。也就是说,默认情况下,数组大小默认为16,那么当HashMap中的元素个数超过16*0.75=12的时候,就把数组的大小扩展为16*2=32,即扩大一倍,然后重新计算每个元素在数组中的位置,扩容是需要进行数组复制的,复制数组是非常消耗性能的,所以如果我们已经预支了HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。

5,数据读取

public V get(Object key) {   
    if (key == null)   
        return getForNullKey();   
    int hash = hash(key.hashCode());   
    for (Entry<K,V> e = table[indexFor(hash, table.length)];   
        e != null;   
        e = e.next) {   
        Object k;   
        if (e.hash == hash && ((k = e.key) == key || key.equals(k)))   
            return e.value;   
    }   
    return null;   
}

有了上面的基础,就比较容易理解,从HashMap中读取元素的时候,首先计算HashCode值,找到数组中对应位置的某一个元素,然后通过key的equals方法在对应位置的链表找到我们所需的数据即可。

6,HashMap的性能参数

HashMap包含以下几个构造器:

  • HashMap():构建一个初始容量为 16,负载因子为 0.75 的 HashMap。

  • HashMap(int initialCapacity):构建一个初始容量为 initialCapacity,负载因子为 0.75 的 HashMap。

  • HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的负载因子创建一个 HashMap。
    HashMap的基础构造器HashMap(int initialCapacity, float loadFactor)带有两个参数,它们是初始容量initialCapacity和加载因子loadFactor。

  • initialCapacity:HashMap的最大容量,即为底层数组的长度。

  • loadFactor:负载因子loadFactor定义为:散列表的实际元素数目(n)/ 散列表的容量(m)。

负载因子是衡量一个散列表空间使用率的参数,负载因子越大表示散列表的填充程度越高,反之越小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用率更充分,然后后果是查询效率的降低,如果负载因子太小,那么数据过于稀疏,造成对空间的浪费

HashMap的实现中,通过threshold字段来判断HashMap的最大容量:


threshold = (int)(capacity * loadFactor);  

结合负载因子的定义公式可知,threshold就是在此loadFactor和capacity对应下允许的最大元素数目,超过这个数目就重新resize,以降低实际的负载因子。默认的的负载因子0.75是对空间和时间效率的一个平衡选择。当容量超出此最大容量时,resize后的HashMap容量是容量的两倍。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值