由于jdk版本的升级导致源码的更新,因此hashmap的源码需要重新读一下,不过在本文记录时jdk的版本早就不是8版本了,只不过是1.7和1.8发生了本质的变化,因此才记录一下的。至于9,10版本,暂时不管了。
为了重新去读1.8版本的hashmap源码,特此做了些前期准备:
1.总述
关于之前学习的1.7版本,我着重学习了几个点,构造函数(容量大小,加载因子),put(),get(),扩容机制,扩容时机,hashcode的产生,hash冲突,线程安全问题,当然还有最重要的底层结构(数组+链表)。可以说关于hashmap,可学习和可关注的点太多太多。因此,文章不可能所有的都能涉及到,只能尽可能的去学习和理解。
那么在学习之前呢,我已经了解到底层结构的变成了数组+链表+红黑树。so,着重关注下红黑树部分应该说就能将1.8的源码拿下了。
1.源码分析
构造函数部分:
//构造函数1
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity); //1
}
//构造函数2
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//构造函数3
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
//构造函数4
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
构造函数3:常用构造函数,一般不指定大小,构造函数3只是指定了loadFactory(加载因子)的值,其他的值都没赋值。应该是后续由初始化的操作。
构造函数2和构造函数1:内部调用构造函数1指定一些常用值的初始值,这和1.7一致,不同点在于threshold的值的确定。
threshold(阈值)的确定
在1.7内,通过构造函数的操作就确定了,如下:
例如,构造函数是new HashMap(7),那么capacity就是8,而threshold就是8*0.75 = 6。
//设置capacity为大于initialCapacity且是2的幂的最小值
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
threshold = (int)(capacity * loadFactor);
而在1.8内,确不是如此,而是经历过两次操作,但是本质是还是一致的。首先构造函数内得到一个threshold的值,例如构造韩式是new HashMap(7),那么此处的threshold的值就是8。
this.threshold = tableSizeFor(initialCapacity);
但是在扩容resize()函数内,还存在一部分额外的初始化动作,threshold的值也在其内,最终threshold的值依然是8*0.75=6;
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
可以说tableSizeFor()函数的作用其实可以认为是capacity的获取的作用(得到大于initialCapacity且是2的幂的最小值)。
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;
}
此处 n |= n >>>1 等价于 n = n | n>>>1; >>>是无符号右移动。
例如 new HashMap(53);图解如下:
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;
if ((tab = table) == null || (n = tab.length) == 0)
//初始化数组table,初始化操作延迟到有新数据插入时并且合并到扩容逻辑内
n = (tab = resize()).length;//返回table桶的大小,默认还是16
if ((p = tab[i = (n - 1) & hash]) == null)
//定位key的hash值和桶的大小进行按位与操作,确定在桶内位置
//如果没有发生冲突,构造新的Node节点,进行插入
tab[i] = newNode(hash, key, value, null);
else {
//存在冲突
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//当前key和桶内的第一个Node的key相等,则指向它
e = p;
else if (p instanceof TreeNode)
//如果桶内元素是红黑树,则进行红黑树逻辑
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//此逻辑段均是在当期key和第一个key不相等的时候循环的
//遍历找到的当前桶内元素,并记录当前元素个数
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//如果不存在相同的key,则将元素连接到此链表后面
p.next = newNode(hash, key, value, null);
//如果数量超过阈值,则转成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果遍历的时候找到某个key和当前key相等,则跳出循环
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;
if (++size > threshold)//超过阈值,扩容
resize();
afterNodeInsertion(evict); //暂时不清楚
return null;
}
转成红黑树的条件()
条件1:如果当前桶内的 链表长度大于等于8个时,进入转变流程。 if (binCount >= TREEIFY_THRESHOLD - 1)
条件2:当
table的长度超过64时,才会将这一部分链表结构转成红黑树,不然依然是扩容。
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
treeifyBin()
转成红黑树的代码也是比较重点的一个部分,在文章的开头,关于红黑树的插入,删除和理论知识已经给出,不熟悉的可以先去练练手。
final void treeifyBin(HashMap.Node<K,V>[] tab, int hash) {
int n, index; HashMap.Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
//桶的长度小于64,只扩容,不转红黑树
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
//hd头结点,tl尾节点
HashMap.TreeNode<K,V> hd = null, tl = null;
do {
//先转成树型节点
HashMap.TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);//将链表结构转成树型节点链表结构
if ((tab[index] = hd) != null)
hd.treeify(tab); //转成红黑树
}
}
上面的put源码中已经分析过转成红黑树的两个条件了,链表长度>=8以及桶的大小超过64时才会转。
个人猜测原因:桶的容量在比较小时,hash冲突会比较高,扩容会非常频繁,如果此时就转成红黑树,那么优先扩容的话会减小不必要的树化过程,另一个减小扩容时的红黑树的重新映射的复杂度。
treeify()
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)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;
//遍历根节点,执行插入x节点操作,然后进行红黑树的修正操作
for (TreeNode<K,V> p = root;;) {
int dir, ph;
K pk = p.key;
//比较hash值,确定是左节点还是右节点
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); //hash值不能确定的,执行tieBreakOrder再次确认大小
TreeNode<K,V> 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);
}
HashMap在设计之初可以发现,键对象可以是任意对象,因此可能自定义的键对象没有实现comparable接口,因此如何比较键对象的大小就变得复杂的多。
所以在比较键对象大小时,1.8的代码中采取了3个步骤:
1. 比较hashcode的大小;
2. 检测键对象是否实现了comparable接口,如果实现了则调用compareTo比较;
3. 都没法比较则进行tieBreakOrder(class对象层面和system层面)比较;
balanceInsertion()
修正操作,关于修正操作我们去分析一下,场景的话我们借鉴文章最上面红黑树的理论分析来进行。基本和算法导论里的伪代码是一致的,看起来不费力。 static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
TreeNode<K,V> x) {
//待插入节点是红色的
x.red = true;
//xp=x.parent 待插入节点的父节点
//xpp=xp.parent 待插入节点的祖父节点
//xppl=xpp.left 待插入节点的祖父节点的左孩子节点
//xppr=xpp.left 待插入节点的祖父节点的右孩子节点
for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
if ((xp = x.parent) == null) {
//待插入节点就是根节点,设置为黑色就行
x.red = false;
return x;
}
else if (!xp.red || (xpp = xp.parent) == null)
//如果待插入节点的父节点是根节点或者父节点是黑色,结束
return root;
if (xp == (xppl = xpp.left)) {
//如果待插入节点的父节点是红色且是祖父节点的左孩子
if ((xppr = xpp.right) != null && xppr.red) {
//待插入节点的父节点是红色(总条件) 且 叔叔节点是红色
xppr.red = false; //设置叔叔节点是黑色
xp.red = false; //设置父节点是黑色
xpp.red = true; //设置祖父节点为红色
x = xpp; //设置祖父节点为当前节点,进行下一次修正
}
else {
if (x == xp.right) {
//待插入节点的父节点是红色(总条件) 且 叔叔节点是黑色 且 待插入节点是父节点的右孩
root = rotateLeft(root, x = xp); //设置父节点为当前节点进行左旋
xpp = (xp = x.parent) == null ? null : xp.parent; //设置新的祖父节点
}
if (xp != null) {
//设置父节点为黑色
xp.red = false;
if (xpp != null) {
//设置祖父节点为红色
xpp.red = true;
//以祖父节点为支点进行右旋
root = rotateRight(root, xpp);
}
}
}
}
else {
//镜像操作,全部相反,left变更为right,right变更为left
if (xppl != null && xppl.red) {
xppl.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}
else {
if (x == xp.left) {
root = rotateRight(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateLeft(root, xpp);
}
}
}
}
}
}
插入整体流程图
树化简易流程图
树化前
树化后(树化之前根据代码可知,是先转成的树型双向链表,因此prev和next关系就保留下来了),这也是和1.7不同之处,1.7内只有单链表的结构。此处保留prev和next的关键因素我觉得应该是和后续如果再次转成链表有关。
get()
get的主要流程其实和1.7没什么区别,在1.8的代码中,桶内第一个元素的重要性被提升了,主要还是因为红黑树的存在。 public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
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))))
//比较桶内第一个元素的key是否相等,相等则直接返回
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
//不相等的时候判断是否是红黑树节点,进入红黑树流程
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
//否则循环找到key相等的Node节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
resize()
最后一个关注点就是扩容,1.7的扩容针对元素就是重新rehash定位在新的桶里面的位置。而1.8的代码发生了一些思路上的改变。
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;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
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;
//以上和1.7一致,确定新桶大小和阈值大小等等常规参数的设置
@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;
}
针对单链表的扩容
也就是上述代码进行do while循环的地方,此处的思路和1.7发生了一些改变。
1.8的思想是针对这一条单链表做一下归类的操作,把位置没有发生改变的归成一类,位置发生改变的归成另一类。具体是怎么操作的呢?我们列举一些简单的例子一看便知:
例如我们现在有下面的一个基础hashmap结构,大小是16;阈值是12=16*0.75;桶内位置=15 & key;整体过程如下图:
小结一下:
这样看来1.8里,元素之间的相对位置并没有发生改变,由于是分组的关系,所以最终只要将head节点接到新桶内即可。
但是1.7里,如果单链表中的元素在新桶内具有相同的位置话,元素会倒置。
针对红黑树的扩容
//红黑树整体思路和单链表思路一致,也是先分组,然后判断是否需要转化
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
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和prev指针,因此可以通过这种方式遍历
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;
}
}
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)
//如果位置不变的元素个数小于6个,则转成单链表
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
//如果hiHead不为null,表明有元素从红黑树中移除,结构发生改变了,需要修正
loHead.treeify(tab);
}
}
//下面同理
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
//如果个数小于6个,则转成单链表
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
红黑树的扩容部分和单链表方式一致,但是在此间还存在了红黑树向单链表的转化,判断个数是6。代码如下:就不做图了。
final Node<K,V> untreeify(HashMap<K,V> map) {
Node<K,V> hd = null, tl = null;
for (Node<K,V> q = this; q != null; q = q.next) {
//从TreeNode向Node节点转变,然后连接成单链表
Node<K,V> p = map.replacementNode(q, null);
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}
1.7和1.8扩容问题的比较
1.7:
问题:链表的死循环(由于线程A操作了线程B扩容之后的正常的table数组导致死循环)。
现象:同一个位置的元素如果扩容后还是相同的位置,会出现倒置的现象,当然这不是问题,只是算法导致的。
1.8:
问题:不会出现链表的死循环(不针对红黑树的场景,只讨论单链表),可能造成数据丢失。
现象:元素之间的相对位置不会发生改变。
代码的不同
//1.7
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i]; //针对每个元素的next指针连接到新的位置的后续元素之前
newTable[i] = e; //针对每一个元素都连接到新的位置上
e = next;
}
}
}
//1.8
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
//将位置上的元素赋值给e,然后针对每一个Node节点置成null
oldTab[j] = null;
//...省略中间无关代码
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; //位置改变的一组
}
}
}
}
比较代码就能发现,1.8内由于栈变量e保存了此链表中的数据然后进行分组的关系,所以不可能出现死循环了,唯一的问题就是oldTab[j] = null;这个操作导致了元素被清空,也就是null的问题。所以在多线程下容易出现元素丢失。
总结:
1.8HashMap的正篇就到此为止吧,还有很多细节都没涉及到,就留给以后补充吧,一下子也没法方方面面的顾全到,一开始以为这一篇幅应该花不了多长时间,结果花了3天时间才整理了这么点东西。主要没有想到的是作者的处理思路发生了质的变化了。