前言
HashMap做为日常使用较多的类,也是面试中的重点。本文重点在解读源码,希望大家看完本文,可以学习到HashMap的设计思想、扩容、rehash过程,面试不再迷茫。
构造函数
HashMap有几种构造函数,但最终都调用下方这个构造函数。
int initialCapacity 初始容量,用于决策数组size
float loadFactor 负载因子,用于决策是否需要扩容(使用容量 >= 数组size * 负载因子)
public HashMap(int initialCapacity, float loadFactor) {
//省略校验部分
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
//根据容量决策数组大小
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;
}
这里可以看到,threshold等于tableSizeFor方法的返回值,并不会直接消费构造传入的initialCapacity。tableSizeFor方法很复杂,或操作、位运算,我们可以不用理解具体细节,只需要知道这个方法会返回大于cap的最小2的N次方。
cap tableSize
3 4
4 4
5 8
10 16
20 32
为何数组大小必须是2的N次方
1、可以通过位运算快速计算key的hash值对应的数组下标
2、扩容rehash无需重新计算hash值对应新数组的下标
计算数组下标
如果我们自己实现此功能,简单做法是根据hash %(取模) 数组大小,但这么做效率较低,我们看HashMap如何做的。
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//(n - 1) & hash 计算数组下标
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//省略部分代码
}
return null;
}
如上诉源码,这是HashMap的get()调用的方法,通过(n - 1) & hash来计算下标,n为数组大小,既数组大小-1和hash值与操作。
为什么这么做就可以计算出下标呢?
我们回顾下与操作,两个二进制位都为1,结果位=1,否则=0。
假设数组size=16
16对应二进制 0001 0000
16-1对应二进制 0000 1111
如上图几个例子,最终结果只与hash最后4位有关,最后4位哪些位置为1,对应结果位就为1,否则都为0,故结果范围[0000,1111],即0-15范围,完美使用与运算解决数组下标问题。
扩容rehash无需重新计算数组下标
具体扩容和rehash见下面源码部分,此章节只描述rehash使用位运算计算新数组下标的思想。
当HashMap使用比例超过阀值,会触发扩容操作,正常情况下新数组size=老数组size*2(满足数组大小为2的N次方)。
HashMap的底层结构为数组+链表/红黑树,当hash冲突的情况下,数组会指向链表或红黑树,当扩容时,链表或红黑树数据也需要拆分,挂靠在新数组下。
如上图,数组16扩容到32,32大小的下标计算跟16大小对比,差异点在于倒数第五位是否为1,如果等于1,结果倒数第五位值为1,否则为0,而其他位置跟16大小时完全相等。
基于此,我们可以计算倒数第五位是0还是1,将链表或红黑树拆分成2部分。
等于0,新数组的下标等于旧数组下标
等于1,新数组的下标等于旧数组下标+旧数组size(0000 0001 -> 0001 0001 中间增加10000=旧数组size)
源码
以put方法为例,put成功后,put后校验使用率触发扩容操作。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//put操作,不是本文重点,忽略
++modCount;
//使用size+1,大于扩容阀值(容量 * loadFactor),进行扩容操作
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
//重新计算数组size,初始化&put使用率超过给定比例触发
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) {
//原先数组已达到最大上限,无法触发
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//未达到最大值,扩容为原先2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//省略部分代码
threshold = newThr;
//初始化数组,newCap = oldCap * 2
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;
//只存在一个数据,直接使用新数组大小计算hash值后放入数据
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//当前位存放红黑树,需拆分红黑树成2部分
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//此处只会为链表,循环链表,将链表拆分成2部分
do {
next = e.next;
//通过hash值和oldCap与操作,结果=0则数据存放在loHead
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//此处结果=1则数据存放在hiHead
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//拆分的2个链表,放入对应的位置
//与结果=0,放入原先位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//与结果=1,放入原先位置+原先数组size位置
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
上面源码看着很复杂,其实思想上文已描述清楚,如果你已经理解rehash思想,看此部分代码会容易理解。
这里解释下 (e.hash & oldCap) == 0
上文新旧数组下标计算图可知,存放在当前下标还是当前下标+老数组size位,取决于倒数第五位,即老数组size的二进制大小。
如图所示,由于oldCap为2的N次方,只有1个1,其他位都为0,如果hash值此位为0,则与运算结果为0。
(e.hash & oldCap) == 0 表示当前hash的当前位为0,放入loHead链表
(e.hash & oldCap) != 0 表示当前hash的当前位为1,放入hiHead链表
树节点split
上面已描述链表节点拆分,继续上树节点拆分。思想跟链表节点类型,通过 e.hash & bit == 0来拆分成两部分。
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
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;
//通过hash值和bit与操作,结果=0则数据存放在loHead
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
//此处结果=1则数据存放在hiHead
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
//树节点拆分的两部分数据,放入对应位置
//此处有点特殊,如果节点数量小于非树化阀值(6),需要转换成链表
//否则继续放入树结构
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null)
loHead.treeify(tab);
}
}
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
树节点的拆分代码跟链表拆分类似,唯一有区别的在于会判断拆分出两部分的大小,数量小于6则转换成链表,否则放入红黑树。
举例说明:
原本节点A指向一颗红黑树,数据大小为10。经过拆分,loHead大小为7,hiHead大小为3。
loHead大于6,转换成红黑树,放入A位置。
hiHead小于6,转换成链表,放入A+原数组size处。
总结
HashMap通过合理的设计数组大小,使用位运算巧妙的计算下标位置、扩容后新数组下标位置。
看完HashMap源码,不得不佩服大牛们,继续加油吧骚年!!!