HashMap学习

简单介绍

HashMap是基于一个散列表实现(设计用来替代HashTable)。针对键-值的插入和检索,保证了它稳定的性能。

我们可以把先HashMap可以看做是一个存储了很多个键值对的数组,每一个键值对我们把它叫做一个Entry,这些Entry通过一定的规则分散的存储在数组里面,HashMap数组每一个元素的初始值都是Null。

entry源码
/**
 * The table, resized as necessary. Length MUST Always be a power of two.
 */ 
transient Entry[] table; 
   
static class Entry<K,V> implements Map.Entry<K,V> { 
    final K key; 
    V value; 
    Entry<K,V> next; 
    final int hash; 
    …… 
}
 
  1. Put方法的原理

比如调用 hashMap.put("apple", 0) ,插入一个Key为“apple"的元素。这时候我们需要确定Entry的插入位置

首先对key进行hash运算

 折叠源码
static final int hash(Object key) {
     int h;
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);  //这里通过key.hashCode()计算出key的哈希值,然后将哈希值h右移16位,再与原来的h做异或^运算——这一步是高位运算
}

再由hash和lenth进行运算

index=(lenth - 1) & hash

假定最后计算出的index是2,那么结果如下:

 

但是,因为HashMap的长度是有限的,当插入的Entry越来越多时,再完美的Hash函数也难免会出现index冲突的情况。比如下面这样:

 

这时候该怎么办呢?我们可以利用链表来解决。

 

HashMap采用Entry数组来存储key-value对,每一个键值对组成了一个Entry实体,Entry类实际上是一个单向的链表结构,它具有Next指针,可以连接下一个Entry实体,以此来解决Hash冲突的问题。

 需要注意的是,新来的Entry节点插入链表时,jdk8之前使用的是“头插法”。jdk8之后就是使用“尾插法”了,主要为了安全,避免链条成环,这里不详细讲解了

 

 在JDK1.8之前,HashMap采用数组+链表实现,即使用链表处理冲突(只能保证尽量分布均匀,冲突难以避免),同一hash值的节点都存储在一个链表里。但是当位于一个桶中的元素较多,链表被拉的很长,通过key值依次查找的效率大大降低。

而JDK1.8中,HashMap采用数组+链表+红黑树实现,当某个位桶的链表的长度超过 8 的时候,这个链表就将转换成红黑树,当链条长度小于6时,又会恢复成链表结构,这样大大减少了查找时间。

桶中的结构可能是链表,也可能是红黑树。

 

    2. Get方法的原理

使用Get方法根据Key来查找Value的时候,发生了什么呢?

 

首先会把输入的Key做一次Hash映射,得到对应的index,贴出getNode方法的源代码:

HashMap.GetNode
final Node<K,V> getNode(int hash, Object key) {                  //根据key搜索节点的方法。记住判断key相等的条件:hash值相同 并且 符合equals方法。
            Node<K, V>[] tab;
            Node<K, V> first, e;
            int n;
            K k;
            if ((tab = table) != null && (n = tab.length) > 0 &&            //根据输入的hash值,可以直接计算出对应的下标(n - 1)& hash,缩小查询范围,如果存在结果,则必定在table的这个位置上。
            (first = tab[(n - 1) & hash]) != null){
            if (first.hash == hash && // always check first node
                    ((k = first.key) == key || (key != null && key.equals(k))))    //判断第一个存在的节点的key是否和查询的key相等。如果相等,直接返回该节点。
            return first;
            if ((e = first.next) != null) {                       //遍历该链表/红黑树直到next为null。
                if (first instanceof TreeNode)                       //当这个table节点上存储的是红黑树结构时,在根节点first上调用getTreeNode方法,在内部遍历红黑树节点,查看是否有匹配的TreeNode。
                return ((TreeNode<K, V>) first).getTreeNode(hash, key);
                do {
                    if (e.hash == hash &&                        //当这个table节点上存储的是链表结构时,用跟第11行同样的方式去判断key是否相同。
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
                } while ((e = e.next) != null);                      //如果key不同,一直遍历下去直到链表尽头,e.next == null。
            }
        }
 }

 


 

 

同一个位置有可能匹配到多个Entry,这时候就需要顺着对应链表/红黑数的头节点,一个一个向下来查找。假设我们要查找的Key是“apple”:

 

 

第一步,我们查看的是头节点Entry6,Entry6的Key是banana,显然不是我们要找的结果。

第二步,我们查看的是Next节点Entry1,Entry1的Key是apple,正是我们要找的结果(当然需要判断是链表还是红黑树实现,这里以链表为例)

 

 

     3. 计算存取时的index

如果节点存储分布的不均匀的话,拿取值的时候效率就会大打折扣,甚至会变成纯链表结构,复杂度由理想的O(1)变成了O(N),Hashmap设计者为了让节点存储分布的更加均匀,使用位运算的方式来计算新增节点存储在那个位置

index =  HashCode(Key) &  (Length - 1)

下面我们以值为“book”的Key来演示整个过程:

1.计算book的hashcode,结果为十进制的3029737,二进制的101110001110101110 1001。

2.假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。

3.把以上两个结果做与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9。

 

但是这种方法有一个前提就是hashmap的长度必须是2的幂,要不然用这种位运算根本无法得到合适的index,甚至会超出hashmap的长度

 

    4. ReHash

当插入的元素增加,HashMap达到一定饱和度时,Key映射位置发生冲突的几率会逐渐提高。这时候,HashMap需要扩展它的长度,也就是进行Resize。

默认hashmap的resize的条件是 hashmap的size大于或等于当前长度的百分之七十五,之后会先后执行两个步骤:

  1. 扩容

创建一个新的Entry空数组,长度是原数组的2倍。

  1. ReHash

遍历原Entry数组,把所有的Entry重新Hash到新数组。(这里一定要重写hash运算,因为以前的规则是依据hashmap的长度来的,现在长度变化了,规则也就不同了)

Resize前:

 

 

 

Resize后

 

      

       5. 高并发下的hashmap

       在jdk8之前并发的put操作可能会在rehash的时候导致成环,导致程序死循环的产生,jdk8之后没有成环的情况了(JDK 8 用 head 和 tail 来保证链表的顺序和之前一样;JDK 7 rehash 会倒置链表元素),但是还会有数据丢失问题:

 

  1. 如果多个线程同时使用 put 方法添加元素,而且假设正好存在两个 put 的 key 发生了碰撞(根据 hash 值计算的 bucket 一样),那么根据 HashMap 的实现,这两个 key 会添加到数组的同一个位置,这样最终就会发生其中一个线程 put 的数据被覆盖

       2. 如果多个线程同时检测到元素个数超过数组大小 * loadFactor,这样就会发生多个线程同时对 Node 数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给 table,也就是说其他线程的都会丢失,并且各自线程 put 的数据也丢失

       3. 一个线程迭代时,另一个线程做插入删除操作,造成迭代的fast-fail

 

参考:https://www.jianshu.com/u/e80e770d920d

posted @ 2020-03-02 14:37  liang_liu  阅读(169)  评论(0)    收藏  举报