Java集合中的HashMap类

Posted CoderBuff

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java集合中的HashMap类相关的知识,希望对你有一定的参考价值。

jdk1.8.0_144

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

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

  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关于这个方法的实现。

Java集合中的HashMap类

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

Java集合中的HashMap类

 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)

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

JDK7——HashMap#put

Java集合中的HashMap类

Java集合中的HashMap类

  来看看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未产生散列冲突

Java集合中的HashMap类

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

Java集合中的HashMap类

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

Java集合中的HashMap类

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

Java集合中的HashMap类

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

Java集合中的HashMap类

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

Java集合中的HashMap类

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

Java集合中的HashMap类

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

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

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

Java集合中的HashMap类

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

Java集合中的HashMap类

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

Java集合中的HashMap类

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

Java集合中的HashMap类

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

Java集合中的HashMap类

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

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

JDK8——HashMap#put

Java集合中的HashMap类

        所以关键的方法还是putVal。

Java集合中的HashMap类

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

Java集合中的HashMap类

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

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

        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会带来什么致命问题?

以上是关于Java集合中的HashMap类的主要内容,如果未能解决你的问题,请参考以下文章

Java中请说明集合类ArrayList与 HashMap的区别?

Java中的集合概述

Java 集合类学习之HashMap

Java集合类之HashMap

Java中请说明集合类ArrayList与 HashMap的区别?

java map接口,可变参数,Collections集合工具类