HashMap源码解析、jdk7和8之后的区别、相关问题分析(多线程扩容带来的死循环)
一、概览
HashMap<String, Integer> map = new HashMap<>();
这个语句执行起来,在 jdk1.8 之前,会创建一个长度是 16 的 Entry[]
数组,叫 table
,用来存储键值对。
在 jdk 1.8 后,不在这里创建数组了,而是在第一次 put
的时候才会创建数组叫 Node[] table
,用来存储键值对。
二、源码的成员变量分析
声明部分:
HashMap 实现了 Map 接口,又继承了 AbstractMap
,但是 AbstractMap
也是实现了 Map
接口的,而且很多集合类都是这种实现,这是一个官方失误造成的冗余,不过一直流传了下来。
- 继承
AbstractMap
,这个父类作为抽象类,实现了Map
的很多方法,为了减少直接实现类的工作; - 实现
Cloneable
接口和Serializable
接口,这个问题在 原型模式 里面说过,就是深拷贝的问题,但是值得注意的是,HashMap 实现这两个接口,重写的方法仍然不是深拷贝,而是浅拷贝。
属性部分:
2.1 序列号serialVersionUID
序列化默认版本号,不重要。
2.2 默认初始化容量DEFAULT_INITIAL_CAPACITY
集合默认初始化容量,注释里写了必须是 2 的幂次方数
,默认是 16。
问题 1 : 为什么非要是 2 的次方数呢?
答:第一方面为了均匀分布,第二方面为了扩容的时候重新计算下标值的方便。
这个涉及到了插入元素的时候对每一个 node 的应该在的桶位置的计算:
核心在这个方法里,会根据 (n - 1) & hash
这个公式计算出 i
,hash
是提前算出的 key
的哈希值,n
则是整个 map
的数组的长度。
那么这个节点应该放在哪个桶,这就是散列的过程,我们当然希望散列的过程是尽量均匀的,而不会出现都算出来进入了 table[]
的同一个位置。那么,可以选择的方法有取余啊、之类的,这里采用的方法是位运算来实现取余。
就是(n - 1) & hash 这个位运算,2 的幂 -1 都是11111结尾的:
2 进制,所以 2 的几次方都是 1 00000(很多个 0 的情况),然后 -1, 就会变成 000 11111(很多个1)那么和 本来计算的具有唯一性的 hash 值相与,
- 用高位的 0 把hash 值的高位都置为了 0 ,所以限制在了 table 的下标范围内。
- 保证了 hash 值的尽量散开。
对于第 2 点,如果不是 2 的幂次方,那么 -1 就不会得到 1111 结尾,甚至如果是个基数,-1 后就会变成形如 0000 1110
这样的偶数,那么相与的结果岂不是永远都是偶数了?这样 table 数组就会有一半的位置永远利用不上的。所以 2 的幂次方以及 -1 的操作,才能保证得到和取模一样的效果。
因此得出结论,如果 n 是 2 的幂次方,计算出的位置会很均匀,相反则会干扰这个运算,导致计算出的位置不均匀。
第二个方面的原因就是扩容的时候,重新要计算下标值 hash
,2 的幂次方
带给了好处,下面的扩容部分有详细说明。
注意到我们初始化 HashMap 的时候可以指定容量。
问题 2 那么如果传入的容量并不是 2 的次方,怎么办呢?
从构造方法可以看到,调用指定加载因子和 容量的方法,如果大于最大容量,就会改为最大容量,接着对于容量,调用 tableSizeFor
方法,此时传入的参数已经肯定是 <=
最大容量的数字了。
tableSizeFor
这个方法会产生一个大于传入数字的、最小的 2
的幂次方数。
2.3 最大容量MAXIMUM_CAPACITY
最大 hashMap 的容量就是 1 左移 30 位,也就是 2 的 30 次方
。
2.4 默认加载因子DEFAULT_LOAD_FACTOR
默认加载因子为 0.75
,也就是说,如果键值对超过了当前的容量 * 0.75
,就会触发扩容。
问题 为什么是 0.75
而不是别的数呢?
答:如果加载因子越大,对空间的利用更充分,但是查找效率会降低(链表长度会越来越长);如果加载因子太小,那么表中的数据将过于稀疏(很多空间还没用,就开始扩容了),对空间造成严重浪费。
其实 0.75
是一个统计的结果,比较理想的值,根据旧版源码里面的注释,和概率的泊松分布有关系,当负载因子是 0.75
的情况下,哈希碰撞的概率遵循参数约为 0.5
的泊松分布,因此选择它是一个折衷的办法来满足时间和空间。
2.5 转树的阈值TREEIFY_THRESHOLD
默认为 8
,也就是说一个桶内的链表节点数多于 8
的时候,结合数组当前长度会把链表转换为红黑树。
问题 为什么是超过 8
就转为红黑树?
答:首先,红黑树的节点在内存中是普通链表节点方式存储的 2 倍
,成本是比较高的,那么对于太少的节点数目就没必要转化,继续扩容就行了。
结合负载因子 0.75
的泊松分布结果,每个链表有 8
个节点的概率已经到达可以忽略的程度,所以将这个值设置为 8
。为了避免出现恶意的频繁插入,除此之外还会判断数组长度是否达到了 64。
所以到这里我个人的理解是:
-> 最开始hashmap的思想就是数组加链表;
-> 因为数组里的各个链表长度要均匀,所以就有了哈希值的算法,以及适当的扩容,扩容的加载因子定成了 0.75 ;
-> 而扩容只能根据总共的节点数来计算,可能没来得及扩容的时候还是出现了在同一个链表里元素变得很多,所以要转红黑树,而这个数量就根据加载因子结合泊松分布的结果,决定了是8.
2.6 重新退化为链表的阈值UNTREEIFY_THRESHOLD
默认为 6
, 也就死说如果操作过程发现链表的长度小于 6
,又会把树退回链表。
2.7 转树的最小容量
不仅仅是说有链表的节点多于 8
就转换,还要看 table
数组的长度是不是大于 64
,只有大于 64
了才转换。为了避免开始的时候,正好一些键值对都装进了一个链表里,那只有一个链表,还转了树,其实没必要。
还有属性的第二部分:
第一个是容器 table
存放键值对的数组,就是保存链表或者树的数组,可以看到 Node
类型也是实现了 Entry
接口的,在 1.8
之前这个节点是不叫 Node
的,就叫的 Entry
,因为就是一个键值对,现在换成了 Node
,是因为除了普通的键值对类型,还可能换成红黑树的树节点TreeNode
类型,所以不是 Entry
了。
第二个是保存所有键值对的一个 set
集合,是一个存放缓存的;
第三个 size
是整个hashmap
里的键值对的数目;
第四个是 modCount
是记录集合被修改的次数,有助于在多个线程操作的时候报根据一致性保证安全;
第五个 threshold 是扩容的阈值,也就是说大于阈值的时候就开始扩容,也就是 threshold = 当前的 capacity * loadfactor
;
第六个 loadFactor
也是对应前面的加载因子。
三、源码的核心方法分析
3.1 构造方法
可以看到,这几个重载的构造方法做的事就是设置一些参数。
事实上,在 jdk1.8 之后,并不会直接初始化 hashmap
,只是进行加载因子、容量参数的相关设定,真正开始将 table
数组空间开辟出来,是在 put
的时候才开始的。
第一个:
public HashMap()
是我们平时最常用的,只是设置了默认加载因子,容量没有设定,那显然就是 16
。
第二个:
public HashMap(int initialCapacity)
为了尽量少扩容,这个构造方法是推荐的,也就是指定 initialCapacity
,在这个方法里面直接调用的是
第三个构造方法:
public HashMap(int initialCapacity, float loadFactor)
用指定的初始容量和加载因子,确保在最大范围内,也调整了 threshold 容量是 2 的幂次方数
。
这里就是一个问题,把 capcity
调整成 2 的幂次方
数,计算 threshold
的时候不应该要乘以 loadfactor
吗,怎么能直接赋给 threshold
呢?
原因是这里没有用到 threshold
,还是在 put
的时候才进行 table
数组的初始化的,所以这里就没有操作。
最后一个构造方法是,将本来的一个 hashmap 放到一个新的 map 里。
3.2 put 和 putVal 方法
put
方法是直接调用了计算 hash
值的方法计算哈希值,然后交给 putVal
方法去做的。
hash
方法就是调用本地的 hashCode
方法再做一个位移操作计算出哈希值。
为什么采用这种右移 16 位
再异或的方式计算 hash
值呢?
因为 hashCode
值一般是一个很大的值,如果直接用它的话,实际上在运算的时候碰撞的概率会很高,所以要充分利用这个二进制串的性质:int
类型的数值是 4
个字节的,右移 16
位,再异或可以同时保留高 16 位
和低 16 位
的特征,进行了混合得到的新的数值中,高位与低位的信息都被保留了 。
另外,因为,异或运算能更好的保留各部分的特征,如果采用 &
运算计算出来的值会向 1
靠拢,采用 |
运算计算出来的值会向 0
靠拢, ^
正好。
最后的目的还是一样,为了减少哈希冲突。
算出 hash 值后,调用的是 putVal 方法:
传入哈希值;要插入的 key 和 value;然后两个布尔变量,onlyIfAbsent 代表当前要插入的 value 是否存在了如果是 true,就不修改;evict 代表这个 hashmap 是否处于创建模式,如果是 false,就是创建模式。
下面是源码及具体注释:
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)
n = (tab = resize()).length;//调用resize方法初始化tab,验证了我们说的,构造方法不会创建数组,而是插入的时候创建。
//这个算法前面也已经讲过,就是计算索引,如果p的位置是 null,就在这里放入一个newNode;
//如果p的位置不是 null,说明这个桶里已经有链表或者树了,就不能直接 new ,而是要遍历链表插入,并同时判断是不是需要转树
if ((p = tab[i = (n - 1) & hash]) == null)
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))))
e = p;
else if (p instanceof TreeNode)
//已经不是链表是红黑树了,调用putTreeVal
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//是链表,用 for 循环遍历
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;//如果已经有值,覆盖,这里用到了onlyIfAbsent
afterNodeAccess(e);
return oldValue;
}
}
//增加修改hashMap的次数
++modCount;
//如果已经达到了阈值,就要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
这里面涉及到的步骤主要如下:
-
调用
resize
方法初始化table
数组,jdk1.8 后确实是到put
的时候才会初始化数组; -
用
hash
值计算出在数组里应该在的索引; -
如果索引位置是
null
,就直接放入一个新节点,也就是Node
对象; -
如果不是
null
,则要在这个桶里插入:- 如果遇见了一个节点的
hash
值、key值和传入的这个新的一样,赋值给e
这个节点; - 用
instanceof
判断是否为TreeNode
类型,也就是说如果这个桶里已经不是链表而是红黑树了,就调用putTreeVal
方法; - 如果不是,那就要遍历这个链表,同理,遍历的过程如果也找到了一个阶段的
hash
值、key
值和传入的一样,赋值给e
这个节点,否则遍历到最后,把一个Node
对象插到链表末尾,插完后链表长度已经大于阈值,就要转树。
- 如果遇见了一个节点的
-
结束插入的动作后,前面的
e
一旦被赋值过了,说明是有一样的key
出现,那么就说明不用插入新节点,而是替代旧的val
。
这里面涉及到的 resize 、putTreeVal 和 treeifyBin 也是比较复杂的方法,下来进行介绍。
3.3 treeifyBin 方法
转换为树的方法
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//如果数组的长度还没有达到 64 ,就不转树,只是扩容。
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
//如果 e 不为空,那么遍历整个链表,把每个节点都换成具有prev和next两个指针的树节点
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
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);
}
}
treeify 里面调用了各种左旋啊、右旋啊,平衡
啊,各种很复杂的红黑树操作方法,这里不再深入。
3.4 resize 扩容方法
问题:什么时候会扩容?
从前面成员变量的解释和插入元素,已经能总结出两种扩容的情况:
- 当键值对的元素个数(也就是键值对的个数,size)超过了
数组长度*负载因子(0.75)
的时候,扩容; - 当其中某一个链表的元素个数达到
8
个,并且数组长度没有达到64
,则扩容而不转红黑树。
扩容每次都会把数组的长度扩到 2
倍,并且之后还要把每个元素的下标重新计算,这样的开销是很大的。
值得注意的是,重新计算下标值的方法 和第一次的计算方法一样,这样很简便且巧妙:
- 首先,仍然使用
(n - 1) & hash
这个式子计算索引,但是显然有重新计算的时候,变化的是n-1
,有些就不会在原位置了; - 从
n
的变化入手,因为是2
倍扩容,而数组长度本身也设置是2
的幂次,在二进制位上来说,新算出来的n-1
只是相比旧的n-1
左移了一位;
比如 16-1 = 15,就是 1 0000 - 1 = 0 1111;
新的 32-1 = 31,就是 10 0000 - 1 = 01 1111;
- 那么这个值再和
hash
相与运算,节点要么在原来位置,要么在原位置+旧的容量的位置
,也就是在最高位加上了一个原来的容量; - 这样计算的时候就不用频繁的再计算,而是用一个加法就直接定位到要挪动的地方。
上面讲过的为什么长度设置 2 的幂次,这里也能作为一个优势的解释。
源码如下:
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; //这里把新的阈值和新的边界值都*2
}
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;
//创建新数组
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
//for循环就开始把所有旧的节点都放到新数组里
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 {
//是链表,保持顺序,用do-while循环进行新的位置安排
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) {//用hash和oldCap的与结果,拆分链表
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}else {//用hash和oldCap的与结果,拆分链表
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;//放在新索引位置,就是加上 oldCap
}
}
}
}
}
return newTab;
}
3.5 remove 和 removeNode 删除方法
remove 直接调用的 removeNode 方法,类似于前面的 put 调用 putVal 。
注意 remove
根据 key
的时候肯定默认那个对应的 value
也是要删除的,所以 matchValue
置为 false
,意思就是不用看 value
。
removeNode
的整体思路比较常规,就是我们能想到的:
-
如果本身
hashmap
不为空,且hash
值对应的索引位置不为空,才去某一个桶里找并删除;- 在遍历查找的过程里,分成对于链表节点和树节点的查找,就是根据
key
来比较的; - 找到之后,根据
matchValue
判断要不要删除,删除的过程就是用之前找到的那个位置,然后指针操作就可。
- 在遍历查找的过程里,分成对于链表节点和树节点的查找,就是根据
-
否则,直接返回
null
。
3.6 get 和 getNode 方法
get
也只直接调用了 getNode
方法:
这里面的代码就和 remove
方法的前半部分几乎一样,也就是找到指定的 key
的位置,并返回对应的 value
。
3.7 HashMap的遍历
HashMap 本身维护了一个 keySet 的 Set,拿到所有的 key 。(显然维护 value 是没办法的,因为 key 都是唯一的),但这种方法不推荐,因为拿到 key 后再去找 value又是对 map 的遍历。
Set<String> keys = map.keySet();
for (String key: keys){
System.out.println(key + map.get(key));//根据key得到value
}
也可以拿到所有的 value 需要用 Collection 来接收:
Collection<Integer> values = map.values();
for (Integer v: values){
System.out.println(v);
}
也可以获取到所有的键值对Entry 的 Set 集合,然后拿到对应的迭代器进行遍历:
Set<Map.Entry<String,Integer>> entries = map.entrySet();
Iterator<Map.Entry<String,Integer>> iterator = entries.iterator();
while (iterator.hasNext()){
Map.Entry<String,Integer> entry = iterator.next();
System.out.println(entry.getKey()+entry.getValue());//得到key和value
}
jdk 1.8 之后,还增加了一个 forEach 方法,可以接口里的这个方法本身也是通过第二种方法实现的,在HashMap 里重写了这个方法,变成了对 table 数组的遍历,使用的时候,用 lambda 表达式传入泛型就可以。
map.forEach((key,value)->{
System.out.println(key + value);
});
这种方法其实用到的也属于设计模式的代理模式
四、总结 jdk 1.7 和 1.8 之后关于 HashMap 的区别
4.1 数据结构的使用
- 1.7 :单链表
- 1.8 :单链表,如果链表长度>8且数组长度已经>64,转为红黑树
关于数组本身,1.7 是一个 Entry 类型的数组,1.8是一个 Node 类型。
4.2 什么时候扩容?
1.7 扩容时机
- 扩容只有一种情况。利用了两个信息:
数组长度 * 加载因子
。加载因子默认情况是0.75
,等键值对个数size
达到了数组长度 * 加载因子
;- 产生哈希冲突,当前插入的时候数组的这个位置已经不为空了。
扩容后,添加元素。
1.8 的扩容时机
先添加元素,再看是否需要扩容。
- 扩容的第一种情况。
数组长度 * 加载因子。
加载因子默认情况是 0.75
,等键值对个数 size
达到了数组长度 * 加载因子
(这点判断是一样的)
- 扩容的第二种情况。
当其中某一个链表的元素个数达到 8
个,走到转树节点的方法里,但是又发现数组长度没有达到 64
,则扩容而不转红黑树。
4.3 扩容的实现
1.7 扩容的实现
数组长度 * 2
操作;- 然后用一个 transfer 方法进行数据迁移,
transfer
里,对单向链表进行一个一个hash
重新计算并且安排,采用头插法来安排单向链表,把节点都安排好。
但是如果多线程的情况下,有别的线程先完成了扩容操作,这个时候链表的重新挪动已经导致节点位置的变化,切换回这个线程的时候,继续改变链表指针就可能会产生环,然后这个线程死循环。
具体就是 7 的扩容方法在迁移的时候采用的是头插法,那么比如两个元素 ab一个链表,线程1和2都发现要扩容,就会去调用transfer方法:
- 1 先读取了 e 是 a,next 是 b,但是没来得及继续操作就挂起了;
- 2 开始读取,并采用头插法就是遍历ab,先把a移到新数组的位置,此时a.next = null;继续遍历到 b,b移到新位置,b.next = a;(形成了 b->a)
- 这时候切换到了线程 1 执行,本来已经再循环里面记录了 e 和 e.next 了,然而这时本来数组都变新的了,所以修改的时候计算位置啥的还是这个新数组里,不会变,因为计算的肯定是一样的, a.next = b,而前面就修改过了b.next = a,这样已经是环了,那么线程 1 继续while,一直next,死循环。
1.8 扩容的实现
因为是先插入,再扩容,所以插入的时候对于链表就是一个尾插法。
然后如果达到了扩容的条件,也就先进行数组长度 * 2
操作,直接在 resize
方法里完成数据迁移,这里因为数据结构已经有链表+红黑树两种情况:
- 如果是
链表
,把单链表进行数据迁移,充分利用与运算,将单链表针对不同情况拆断,放到新数组的不同位置; - 如果是
红黑树
,树节点里维护了相当于双向链表的指针,重新处理,如果处理之后发现树的节点(双向链表)小于等于 6 ,还会再操作把树又转换为单链表。
但是如果在多线程的情况下,不会形成环链表,但是可能会丢失数据,因为会覆盖到一样的新位置。
4.4 为什么HashMap线程不安全
- put、get 等等核心方法在多线程情况下,都会出现修改的覆盖,数据不一致等等问题。比如多个线程 put 先后的问题,会导致结果覆盖,如果一个 put 一个get,也可能会因为调度问题获取到错误的结果。
- 正如上面具体分析过的死循环问题,在多线程扩容的时候,1.7的 hashmap 因为采用头插法进行扩容之后的重新节点分配,可能会出现死循环;
- 因为 Hashmap 的迭代器是 fast-fail iterator,所以多线程一边写操作一边遍历,会出现 ConcurrentModificationException 并发读写异常。