Java集合中的HashMap类

jdk1.8.0_144

         HashMap作为最常用集合之一,继承自AbstractMap。JDK8的HashMap实现与JDK7不同,新增了红黑树作为底层数据结构,结构变得复杂,效率变得更高。为满足自身需要,也重新实现了很多AbstractMap中的方法。本文会围绕HashMap,详细探讨HashMap的底层数据结构、扩容机制、并发环境下的死循环问题等。

         JDK8同JDK7一样对Map.Entry进行了重新实现,改了个名字叫——Node,我想这是因为在红黑树中更方便理解,方法和JDK7大体相同只是取消了几个方法。并且此时的Node节点(也就是Entry)结构更加完善:

1 static class Node<K,V> implements Map.Entry<K,V> {
2     final int hash;            //节点hash值
3     final K key;                 //key值
4     V value;                      //value值
5     Node<K,V> next;      //指向的下一个节点
6 
7     //省略,由于JDK8的Map接口新增了几个compare比较的方法,Node直接就继承了
8 
9 }

  Node作为HashMap维护key-value的内部数据结构比较简单,下面是HashMap重新实现Map的方法。

public int size()

         HashMap并没有继承AbstractMap的size方法,而是重写了此方法。HashMap在类中定义了一个size变量,再此处直接返回size变量而不用调用entrySet方法返回集合再计算。可以猜测这个size变量是当插入一个key-value键值对的时候自增。

public boolean isEmpty()

         判断size变量是否0即可。

public boolean containsKey(Object key)

         AbstractMap通过遍历Entry节点的方式实现了这个方法,显然HashMap觉得效率太低并没有复用而是重写了这个方法。

         JDK8的HashMap底层数据结构引入了红黑树,它的实现要比JDK7略微复杂,我们先来看JDK7关于这个方法的实现。

1 //JDK7,HashMap#containsKey
2 public boolean containsKey(Object key) {
3     return getEntry(key) != null;        //调用getEntry方法
4 }

  getEntry实现的思路也比较简单,由于JDK7的HashMap是数组+链表的数据结构,当key的hash值冲突的时候使用链地址法直接加到冲突地址Entry的next指针行程链表即可。所以getEntry方法的思路也是先计算key的hash值,计算后再找到它在散列表的下标,找到过再遍历这个位置的链表返回结果即可。

  JDK8加入了红黑树,在链表的个数达到阈值8时会将链表转换为红黑树,如果此时是红黑树,则不能通过遍历链表的方式寻找key值,所以JDK8对该方法进行了改进主要是需要遍历红黑树,有关红黑树的具体算法在此不多介绍。

1 //JDK8,HashMap#containsKey
2 public boolean containsKey(Object key) {
3     return getNode(hash(key), key) != null;    //JDK8中新增了一个getNode方法,且将key的hash值计算好后作为参数传递。
4 }
5 //HashMap#getNode
6 final Node<K,V> getNode(int hash, Object key) {
7     //此方法相比较于JDK7中的getEntry基本相同,唯一不同的是发现key值冲突过后会通过“first instanceof TreeNode”检查此时是否是红黑树结构。如果是红黑树则会调用getTreeNode方法在红黑树上进行查询。如果不是红黑树则是链表结构,遍历链表即可。
8 }

 public boolean containsValue(Object value)

  遍历散列表中的元素

public V get(Object key)

   在JDK8中get方法调用了containsKey的方法getNode,这点和JDk7的get方法中调用getEntry方法类似。

  1. 将参数key的hash值和key作为参数,调用getNode方法;
  2. 根据(n - 1) & hash(key)计算key值所在散列桶的下标;
  3. 取出散列桶中的key与参数key进行比较:

         3.1 如果相等则直接返回Node节点;

         3.2 如果不相等则判断当前节点是否有后继节点:

                   3.2.1 判断是否是红黑树结构,是则调用getTreeNode查询键值为key的Node   节点;

                   3.2.2 如果是链表结构,则遍历整个链表。

public V put(K key, V value)

  这个方法最为关键,插入key-value到Map中,在这个方法中需要计算key的hash值,然后通过hash值计算所在散列桶的位置,判断散列桶的位置是否有冲突,冲突过后需要使用链地址法解决冲突,使之形成一个链表,从JDK8开始如果链表的元素达到8个过后还会转换为红黑树。在插入时还需要判断是否需要扩容,扩容机制的设计,以及在并发环境下扩容所带来的死循环问题。

  由于JDK7比较简单,我们先来查看JDK7中的put方法源码。

JDK7——HashMap#put

 1 //JDK7, HashMap#put
 2 public V put(K key, V value) {
 3     //1. 首先判断是否是第一次插入,即散列表是否指向空的数组,如果是,则调用inflateTable方法对HashMap进行初始化。
 4     if (table == EMPTY_TABLE) {
 5         inflateTable(threshold);
 6     }
 7     //2. 判断key是否等于null,等于空则调用putForNullKey方法存入key为null的key-value,HashMap支持key=null。
 8     if (key == null)
 9         return putForNullKey(value);
10     //3. 调用hash方法计算key的hash值,调用indexFor根据hash值和散列表的长度计算key值所在散列表的下标i。
11     int hash = hash(key);
12     int i = indexFor(hash, table.length);
13     //4. 这一步通过循环遍历的方式判断插入的key-value是否已经在HashMap中存在,判断条件则是key的hash值相等,且value要么引用相等要么equals相等,如果满足则直接返回value。
14     for (Entry<K,V> e = table[i]; e != null; e = e.next) {//如果插入位置没有散列冲突,即这个位置没有Entry元素,则不进入循环。有散列冲突则需要遍历链表进行判断。
15         Object k;
16         if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
17             V oldValue = e.value;
18             e.value = value;
19             e.recordAccess(this);
20             return oldValue;
21         }
22     }
23     //插入
24     modCount++;//记录修改次数,在并发环境下通过迭代器遍历时会抛出ConcurrentModificationException异常(Fail-Fast机制),就是通过这个变量来实现的。在迭代器初始化过程会将modCount赋给迭代器的ExpectedModCount,是否会抛出ConcurrentModificationException异常的实现就是在迭代过程中判断modCount是否与ExpectedModCount相等。
25     //插入key-value键值对,传入key的hash值、key、value、散列表的插入位置i
26     addEntry(hash, key, value, i);    
27 }                

 

 1 //JDK7,HashMap#addEntry,这个方法是put方法的实现核心,在其中会判断是否冲突,是否扩容。
 2 void addEntry(int hash, K key, V value, int bucketIndex) {
 3     //第一步判断就是是否扩容,需要扩容的条件需要满足以下两个:1、Map中的key-value的个数大于等于Map的容量threshold(threshold=散列表容量(数组大小)*负载因子)。2、key值所对应的散列表位置不为null。
 4     if ((size >= threshold) && (null != table[bucketIndex])) {
 5         resize(2 * table.length);        //关键的扩容机制,扩容后的大小是之前的两倍
 6         hash = (null != key) ? hash(key) : 0;        //计算key的hash值
 7         bucketIndex = indexFor(hash, table.length);        //重新计算key所在散列表的下标
 8     }
 9     //创建Entry节点并插入,每次插入都会插在链表的第一个位置。
10     createEntry(hash, key, value, bucketIndex);    
11 }

  来看看HashMap是如何扩容的。JDK7HashMap扩容的大小是前一次散列表大小的两倍2 * table.length

void resize(int newCapacity)

  在这个方法中最核心的是transfer(Entry[], boolean)方法,第一个参数表示扩容后新的散列表引用,第二参数表示是否初始化hash种子。

  结合源码我们用图例来说明HashMap在JDK7中是如何进行扩容的。

  假设现在有如下HashMap,初始容量initialCapacity=4,负载因子loadFactor=0.5。初始化时阈值threshold=4*0.5=2。也就是说在插入第三个元素时,HashMap中的size=3大于阈值threshold=2,此时就会进行扩容。我们从来两种情况来对扩容机制进行分析,一种是两个key-value未产生散列冲突,第二种是两个key-value产生了散列冲突。

  1. 扩容时,当前HashMap的key-value未产生散列冲突

  此时当插入第三个key-value时,HashMap会进行扩容,容量大小为之前的两倍,并且在扩容时会对之前的元素进行转移,未产生冲突的HashMap转移较为简单,直接遍历散列表对key重新计算出新散列表的数组下标即可。

  2. 扩容时,当前HashMap的key-value产生散列冲突

  在对散列冲突了的元素进行扩容转移时,需要遍历当前位置的链表,链表的转移若新散列表还是冲突则采用头插法的方式进行插入,此处需要了解链表的头插法。同样通过for (Entry<K,V> e : table)遍历散列表中的元素,判断当前元素e是否为null。由例可知,当遍历到第2个位置的时候元素e不为null。此时创建临时变量next=e.next。

  重新根据新的散列表计算e的新位置i,后面则开始通过头插法把元素插入进入新的散列表。

  通过头插法将A插入进了新散列表的i位置,此时指针通过e=next继续移动,待插入元素变成了B,如下所示。

  此时会对B元素的key值进行hash运算,计算出它在新散列表中的位置,无论在哪个位置,均是头插法,假设还是在位置A上产生了冲突,头插法后则变成了如下所示。

  可知,在扩容过程中,链表的转移是关键,链表的转移通过头插法进行插入,所以正是因为头插法的原因,新散列表冲突的元素位置和旧散列表冲突的元素位置相反。

  关于HashMap的扩容机制还有一个需要注意的地方,在并发条件下,HashMap不仅仅是会造成数据错误,致命的是可能会造成CPU100%被占用,原因就是并发条件下,由于HashMap的扩容机制可能会导致死循环。下面将结合图例说明,为什么HashMap在并发环境下会造成死循环。

  假设在并发环境下,有两个线程现在都在对同一个HashMap进行扩容。

  此时线程T1对扩容前的HashMap元素已经完成了转移,但由于Java内存模型的缘故线程T2此时看到的还是它自己线程中HashMap之前的变量副本。此时T2对数据进行转移,如下图所示。

  进一步地,在T2中的新散列表中newTable[i]指向了元素A,此时待插入节点变成了B,如下图所示。

  原本在正常情况下,next会指向null,但由于T1已经对A->B链表进行了转置B->A,即next又指回了A,并且B会插入到T2的newTable[i]中。

  由于此时next不为空,下一步又会将next赋值给e,即e = next,反反复复A、B造成闭环形成死循环。

  所以,千万不要使用在并发环境下使用HashMap,一旦出现死循环CPU100%,这个问题不容易复现及排查。并发环境一定需要使用ConcurrentHashMap线程安全类。

  探讨了JDK7中的put方法,接下来看看JDK8新增了红黑树HashMap是如何进行put,如何进行扩容,以及如何将链表转换为红黑树的。

JDK8——HashMap#put

1 //JDK8, HashMap#put
2 public V put(K key, V value) {
3     //在JDK8中,put方法直接调用了putVal方法,该方法有5个参数:key哈希值,key,value,onlyIfAbsent(如果为ture则Map中已经存在该值的时候将不会把value值替换),evict在HashMap中无意义
4     return putVal(hash(key), key, value, false, true);
5 }

  所以关键的方法还是putVal。

 1 //JDK8中putVal方法和JDK7中put方法中的插入步骤大致相同,同样需要判断是否是第一次插入,插入的位置是否产生冲突,不同的是会判断插入的节点是“链表节点”还是“红黑色”节点。
 2 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
 3   //1. 是否是第一次插入,是第一次插入则复用resize算法,对散列表进行初始化
 4   if ((tab = table) == null || (n = tab.length) == 0)
 5     n = (tab = resize()).length;
 6   //2. 通过i = (n - 1) & hash计算key值所在散列表的下标,判断tab[i]是否已经有元素存在,即有无冲突,没有则直接插入即可,注意如果插入的key=null,此处和JDK7的策略略有不同,JDK7是遍历散列表只要为null就直接插入,而JDK8则是始终会插入第一个位置,即使有元素也会形成链表
 7   if ((p = tab[i = (n - 1) & hash]) == null)
 8     tab[i] = newNode(hash, key, value, null);
 9   //3. tab[i]已经有了元素即产生了冲突,如果是JDK7则直接使用头插法即可,但在JDK8中HashMap增加了红黑树数据结构,此时有可能已经是红黑树结构,或者处在链表转红黑树的临界点,所以此时需要有几个判断条件
10   else {
11      //3.1 这是一个特殊判断,如果tab[i]的元素hash和key都和带插入的元素相等,则直接覆盖value值即可
12    if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
13      e = p;
14     //3.2 待插入节点是一个红黑树节点
15     else if (p instanceof TreeNode)
16      e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
17    //3.3 插入后可能继续是一个链表,也有可能转换为红黑树。在元素个数超过8个时则会将链表转换为红黑树,所以第一个则需要一个计数器来遍历计算此时tab[i]上的元素个数
18    else {
19      for (int binCount = 0; ; ++binCount) {
20        if ((e = p.next) == null) {
21          p.next = newNode(hash, key, value, null);        //遍历到当前元素的next指向null,则通过尾插法插入,这也是和JDK7采用头插法略微不同的地方
22          if (binCount >= TREEIFY_THRESHOLD - 1) // tab[i]的数量超过了临界值8,此时将会进行链表转红黑树的操作,并跳出循环
23            treeifyBin(tab, hash);
24            break;
25              }
26              if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))        //这种情况同3.1,出现了和插入key相同的元素,直接跳出循环,覆盖value值即可,无需插入操作
27                 break;
28              p = e;
29      }
30    }
31    if (e != null) {        //这种情况表示带插入元素的key在Map中已经存在,此时没有插入操作,直接覆盖value值即可
32      V oldValue = e.value;
33      if (!onlyIfAbsent || oldValue == null)
34        e.value = value;
35      afterNodeAccess(e);
36      return oldValue;
37    }
38  }
39  ++modCount;        //修改计数,在使用Iterator迭代器时会和这个变量比较,如果不相等,则会抛出ConcurrentModificationException异常
40  if (++size > threshold)    //判断是否需要扩容
41    resize();
42  afterNodeInsertion(evict);        //并无意义
43  return null;
44 }

 

  从上面的JDK7和JDK8的put插入方法源码分析来看,JDK8确实复杂了不少,在没有耐心的情况下,这个“干货”确实显得比较干,我试着用下列图解的方式回顾JDK7和JDK8的插入过程,在对比过后接着对JDK8中的红黑树插入、链表转红黑树以及扩容作分析。

  综上JDK7和JDK8的put插入方法大体上相同,其核心均是计算key的hash并通过hash计算散列表的下标,再判断是否产生冲突。只是在实现细节上略有区别,例如JDK7会对key=null做特殊处理,而JDK8则始终会放置在第0个位置;而JDK7在产生冲突时会使用头插法进行插入,而JDK8在链表结构时会采用尾插法进行插入;当然最大的不同还是JDK8对节点的判断分为了:链表节点、红黑树节点、链表转换红黑树临界节点。

  对于红黑树的插入暂时不做分析,接下来是对JDK8扩容方法的分析。

 1 // JDK8,HashMap#resize扩容,HashMap扩容的大小仍然是前一次散列表大小的两倍 
 2 final Node<K,V>[] resize() {
 3  //1. 由于JDK8初始化散列表时复用了resize方法,所以前面是对oldTab的判断,是否为0(表示是初始化),是否已经大于等于了最大容量。判断结束后newTab会扩大为oldTab的两倍,同样newThr(阈值)也是以前的两倍。源码略。
 4  //2. 确定好newTab的大小后接下来就是初始化newTab散列表数组
 5  Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
 6  table = newTab;
 7  //3. 如果是初始化(即oldTab==null),则直接返回新的散列表数组,不是则进行转移
 8  //4. 首先还是遍历散列表
 9  for (int j = 0; j < oldCap; ++j) {
10  //5. e = oldCap[i] != null,则继续判断
11    //5.1 当前位置i,是否有冲突,没有则直接转移
12    if (e.next == null)
13      newTab[e.hash & (newCap - 1)] = e;    //这里并没有对要转移的元素重新计算hash,对于JDK7来会通过hash(e.getKey()) ^ newCap重新计算e在newTab中的位置,此处则是e.hash & (newCap - 1),减少了重新计算hash的过程。扩容后的位置要么在原来的位置上,要么在原索引 + oldCap位置 
14    //5.2 判断是否是红黑树节点
15    else if (e instanceof TreeNode)
16      ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
17    //5.3 判断是否是链表节点
18    else {
19     20    }
21   }
22 }

 

  JDK8的扩容机制相比较于JDK7除了增加对节点是否为红黑树的判断,其余大致相同,只是做了一些微小的优化。特别在于在JDK8中并不会重新计算key的hash值。

public V remove(Object key)

  如果已经非常清楚put过程,我相信对于HashMap中的其他方法也基本能知道套路。remove删除也不例外,计算hash(key)以及所在散列表的位置i,判断i是否有元素,元素是否是红黑树还是链表。

  这个方法容易陷入的陷阱是key值是一个自定义的pojo类,且并没有重写equals和hashCode方法,此时用pojo作为key值进行删除,很有可能出现“删不掉”的情况。这需要重写equals和hashCode才能使得两个pojo对象“相等”。

  剩下的方法思路大同小异,基本均是计算hash、计算散列表下标i、遍历、判断节点类型等等。本文在弄清put和resize方法后,一切方法基本上都能举一反三。所以在看完本文后,你应该试着问自己以下几个问题:

  1. HashMap的底层数据结构是什么?
  2. HashMap的put过程?
  3. HashMap的扩容机制?
  4. 并发环境下HashMap会带来什么致命问题?

 

 

这是一个能给程序员加buff的公众号 

posted @ 2018-03-13 21:32  OKevin  阅读(8170)  评论(1编辑  收藏  举报