前文
在正式的源码学习开始之前,我们需要对学习的东西有一个较为大概的理解。HashMap作为平时开发过程中经常使用的一种数据结构,基本上的特性也是被我们熟知,例如实际存储形式为键值对、相同的key只能存在一个、与HashTable的区别等。但是这些相对而言只是在应用层面上的东西,如果真正想要在应用HashMap时能发挥其最大的优点并且避免踩到一些坑,了解其底层实现时必不可少的。
本文将按以下的目录结构对HashMap的源码进行简单的分析。以下JDK版本为1.8。
- HashMap的结构
- API
- 内部类
- 参数
- Hash算法
- put()和get()
- 1.7和1.8的区别
结构
API
HashMap实现了Map接口,我们先来看看Map接口定义了哪些基础API。
首先是大多集合类都存在的一些方法,size()、put()、get()、remove()、clear()、isEmpty()等,还有返回Key集合或Value集合的方法,其余是与Map这种键值对存储结构相关的一些方法。
JDK1.8开始,HashMap提供了一些函数式编程的API,我们在某些场景可以通过这些方法非常简便明了的完成我们的业务场景。
内部类
HashMap中内部类有如上几个,HashMap的存储数据结构基于Node和TreeNode,这两个内部类实现了Map接口的内部类。
Node存储节点的相关代码如下:
/** 基本的hash桶节点 */
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // hash值
final K key;
V value;
Node<K,V> next; // 链表的next节点
}
HashMap的底层结构大家应该都有所了解,在一般情况下,是以数组 + 链表的形式存储。而该链表的基本存储单元就是这个Node结构,其存储key和value,并且保存key对应的hash值,在一些方法调用的时候避免重复计算hash值,并且持有一个next节点的引用,保证一个链表的形式。下图就是HashMap以数组 + 链表的形式保存数据的示意图。
而TreeNode是在链表长度大于一定程度时,在链表转换成红黑树结构时使用的存储节点,其除了Node的四个属性外,还持有其parent、left、right和prev节点的引用(还有LinkedHashMap.Entry的before和after)。下图就是HashMap以数组 + 链表 + 红黑树的形式保存数据的示意图。
其他内部类较为简单,除了一些作为HashMap访问其内部key、value或entry节点的集合类,剩余的是一些相关的迭代器等,在本文中不做深入探讨。
参数
首先是loadFactor负载因子和size容量这两个参数,在了解这两个参数之前,我们知道HashMap可以通过4个公有的构造函数来进行实例化。
4个构造函数分别是指定初始化容量并指定负载因子构造、指定初始化容量构造、默认构造和给定另一个Map对象来进行构造。从代码里也可以看出,在实例化HashMap时,我们只能自定义这两个参数,未指定则使用默认值。
我们先来了解一下这两个值的含义。
size参数即该map包含的key-value映射数量,而其对应的默认值DEFAULT_INITIAL_CAPACITY为16,其必须为2的幂次,至于为什么必须为2的幂次,是因为HashMap的hash算法,这个我们在说到hash算法部分的时候再提。
loadFactor参数即HashMap的负载因子。什么是负载因子呢?负载因子决定就是HashMap在其已有元素(负载)达到什么程度时进行扩容的因子。其对应的默认值DEFAULT_LOAD_FACTOR为0.75f,表示HashMap已有元素达到容量的3/4时,HashMap会进行扩容操作。举例即在初始容量为16,在HashMap中元素为12并且元素离散分布在HashMap的不同位置时,下一个元素插入有较大的可能导致Hash碰撞,因此需要进行扩容,保证HashMap元素的离散性。
由这两个参数得来的扩容临界值成为阈值threshold,一旦HashMap的容量大于这个值,就会触发扩容机制尝试扩容。扩容的容量上限为MAXIMUM_CAPACITY = 1 << 30,如果超过了这个上限,则不会再进行扩容(最高位为符号位,因此只能扩容到这个大小,而不能扩容到1 << 31)。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// ... put操作
// 如果size大于阈值,则进行扩容操作
if (++size > threshold)
resize();
return null;
}
在JDK1.8后HashMap的数组 + 链表的形式变成了数组 + 链表 + 红黑树,该机制是为了解决Hash碰撞频繁的情况下,Hash表退化成链表,导致查询时间复杂度提高到O(N),因此在链表长度大于某个阈值时,将该链表转换成红黑树的结构,保证O(log N)的时间复杂度。这个阈值为TREEIFY_THRESHOLD=8,表示在链表长度大于8时转换成红黑树。在实际使用过程中,其实会考虑到数组上的那个节点,因此在TREEIFY_THRESHOLD-1的大小时就会进行转换。
当然,存在树化阈值必定也存在反树化阈值UNTREEIFY_THRESHOLD=6,树退化回链表的长度阈值,比成树阈值略小,使用类似懒加载的方式避免频繁进行结构变化。
关于树化机制还有一个比较重要的参数MIN_TREEIFY_CAPACITY=64。只有在容量大于该值时,才会启用树化机制。如果HashMap的容量小于该值,则即使达到树化条件,也会以扩容来替代树化,而不会直接进行树化。在树元素较少的情况下,查询效率不一定优于链表。
剩下的属性为entrySet,持有键值对集合。table属性,即上图中所示的Node数组。modCount,类似版本号的属性,在很多集合类中都存在,保证fail-fast机制。
Hash算法
关于这部分的重要方法有两个,hash()和tableSizeFor()。这两个方法的作用分别是计算hash值和计算一个合适的tableSize。
hash()
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
jdk1.8的hash()方法相较于jdk1.7较为不同,具体区别我们放到后面去讲,这里先介绍一下1.8版本的hash算法。在 HashMap 中哈希表的初始化长度是 16,如果直接用 hashCode 对长度取模来寻址,那么相当于只有低 4 位有效,其他高位不会有影响。这样假如几个 hashCode 分别是 2^10、2^20、2^30,那么寻址结果 index 就会一样而发生冲突,所以哈希表就不均匀分布了。因此jdk1.8中将高位与低位进行异或运算,进行扰动计算,减少这种冲突(HashMap允许保存null key,其把null key的hash值计算直接返回了0)。
tableSizeFor()
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
在解释这个方法之前,我们现在看看HashMap的如何通过hash值来进行寻址的。从图中可以看出通过 (n - 1) & hash 来计算hash值对应的数组下标(n为数组长度)。那为什么不直接使用 hash % n 的方式来寻址呢。事实上, (n - 1) & hash 计算的即为 hash % n (在n为2的幂次时,这就是为什么HashMap的size必须是2的幂次),而使用与运算替代取模,运算会更快,因此HashMap需要保证哈希表的长度为2的幂次,而tableSizeFor()方法干的正是此事。
通过tableSizeFor()方法,能获取到大于等于入参cap的最小2的幂次,例如tableSizeFor(6)=8,tableSizeFor(12)=16。这样一来使hash表的长度是2的幂次,就能保证通过 (n - 1) & hash 寻址方式能正确高效地得到hash值对应的数组索引。
put()与get()
put
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
Put()方法通过调用final修饰的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;
// 如果hash表为空,则进行resize
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 对应位置上(n-1)&hash如果为空,则直接创建新节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// key冲突
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;
}
}
// 返回
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 修改"版本号"
++modCount;
// 如果size大于阈值,则进行扩容操作
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
resize()
我们来看看主要的resize()方法,相关的注释都写的比较详细了。
final Node<K,V>[] resize() {
// 将当前table保存一份
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
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
}
// 如果构造时指定了初始化容量,则该容量保存在threshold中,通过该值进行初始化
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 否则使用默认初始化容量,并计算相应threshold
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
// 指定了初始化容量后,计算新的threshold
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
// 将旧table中的数据放到新的table中
@SuppressWarnings({"rawtypes", "unchecked"})
Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap];
table = newTab;
if (oldTab != null) {
// 遍历旧table数组节点
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 该节点为单个节点,通过 hash & (capcity - 1) 计算新table中的索引放进去
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 如果是树节点,通过TreeNode#split()方法进行分配
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;
// hash & oldCap == 0 的节点留置低位
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;
}
// 高位链表放到 j + oldCap 处
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
我们主要来关注一下注释中标注了“!!!重要!!!”的代码。其实这一块主要是从旧哈希表rehash到新哈希表的过程。
如果遍历到的节点为单节点,则通过上面提到的寻址方法找到索引,直接放到新表对应位置;如果是树节点,则通过TreeNode#split()方法进行重新分配;如果是链表节点,就比较复杂了。
从代码中可以看出,在rehash链表节点时,HashMap的设计者准备了两个链表,分别是low和high,对应索引低位和高位的两个链表,即把当前的一条长链表,拆分成一条或两条链条。可以看到代码中通过 (e.hash & oldCap) == 0 条件来进行判断,true则放入低位,false则放入高位,并且分配完之后将低位链表放置原位不动,高位链表则放置到「当前索引 j + 旧表长度 oldCap」的位置。其实这一块代码和jdk1.7有非常大的不同,并且jdk1.8修复了一个由这块代码导致的严重bug,这个我们留至后面再提。
TreeNode#split()
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// 将树重新分配成两个链表,通过前序遍历的方式保存
// 同样分成low和high两个链表
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
// 该判断条件与链表拆分相同
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
// 放置索引为index
if (loHead != null) {
// 根据拆分后的链表长度决定是否要恢复树化
if (lc <= UNTREEIFY_THRESHOLD)
// 将TreeNode替换成Node
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
// 同上 放置索引为index + bit(oldCap)
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
将树重新分配其实是将树结构打回链表的前序遍历形式保存,并通过和链表分配相同的方式进行rehash,具体的逻辑不再详细介绍。
get()
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
get()方法通过getNode()方法实现。同样跟着代码来一步步看。
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 表不为空,并且key对应的位置存在
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 首先检查第一个节点
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
// 如果是树节点,则通过TreeNode#getTreeNode()方法查询
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 否则遍历链表查询对应key
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
首先这一步我们还是先看链表的操作方式,链表的操作方式非常简单,就是普通的链表查询相应的key。
如果是树节点,则通过TreeNode#find()方法来查询。
TreeNode#getTreeNode()
final TreeNode<K,V> getTreeNode(int h, Object k) {
return ((parent != null) ? root() : this).find(h, k, null);
}
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
TreeNode<K,V> p = this;
do {
int ph, dir; K pk;
TreeNode<K,V> pl = p.left, pr = p.right, q;
// 通过hash值比较来决定往哪边遍历
if ((ph = p.hash) > h)
p = pl;
else if (ph < h)
p = pr;
// 相等直接返回
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
// 先遍历右边
else if (pl == null)
p = pr;
// 再遍历左边
else if (pr == null)
p = pl;
// 通过提供的类来比较
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
p = (dir < 0) ? pl : pr;
else if ((q = pr.find(h, k, kc)) != null)
return q;
else
p = pl;
} while (p != null);
return null;
}
底层就是最简单的树遍历查询。
1.7和1.8的区别
JDK1.7和1.8对于HashMap的实现,有不少地方是较为不同的。
1.7使用数组 + 链表,1.8使用数组 + 链表 + 红黑树。
1.7使用的是头插法(其导致了一个严重的bug),1.8使用的是尾插法。
1.7 rehash有可能改变链表的顺序(头插法导致),1.8 rehash保证原链表的顺序。
1.7与1.8的hash算法不同。
1.7与1.8的resize方法有较大不同。
1.7与1.8的区别比较将在之后单独写一篇文章来详细地比较(其实是因为我还没有看完1.7的源码~)。
后文
文中几个关键点参考了几篇博客。
https://www.cnblogs.com/eycuii/p/12015283.html
https://mp.weixin.qq.com/s/_zbOHbQa2zDVosXUlYUrSQ
本文是我在学习HashMap源码时的一些笔记和理解,如果有任何不对的地方,欢迎评论指出~