hashmap技术概览:
- 由
数组 + 链表
的方式实现,当hash冲突
的时候,会将新put值放到链表开头。- 初始化时会初始化容量(capacity)、加载因子(loadfactor)、阈值(threshold),其中
threshold = capacity * loadfactor
,缺省值分别是:12 = 16*0.75
。- 当
count
值大于等于阈值(threshold)
时,会进行动态扩容,扩容时扩容成原来容量(capacity)
的两倍,并对每个值进行重定位。- Java8后对链表进行了优化,如果链表长度超过
8
,会将链表变成红黑树
。
HashMap大部分的内容是比较好理解的,链表的实现是通过一个内部类Node
实现的:
//实现自Map.Entry接口,包含当前值的hash值、key、value、next节点的指针
static class Node implements Map.Entry {
final int hash;
final K key;
V value;
Node next;
//... 省略 ...
}
这里我们主要说下在动态扩容时hashmap是怎么实现的,Java8引入了红黑树,扩容方式也换了另一个方法,所以代码实现比Java7复杂了不止一倍,但本质差别不大,我们先从7的扩容代码resize()
来理解扩容的重新定位是如何实现的:
void resize(int newCapacity) { //传入新的容量
Entry[] oldTable = table; //引用扩容前的Entry数组
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
return;
}
Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组
transfer(newTable); //!!将数据转移到新的Entry数组里,这里包含最重要的重新定位
table = newTable; //HashMap的table属性引用新的Entry数组
threshold = (int) (newCapacity * loadFactor);//修改阈值
}
//遍历每个元素,按新的容量进行rehash,放到新的数组上
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了旧的Entry数组
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
Entry e = src[j]; //取得旧Entry数组的每个元素
if (e != null) {
src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
do {
Entry next = e.next;
int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
e.next = newTable[i]; //标记[1]
newTable[i] = e; //将元素放在数组上
e = next; //访问下一个Entry链上的元素
} while (e != null);
}
}
}
//调用传入hash值和容量,如:indexFor(e.hash, newCapacity)
static int indexFor(int h, int length) {
return h & (length - 1); //进行与操作,求出,这样比%求模快,这也是hashmap的容量都是2的次方的原因之一。
}
其中的哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在mod 2以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。
我们再来看下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,经过rehash之后,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。对应的就是下方的
resize()
的注释。
看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。
Java8 resize()源码:
final Node[] resize() {
Node[] oldTab = table; //引用扩容前的node数组
int oldCap = (oldTab == null) ? 0 : oldTab.length; //旧的容量
int oldThr = threshold; //旧的阈值
int newCap, newThr = 0; //新的容量、阈值初始化为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) //如果旧容量翻倍没有超过最大值,且旧容量不小于初始化容量16,则翻倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold - 初始化容量设置为阈值
newCap = oldThr;
else { // zero initial threshold signifies using defaults - 0的时候使用默认值初始化
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[] newTab = (Node[])new Node[newCap];
table = newTab; //创建新的数组,并引用
//如果老的数组有数据,也就是是扩容而不是初始化,才执行下面的代码,否则初始化的到这里就可以结束了
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) { //轮询老数组所有数据
Node e; //以一个新的节点引用当前节点,然后释放原来的节点的引用
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null) //如果e没有next节点,证明这个节点上没有hash冲突,则直接把e的引用给到新的数组位置上
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode)e).split(this, newTab, j, oldCap); //!!!如果是红黑树,则进行分裂
else { // preserve order
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node next;
do { //从这条链表上第一个元素开始轮询,如果当前元素新增的bit是0,则放在当前这条链表上,如果是1,则放在"j+oldcap"这个位置上,生成“低位”和“高位”两个链表
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e; //元素是不断的加到尾部的,不会像1.7里面一样会倒序
loTail = e; //新增的元素永远是尾元素
}
else { //高位的链表与地位的链表处理逻辑一样,不断的把元素加到链表尾部
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) { //低位链表放到j这个索引的位置上
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) { //高位链表放到(j+oldCap)这个索引的位置上
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
从这里看,如果没有红黑树,其实1.7与1.8处理逻辑大同小异,区别主要还是在树节点的分裂((TreeNode
这个方法上。
//resize时调用((TreeNode)e).split(this, newTab, j, oldCap);对树进行扩容或缩容,如果低于阈值会变成链表
/**
* Splits nodes in a tree bin into lower and upper tree bins,
* or untreeifies if now too small. Called only from resize;
* see above discussion about split bits and indices.
*
* @param map the map
* @param tab the table for recording bin heads
* @param index the index of the table being split
* @param bit the bit of hash to split on
*/
final void split(HashMap map, Node[] tab, int index, int bit) {
TreeNode b = this; //当前这个节点的引用,即这个索引上的树的根节点
// Relink into lo and hi lists, preserving order
TreeNode loHead = null, loTail = null;
TreeNode hiHead = null, hiTail = null;
int lc = 0, hc = 0; //高位低位的初始树节点个数都设成0
for (TreeNode e = b, next; e != null; e = next) {
next = (TreeNode)e.next;
e.next = null;
if ((e.hash & bit) == 0) { //bit=oldcap,这里判断新bit位是0还是1,如果是0就放在低位树上,如果是1就放在高位树上,这里先是一个双向链表
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;
}
}
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map); //!!!如果低位的链表长度小于阈值6,则把树变成链表,并放到新数组中j索引位置
else {
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)如果高位树是空,即整个树没变化,那么树其实是不用重新调整的
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);
}
}
}
//树转变为单向链表
final Node untreeify(HashMap map) {
Node hd = null, tl = null;
for (Node q = this; q != null; q = q.next) {
Node p = map.replacementNode(q, null);
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}
//链表转换为红黑树,会根据红黑树特性进行平衡、左旋、右旋等
//TODO 这里不细讲了,后续我会写一篇博客专讲红黑树在这里的实现
final void treeify(Node[] tab) {
TreeNode root = null;
for (TreeNode x = this, next; x != null; x = next) {
next = (TreeNode)x.next;
x.left = x.right = null;
if (root == null) {
x.parent = null;
x.red = false;
root = x;
}
else {
K k = x.key;
int h = x.hash;
Class> kc = null;
for (TreeNode p = root;;) {
int dir, ph;
K pk = p.key;
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
TreeNode xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
root = balanceInsertion(root, x);//对树进行平衡插入,里面包括左旋右旋等操作
break;
}
}
}
}
moveRootToFront(tab, root);
}