了解了HashMap底层实现原理后,很容易的能推导出HashMap元素插入的步骤,先计算元素hash值,然后mod哈希表长度得到应存入的桶的下标,最后挂链,看一下源码。
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i;
//哈希表为空或长度为0,对其进行初始化 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
//元素所在的桶不包含键值对,直接将键值对的引用存入桶中 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else {
//节点e存储的是 插入节点的引用 Node<K,V> e; K k;
//如果key的值以及哈希值和桶中第一个键值对相等,将e指向该键值对 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p;
//如果桶中节点类型为树形节点,则使用红黑树的插入方式 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else {
//遍历链表 for (int binCount = 0; ; ++binCount) {
//链表遍历到最后仍没有发现链中包含 与要插入的键值对相同键值的节点 则将要插入的节点挂在该链表的最后 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null);
//如果新节点挂链后桶中链表的长度大于树化阈值 对链表进行树化操作 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; }
//当前链表包含要插入的键值时,终止遍历 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } }
//e!=null的情况表示桶中链表存在要插入的节点的键值 if (e != null) { // existing mapping for key V oldValue = e.value;
//onlyIfAbsent表示是否仅在oldValue为空的情况下更新键值对的值 if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount;
//键值对数量超过阈值,扩容操作 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
HashMap插入元素主要步骤解析我已用注释说明,应该不难看懂,这里还想说一下的是onlyIfAbsent这个变量,这个变量表示的是:在桶中链表存在要插入的节点的键值时,是否仅在旧值为空的情况下更新键值对的值,可以看到put方法这个参数传值为false,所以在HashMap在插入节点时,若key值相同,新值替换旧值。
PS. 在HashMap中,在使用自定义类作为键的类型时,如果自定义类重写equals方法,则一定要重写hashCode方法,在源码中可以看到,HashMap在比较key是否相等时,首先比较key的哈希值,然后再使用equals,如果重写equals方法未重写hashCode方法,则会出现两个对象实际上是“相等”的,但在HashMap中并不认为是两个相同的key,导致在节点插入时key值相同的节点的值不会被更新,而是被视作一个新节点,或者查找操作时无法根据key值找到相应的value。总的来说,key值的比较主要遵循以下两点:
1、如果两个对象相同(即用equals比较返回true),那么它们的hashCode值一定要相同。
2、如果两个对象的hashCode相同,它们并不一定相同(即用equals比较返回false)。
接下来就要说一下Hash Map的扩容机制了。HashMap的扩容主要分为两步,第一步将桶的长度扩至两倍,第二步计算哈希值,重新挂链。
源码如下,有点长。
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0;
//table不为空,表明已经初始化过了 if (oldCap > 0) {
//容量已达到最大值,不再扩容,阈值调至最大 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; }
//按旧容量和阈值的二倍计算新容量和阈值 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold }
//table未被初始化,将threshold 的值赋值给 newCap
else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr;
//table未被初始化,设置容量为默认容量,阈值为容量和负载因子的乘积 else { // zero initial threshold signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); }
//重新按照公式计算阈值 if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"})
//创建新的桶数组,完成初始化 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab;
//旧的哈希表不为空,重新挂链 if (oldTab != null) {
//遍历桶 for (int j = 0; j < oldCap; ++j) { Node<K,V> e;
//桶中存在键值对 if ((e = oldTab[j]) != null) { oldTab[j] = null;
//桶中只存在一个键值对,计算哈希值放入新的哈希表的桶中 if (e.next == null) newTab[e.hash & (newCap - 1)] = e;
//如果是树形节点,对红黑树进行拆分 else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//遍历链表,按原顺序分组 else { // preserve order Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null);
//映射至新桶 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
我们分步解析一下:
1 )计算新的容量和新的阈值
整个计算过程对应以上源码的第一个和第二个条件分支,大致如下:
//第一个条件分支
if (oldCap > 0) {
//嵌套分支 if (oldCap >= MAXIMUM_CAPACITY) {...} else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY){...}
} else if (oldThr > 0) {...} else {...}
//第二个条件分支 if (newThr == 0) {...}
第一个条件分支覆盖情况如下:
- oldCap > 0 :table已被初始化。
- oldCap == 0 && oldThr > 0 :table未被初始化且阈值>0,调用HashMap(int)和HashMap(int,float)构造方法会产生这种情况,这种情况下newCap = threshold = tableSizeFor(initialCapacity),新阈值在第二个条件分支计算。
- oldCap == 0 && oldThr == 0 :table未被初始化且阈值=0,调用HashMap()构造方法产生这种情况,这首设置容量为默认值,通过公式计算阈值。
第二个条件分支覆盖情况如下:
- newThr == 0 :第一个条件分支未计算新阈值或在计算过程中新阈值溢出归零,根据公式计算阈值。
以上为新容量和新阈值的计算过程。
2 )节点计算哈希值,重新挂链
在Java8中,重新映射节点时需要判断节点类型,如果是树形节点,需要对其先进行拆分再映射,如果是链表类型节点,则要对其先分组再映射,本文只对链表链表类型的节点映射进行分析。
桶的下标的计算方式为(n - 1) & hash,假设旧的哈希表的容量为16,两个元素的哈希值不同,但与n-1进行与运算后,由于只有后四位参与运算,得到的桶的下标相同。
哈希表扩容后容量变为32,得到的桶的下标发生了变化,如下图所示。
扩容后,参与运算的位数变成了五位,由于两个哈希值的第五位不同,所以得到的桶的下标也就不相同了,所以说对链表类型的节点分组就是将新位置的节点同原位置的节点分开,将桶中的一条链表拆分成两条,并且保证原有的顺序。HashMap通过(e.hash & oldCap) == 0判断节点是否为新位置节点,如下图所示。
扩容方法中使用loHead、loTail、hiHead、hiTail四个节点存放两条链表的头节点与末尾节点的指针。
loHead:原位置节点链表头节点指针
loTail:原位置节点链表末尾节点指针
hiHead:新位置节点链表头节点指针
hiTail:新位置节点链表末尾节点指针
最后将两条链表存放至相应的桶中。
以上就是HashMap插入键值对以及扩容部分的源码解析。
参考资料:https://www.cnblogs.com/chn58/p/6544599.html
https://segmentfault.com/a/1190000012926722