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方法类似。
- 将参数key的hash值和key作为参数,调用getNode方法;
- 根据(n - 1) & hash(key)计算key值所在散列桶的下标;
- 取出散列桶中的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方法后,一切方法基本上都能举一反三。所以在看完本文后,你应该试着问自己以下几个问题:
- HashMap的底层数据结构是什么?
- HashMap的put过程?
- HashMap的扩容机制?
- 并发环境下HashMap会带来什么致命问题?
这是一个能给程序员加buff的公众号
上一篇: JS函数节流和函数防抖问题分析