HashMap及ConcurrentHashMap基本原理概述

0、前言

1、HashMap基本原理

  • 众所周知,HashMap是一个用于存储Key-Value键值对的集合,每一个键值对也叫做Entry。这些个键值对(Entry)分散存储在一个数组当中,这个数组就是HashMap的主干。

  • HashMap数组每一个元素的初始值都是Null。


  • 对于HashMap,我们最常使用的是两个方法:Get 和 Put。

  • Put方法的原理

    • 调用Put方法的时候发生了什么呢?

      扫描二维码关注公众号,回复: 2364540 查看本文章
    • 比如调用 hashMap.put("apple", 0) ,插入一个Key为“apple"的元素。这时候我们需要利用一个哈希函数来确定Entry的插入位置(index):

      index =  Hash(“apple”)
      
    • 假定最后计算出的index是2,那么结果如下:


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


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

    • HashMap数组的每一个元素不止是一个Entry对象,也是一个链表的头节点。每一个Entry对象通过Next指针指向它的下一个Entry节点。当新来的Entry映射到冲突的数组位置时,只需要插入到对应的链表即可:

     
    • 需要注意的是,新来的Entry节点插入链表时,使用的是“头插法”。之所以把Entry6放在头节点,是因为HashMap的发明者认为,后插入的Entry被查找的可能性更大。
  • Get方法的原理

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

    • 首先依然会把输入的Key做一次Hash映射,得到对应的index:

      index =  Hash(“apple”)
      
    • 由于刚才所说的Hash冲突,同一个位置有可能匹配到多个Entry,这时候就需要顺着对应链表的头节点,一个一个向下来查找。假设我们要查找的Key是“apple”:


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

      • 第二步,我们查看的是Next节点Entry1,Entry1的Key是apple,正是我们要找的结果。

      • 在这里get方法会沿着链表一直往下寻找,直到找到了key为apple的节点。若找到链表最尾端的时候(e.next=null)还找不到的话,则返回null

        • 所以当此处出现双向循环链表的时候,那么程序就会出现死循环,因为e.next永远不会等于null

2、HashMap默认初始长度是多少,为什么?

  • 默认长度是16,并且每次自动扩展或手动初始化时,长度必须是2的幂次方。

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
    
  • 之前说过,从Key映射到HashMap数组的对应位置,会用到一个Hash函数,如何实现一个尽量均匀分布的Hash函数呢?我们通过利用Key的HashCode值来做某种运算。

  • 通常情况下,Key的HashCode值会是一个比较大的值,但我们HashMap的初始长度只有16,所以我们必须采取某些方法来将这个HashCode值和Map的长度值做一个映射转换

    • 常见的做法就是将Key的HashCode值和Map的长度值进行求模运算,但模运算效率低,为了实现高效的算法,HashMap采用了位运算的算法。

    • 如何进行位运算呢?有如下的公式(Length是HashMap的长度):

      index =  key.hashCode() & 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。

      • 可以说,Hash算法最终得到的index结果,完全取决于Key的Hashcode值的最后几位。

  • HashMap长度必须是2的幂次方,这样才能保证Length-1的二进制形式全是1。因为假如Length-1的值为1000的话,那么其他数和1000进行与运算之后,结果就只有1000或0000这两种情况,这样就会造成大量的冲突,显然不符合Hash算法均匀分布的原则。

3、HashMap的扩展

  • HashMap的容量是有限的。当经过多次元素插入,使得HashMap达到一定饱和度时,Key映射位置发生冲突的几率会逐渐提高。这时候,HashMap需要扩展它的长度,也就是进行Resize。


  • 影响发生Resize的因素有两个:

    • 1、Capacity:HashMap的当前长度。

    • 2、LoadFactor:HashMap负载因子,默认值为0.75f。

  • 衡量HashMap是否进行Resize的条件如下,也就是说当HashMap中存储的数据量超过总量的0.75倍的时候,则认为该HashMap已经超过负载,需要进行Resize:

                      HashMap.Size >= Capacity * LoadFactorHashMap.Size>=CapacityLoadFactor
  • Resize的步骤

    • 1、扩容

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

      • 遍历原Entry数组,把所有的Entry重新Hash到新数组。为什么要重新Hash呢?因为长度扩大以后,Hash的规则也随之改变。

      • 让我们回顾一下Hash公式:

        index =  key.hashCode() & Length - 1
        
      • 当原数组长度为8时,Hash运算是和111B做与运算;新数组长度为16,Hash运算是和1111B做与运算。Hash结果显然不同。

      • Resize前的HashMap:


      • Resize后的HashMap:


      • ReHash的Java代码如下:

        /**
         * Transfers all entries from current table to newTable.
         */
        void transfer(Entry[] newTable, boolean rehash) {
            int newCapacity = newTable.length;
            for (Entry<K,V> e : table) {
                while(null != e) {
                    Entry<K,V> next = e.next;
                    if (rehash) {
                        e.hash = null == e.key ? 0 : hash(e.key);
                    }
                    int i = indexFor(e.hash, newCapacity);
                    
                    // 头插法,让e.next指向链表中的最后一个节点
                    e.next = newTable[i];
                    // 然后让e成为链表中的第一个节点
                    newTable[i] = e;
                    e = next;
                }
            }
        }
        
  • HashMap的扩展在多线程下会造成死循环

    • 如当前HashMap中存在两个元素a和b,其中他们存放的地址冲突,即hash(a) = hash(b) = 0,此时该哈希表的内存图如下:


    • 假如现在有两个线程分别对该HashMap执行put操作,此时HashMap由于容量不够就需要进行扩容了,假设线程1先执行,在执行完Entry<K,V> next = e.next;这一句之后,cpu的时间片切换到了线程2上了,并且线程2顺利地执行完毕,此时我们先看线程2执行完后的内存图是怎样的。


    • 此时又轮到线程1执行了,我们回顾下rehash的代码

      /**
       * Transfers all entries from current table to newTable.
       */
      void transfer(Entry[] newTable, boolean rehash) {
          int newCapacity = newTable.length;
          for (Entry<K,V> e : table) {
              while(null != e) {
              
                  // 线程1执行完后停在了这里,e=0x001,next=0x009
                  Entry<K,V> next = e.next;
                  
                  // 线程1又执行了
                  // 与之前不同的是,原本是0x009.next = null,现在变成了0x009.next = 0x001
                  if (rehash) {
                      e.hash = null == e.key ? 0 : hash(e.key);
                  }
                  int i = indexFor(e.hash, newCapacity);
                  
                  e.next = newTable[i];
                  newTable[i] = e;
                  e = next;
              }
          }
      }
      
    • 线程1在while代码块中执行了三次

      • 1、e=0x001,next=0x009,然后newTable[2] = 0x001,0x001.next = null
      • 2、e=0x009,next=0x001,然后newTable[2] = 0x009,0x009.next = 0x001
      • 3、e=0x001,next=null,然后newTable[2] = 0x001,0x001.next = 0x009
    • 最终内存图如下:


    • 此时当调用Get查找一个不存在的Key,而这个Key的Hash结果恰好等于2的时候,由于位置2带有环形链表,所以程序将会进入死循环!

4、ConcurrentHashMap

  • 由于多线程在操作HashMap时会出现环形链表进而导致死循环的问题,所以此时就必须寻找解决方法。

  • Hashtable或Collections.synchronizedMap均能保证线程的安全性,但两者都使用了带有阻塞的悲观锁,性能不高。

  • 在并发环境下,ConcurrentHashMap能到兼顾线程的安全性以及运行的效率,替代了Hashtable。

  • ConcurrentHashMap通过使用Segment的方式来减少悲观锁的产生。

    • 其原理有点类似于jvm堆内存分配对象时所使用的本地线程分配缓冲(TLAB),每个线程有一个自己专属的区域,各个线程在自己的区域中执行代码,互不干扰。

    • ConcurrentHashMap则可以看成一个二级哈希表,首先其维护的哈希表中存储的均为Segment对象,而各个Segment对象中同时也维护了一个哈希表,哈希表里面存放的才是真正我们要用的entry对象。


    • 如果两个线程同时操作两个Segment中的两个哈希表,那么自然也就不会出现线程安全性问题了,两者可以同时执行。


    • 这样子设计之后,当我们put进一个元素时,就需要进行两次hash值的获取,第一次先获取key所对应的Segment的位置,第二次再获取key在Segment中所对应entry对象的真正位置。

    • 当然,AB线程也有可能同时操作到同一个Segment,为了保障线程的安全性问题,Segment的写入是需要上锁的,因此对同一Segment的并发写入会被阻塞(由于只对写操作上锁,所以并发读或一个线程读一个线程写的情况并不会被阻塞)。


    • 由此可见,ConcurrentHashMap当中每个Segment各自持有一把锁。在保证线程安全的同时降低了锁的粒度(降低了线程阻塞的可能性),让并发操作效率更高。

  • 总结ConcurrentHashMap的get步骤和put步骤如下:

    • get

      • 1、为输入的Key做Hash运算,得到hash值。

      • 2、通过hash值,定位到对应的Segment对象

      • 3、再次通过hash值,定位到Segment当中数组的具体位置。

    • put

      • 1、为输入的Key做Hash运算,得到hash值。

      • 2、通过hash值,定位到对应的Segment对象

      • 3、获取可重入锁

      • 4、再次通过hash值,定位到Segment当中数组的具体位置。

      • 5、插入或覆盖HashEntry对象。

      • 6、释放锁。

  • ConcurrentHashMap如何保障size()方法数据的一致性?

    • ConcurrentHashMap中的size方法是通过将各个Segment内部的元素数量汇总起来从而得出ConcurrentHashMap元素的总数量的。

    • 假如size方法在统计完Segment1之后,准备统计Segment2的数量时,另一个线程往Segment1插入了一个元素,同时比size方法更先运行完毕。那么在size方法运行完成之后,所得出的数量值就会比map的总数量就会少了一个。那么ConcurrentHashMap是如何保证size方法数据的一致性的呢?

    • ConcurrentHashMap是使用了类似于CAS乐观锁的思想来保证size方法统计时不会出现问题,其步骤如下:

      • 1、遍历所有的Segment,把所有Segment的修改次数累加起来(我们在看集合源代码的时候经常会看到modCount++的这个操作,其实modCount变量就是用来统计当前集合修改次数用的)。

      • 2、在第一步遍历的时候,同时把Segment中内部的元素数量累加起来,得到size值。

      • 3、再一次统计所有Segment修改次数的总和。

      • 4、判断所有Segment的总修改次数是否大于我们第一步所统计的修改次数。如果大于,说明统计过程中有修改,重新统计(跳回第一步),记录尝试次数+1;如果不是。说明没有修改,统计结束。

      • 5、如果尝试次数超过阈值,则对每一个Segment加锁,再重新统计。

      • 6、再次判断所有Segment的总修改次数是否大于上一次的总修改次数。由于已经加锁,次数一定和上次相等。

      • 7、释放锁,统计结束。


猜你喜欢

转载自blog.csdn.net/qq906627950/article/details/79417097