赞
踩
注:感谢美团点评技术团队的分享~,博客部分内容摘抄自其中。侵删!
今天我们来探究一下 HashMap 的内部实现机制。
明确 JDK 1.8 中的 HashMap 使用数组 + 链表 + 红黑树的结构进行实现。
HashMap 的底层思想主要是哈希表,我们来看看 Java 的设计者们是怎么使用数组 + 链表 + 红黑树设计出 HashMap 的。
既然是用哈希表进行实现,那么基本的数据结构就是数组了,HashMap 部分源码如下:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
transient Node<K,V>[] table; // HashMap 底层数据结构(Node 数组)
transient int size; // HashMap 中实际存在的键值对数量
transient int modCount; // 记录 HashMap 内部结构发生变化的次数,用于快速失败机制
int threshold; // 所能容纳的 key-value 对极限(我将之称为“负载”)
final float loadFactor; // 负载因子:默认 0.75
}
除了 table 数组之外,我将源码中的常用字段也贴了出来。对于上面的代码,我们需要注意以下几点:
有了对 table 数组的认识,那么我们用一张图来描述一下 HashMap 中的哈希表结构(来自 “美团点评技术团队” 侵删):
了解了 HashMap 中的成员变量,再来看一下 HashMap 中定义的常量:
// 默认的初始容量,必须是2的幂。 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //最大容量(必须是 2 的幂且小于 2 的 30 次方,传入容量过大将被这个值替换) static final int MAXIMUM_CAPACITY = 1 << 30; // 装载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; // JDK1.8特有 // 当 hash 值相同的记录超过 TREEIFY_THRESHOLD,会动态的使用一个专门的红黑树实现来代替链表结构,使得查找时间复杂度从 O(n) 变为 O(logn) static final int TREEIFY_THRESHOLD = 8; // JDK1.8特有 // 也是阈值,同上一个相反,当桶(bucket)上的链表数小于 UNTREEIFY_THRESHOLD 时红黑树转链表 static final int UNTREEIFY_THRESHOLD = 6; // JDK1.8特有 // 树的最小的容量,至少是 4 x TREEIFY_THRESHOLD = 32 然后为了避免(resizing 和 treeification thresholds) 设置成64 static final int MIN_TREEIFY_CAPACITY = 64;
现在,我们关心的是 table 数组中 Node 元素的实现,源码如下:
// 静态内部类、操纵了 Map 接口中的 Entry<K,V> 接口 static class Node<K,V> implements Map.Entry<K,V> { // key 所产生的 hash 值 (不变) final int hash; // key (不变) final K key; // value V value; // 指向下一个 Node 节点、(链地址法解决冲突) Node<K,V> next; // 构造函数 Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } // 这些方法都不可被重写 public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + "=" + value; } // 计算 key 所产生的 hash 码 public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } // 设置新值,返回旧值 public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } // 比较两个对象是否相等 public final boolean equals(Object o) { if (o == this) return true; // 是否都操作 Map.Entry 接口 if (o instanceof Map.Entry) { // 属于同一个类之后再对对象的属性进行比较 Map.Entry<?,?> e = (Map.Entry<?,?>)o; if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue())) return true; } return false; } }
在这里我们需要注意:
首先我们来了解一下 final 关键字在基本类型与引用类型的使用上有什么不同?
再来讨论,我们在使用 HashMap 时,为什么最好选用不可变对象作为 key。
来看一下选用可变对象作为 HashMap 的 key 有可能会造成什么影响?
import java.util.HashMap; import java.util.Map; public class MutableDemo1 { public static void main(String[] args) { Map<MutableKey, String> map = new HashMap<>(); MutableKey key = new MutableKey(10, 20); map.put(key, "Robin"); System.out.println(map.get(key)); key.setI(30); System.out.println(map.get(key)); } }
输出:
Robin
null
为什么最好不要使用可变对象作为 HashMap 的 key,结论:
如果 key 对象是可变的,那么 key 的哈希值就可能改变。在 HashMap 中可变对象作为 key 会造成数据丢失。
怎么解决?
我们对 HashMap 的基本组成结构已经有了完整的认识,接下来我们分析 HashMap 中最常用的方法之一:put()
。
直接上源码:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
在分析 putVal 的源码之前,我们先来看看 hash(key)
:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
key 的 hash 值就是这样得到的,key.hashCode()
是一个本地方法,具体实现在源码中并没有给出,但这并不是重点,我们需要注意的是在计算出 hash 值后,它又与本身的高 16 位进行了异或。(hash 值本身是 32 位)
为什么这样做?这样做的好处是什么呢?
主要是从速度、功效、质量来考虑的,这么做可以在数组 table 的 length 比较小的时候,也能保证考虑到高低 Bit 都参与到 Hash 的计算中,同时不会有太大的开销。在混合了原始 hashCode 值的高位和低位后,加大了低位的随机性,而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来,这就使得 hash 方法返回的值,具有更高的随机性,减少了冲突。
下面举例说明,n 为 table 的长度(假设为 16)。
在分析 put 方法的源码之前,我们先来看一张有关 put 方法执行过程的图解:
根据图片我们再对 put 方法的执行流程做一个总结,方便等下阅读源码:
putVal 方法源码:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // 哈希表为null || 哈希表的长度为 0(resize 也是一个经典的方法) if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // (n - 1) & hash 计算出 key 在哈希表中应该存储的位置(除留余数法,使用 & 运算 比 % 运算更快) if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; // 插入的 key 在 HashMap 中已经存在(之后进行 value 的直接覆盖) 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); // 当处理冲突的链节点数大于等于 8 的时候,转换红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) treeifyBin(tab, hash); break; } // 插入的 key 在 HashMap 中已经存在 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } // key 已经存在,直接覆盖旧值 if (e != null) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; // 记录 HashMap 内部结构发生变化的次数,用于快速失败机制 if (++size > threshold) resize(); // 扩容 afterNodeInsertion(evict); // 作用不明确 return null; }
put 方法分析到这里基本上就结束了,但是同样有两个值得思考的问题:
(n - 1) & hash
;resize()
。关于红黑树与快速失败机制,不在这篇博客中进行讲述。
你不觉得以(n - 1) & hash
这种方式定位元素在哈希表中的位置很有趣吗?
本质上,它还是“除留余数法”,只不过由于位运算的缘故,会比取模运算要高效许多。
但是使用这种方法有一个前提,就是哈希表 table 的长度 n 必须满足 2 幂次方,因为 n-1 对应的二进制就是前面全是 0,后面全是 1,相与后,只留下 hash 的后几位,正好在长度为 n 的数组下标范围内。
举个例子,假设 hash 值为 3,数组长度 n 为 16,那么我们使用取模运算得到:3 % 16 = 3
,使用 & 运算:0011 & (16 - 1)
即 0011 & 1111 = 0011
得到的还是 3。
而在 HashMap 中,哈希表 table 的默认初始值也为 16(源码如下):
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
我们不谈红黑树,但必须探究包含在 put 方法中的 resize(扩容)机制。了解过 resize 方法之后,你会感叹其设计之巧妙!
首先,对扩容机制做一个简单的介绍:
扩容(resize)就是重新计算容量,向 HashMap 对象里不停的添加元素,而 HashMap 对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。如果 HashMap
的实际大小 > 负载,则 HashMap 中的 table 的容量扩充为当前的一倍。容量翻倍后,重新计算每个 Node 的 index,将有限的元素映射到更大的数组中,减少 hash 冲突的概率。
我将扩容机制分为了两部分:1. 创建新的 table 数组;2. 对元素进行 rehash。
创建新的 table 数组,过程还是比较简单的:
我们先截取第一部分(创建新数组)的源码进行研究:
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) { // 超过最大值就不再扩充,随你去碰撞(将 threshold 设置为 Integer.MAX_VALUE,则不会产生扩容) if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // 扩容成功,更新 newCap 与 newThr 的大小(2 倍扩展) else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; } // !!!对应的哪种情况? else if (oldThr > 0) newCap = oldThr; // oldCap == 0 || oldTab == null else { newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 扩容失败(扩容后 newCap >= MAXIMUM_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; // rehash 的过程 ... ... return newTab; }
直接阅读 JDK 1.8 中的 rehash 过程让人有点头大,为了便于理解,我们先来看看 JDK 1.7 中的 rehash,总体来说,两个版本差别不大:
void transfer(Entry[] newTable) { Entry[] src = table; // src 引用了旧的 Entry 数组 int newCapacity = newTable.length; for (int j = 0; j < src.length; j++) { // 遍历旧的 Entry 数组 Entry<K,V> e = src[j]; // 取得旧 Entry 数组的每个元素 if (e != null) { src[j] = null; // 释放旧 Entry 数组的对象引用(for 循环后,旧的 Entry 数组不再引用任何对象) do { Entry<K,V> next = e.next; int i = indexFor(e.hash, newCapacity); // 重新计算每个元素在数组中的位置 e.next = newTable[i]; // 头插法 newTable[i] = e; // 将元素放在数组上 e = next; // 访问下一个 Entry 链上的元素 } while (e != null); } } }
为了方便理解,下面举个例子说明下扩容过程:
注:JDK 1.7 中的 put 方法使用的是头插法进行新节点的插入,在 JDK 1.8 中,则使用的是尾插法(见上述源码)。对 JDK 1.7 put 方法感兴趣的同学可自行查阅有关资料。
假设我们的 hash 算法就是简单的用 key mod 一下表的大小。其中的哈希桶数组 table 的 size = 2,key = 3、7、5,put 顺序依次为 5、7、3(JDK 1.7 头插法)。在 mod 2 以后都冲突在 table[1]
这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小 size 大于 table 的负载(threshold)时进行扩容。接下来的步骤是哈希桶数组 resize 成 4,然后所有的 Node 重新 rehash 的过程。
JDK 1.8 中的 rehash 过程与 JDK 1.7 大同小异,相比 JDK 1.7,它主要对重新定位元素在哈希表中的位置做了优化:
经过观测可以发现,我们使用的是 2 次幂的扩展(指长度扩为原来 2 倍),所以,元素的位置要么是在原位置,要么是在原位置再移动 2 次幂的位置。看下图可以明白这句话的意思,n 为 table 的长度,图(a)表示扩容前的 key1 和 key2 两种 key 确定索引位置的示例,图(b)表示扩容后 key1 和 key2 两种 key 确定索引位置的示例,其中 hash1 是 key1 对应的哈希与高位的运算结果。
table 在扩容之后,因为 n 变为 2 倍,那么 n-1 的 mask 范围在高位多 1bit(红色),因此新的 index 就会发生这样的变化:
因此,我们在扩充 HashMap 的时候,不需要像 JDK1.7 那样重新计算 hash,只需要看看原来的 hash 值新增的那个 bit 是 1 还是 0 就好了,是 0 的话索引没变,是 1 的话索引变成“原索引 + oldCap”。
了解了 JDK 1.8 相比 JDK 1.7 所做的优化之后,我们再看一下 JDK 1.8 中的 rehash 过程:
final Node<K,V>[] resize() { ... ... // rehash 的过程 if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { // 释放旧 Node 数组的对象引用(for循环后,旧的 Node 数组不再引用任何对象) oldTab[j] = null; // oldTab[j] 只有一个元素,直接进行 rehash 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 { // 原索引(头指针与尾指针) Node<K,V> loHead = null, loTail = null; // 原索引 + oldCap(头指针与尾指针) Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; // 对元素进行 rehash 的过程 do { next = e.next; // 原索引(尾插法) if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } // 原索引 + oldCap(尾插法) else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 将建立的链表放到新 table 数组合适的位置上 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
在多线程使用场景中,应该尽量避免使用线程不安全的 HashMap,而使用线程安全的 ConcurrentHashMap。那么为什么说 HashMap 是线程不安全的,下面举例子说明在并发的多线程使用场景中使用 HashMap 可能造成死循环。代码例子如下(便于理解,仍然使用 JDK1.7 的环境):
public class HashMapInfiniteLoop { private static HashMap<Integer,String> map = new HashMap<Integer,String>(2,0.75f); public static void main(String[] args) { map.put(5, "C"); new Thread("Thread1") { public void run() { map.put(7, "B"); System.out.println(map); }; }.start(); new Thread("Thread2") { public void run() { map.put(3, "A); System.out.println(map); }; }.start(); } }
其中,map 初始化为一个长度为 2 的数组,loadFactor = 0.75,threshold = 2 * 0.75 = 1,也就是说当 put 第二个 key 的时候,map 就需要进行 resize。
通过设置断点让线程 1 和线程 2 同时 debug 到 transfer 方法的首行。注意此时两个线程已经成功添加数据。放开 thread1 的断点至 transfer 方法的Entry next = e.next
这一行;然后放开线程 2 的断点,让线程 2 进行 resize。结果如下图。
newTable 是局部变量,所以两个线程都有自己扩容后开辟的新的 table 数组。(对应图中橙色与紫色方块)
注意,由于 Thread1 执行到了Entry next = e.next
这一行,因此 e 指向了 key(3),而 next 指向了 key(7),其在线程二 rehash 后,指向了线程二重组后的链表(rehash 之后,会将 newtable 赋值给 HashMap 的成员变量 table)。
接着下一部分:
线程一被调度回来执行,先是执行 newTalbe[i] = e(对应图中 thread1 的索引 3 处指向了 thread2 中 索引 3 处的 key = 3 的节点(thread2 中的 table 此时已经是成员变量了,因此共享)), 然后是 e = next,导致了 e 指向了 key(7),而下一次循环的 next = e.next 导致了 next 指向了 key(3)。
当 next 指向 key(3) 的时候,e 为 key(7),又经过一次循环后,结果如下图:
虚线也表示有引用指向 key(7),只不过是想将 thread1 所拥有的 table 与成员变量 table 区分开。
此时再更新 e 与 next 的值,e 为 key(3),next 为 null,因此下一次循环就是最后一次循环。经过下一次循环之后,由于 e.next = newTable[i] 导致 key(3).next 指向了 key(7),而此时的 key(7).next 已经指向了 key(3),环形链表就此形成。结果如下图:
于是,当我们用线程一调用 map.get(11) 时,悲剧就出现了——无限循环。
博主将这块内容看了好几遍,确实不好理解,如果大家对这部分内容还有任何疑惑的话,欢迎在评论区进行提问~~
(n - 1) & hash
。Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。