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)this
AbstractMap经过遍历Entry节点的方式实现了这个方法,显然HashMap以为效率过低并无复用而是重写了这个方法。spa
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方法相似。
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 //第一步判断就是是否扩容,须要扩容的条件须要知足如下两个:一、Map中的key-value的个数大于等于Map的容量threshold(threshold=散列表容量(数组大小)*负载因子)。二、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方法后,一切方法基本上都能触类旁通。因此在看完本文后,你应该试着问本身如下几个问题:
这是一个能给程序员加buff的公众号