HashMap是非线程安全的,在并发情况下可能会在扩容时形成环形链表,导致死循环(该问题在JDK1.8版本已经修复),也可能发生数据丢失的问题
put源码(JDK7U71)
①:在HashMap构造函数中,并未显式指定table值,而是指向了EMPTY_TABLE(空数组),在put方法中进行初始化;
②:key=null时,键值对直接放在table[0]中,若已存在null的key,则替换并返回旧值,若不存在null,则放入链表头部;
③:在1.7中,扩容条件不止是阈值,只有当元素数量>=阈值,且目标桶存在元素的情况下才会进行扩容;
④:扩容时,新数组的容量是旧数组的两倍;
⑤:扩容原理:创建新容量的数组,将旧数组的数据进行hash计算,放到新的数组中,最终将table指向新的数组;
以下代码来自JDK7U71版本
public V put(K key, V value) {
if (table == EMPTY_TABLE) {// ①
inflateTable(threshold);// 如果table指向空数组,则初始化数组
}
if (key == null)
return putForNullKey(value);// ② 空的键值对直接放到0桶
// 计算所在桶的位置
int hash = hash(key);
int i = indexFor(hash, table.length);
// 检查是否有key相同的键值对,如果有则替换并返回旧值
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
// 操作数+1
modCount++;
addEntry(hash, key, value, i);// 添加键值对
return null;
}
void addEntry(int hash, K key, V value, int bucketIndex) {
// ③ 判断是否需要扩容(注:当元素数量>=阈值,且目标桶存在元素的情况下才会进行扩容)
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);// ④
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
void resize(int newCapacity) {
Entry[] oldTable = table;
// 计算新的容量
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// ⑤
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));// initHashSeedAsNeeded是为了协助hash算法得到更好的散列值
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
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];// ⑦
newTable[i] = e;
e = next;
}
}
}
JDK7下的并发扩容
源码中计算桶位的算法比较复杂,不方便举例,此处以 index = XX % length
代替,例:传入e10
,此时index = 10 % 15 = 10
,所以e10存放在table[10]
;
并发扩容案例
假设现有如下map,table中输入如图所示:
此时线程A.put(e23)
、B.put(e24)
,对应indexA=23%16=7,indexB=24%16=8
,线程A、B均满足第③处代码的扩容条件;
假设线程B循环到e2,执行代码⑥后失去了CPU执行时间,此时B.e = 2,B.next = 34;
线程A获得执行时间,并执行完成transfer
方法(注:源码是先扩容再加入新的元素,所以图中newTableA[23]还未放入元素),此时table如下:
通过上面的newTableA可以看到e34
和e2
的关系如下:e34.next = e2
;
线程B重新获得执行时间,执行完当前循环,此时e2.next = null, B.e = e34, newTableB[2]=e2
;
开始下一轮循环,此时B.e = e34,执行完该轮循环:
B.next = B.e.next = e34.next = e2;// ⑥
B.e.next = newTableB[2] = e2;
newTable[2] = e34;
B.e = next = e2;
此时已经出现了端倪,B.e又回到了e2上,我们继续执行,再执行到第⑦处代码时,B.e.next = newTableB[2] = e.34
,此时就出现了环形链表:
程序继续向后执行,由于B.next = null
,所以在newTableB[2]处的循环执行结束(注意:e50并没有遍历到),newTableB最终结果如下:
在resize()
方法中,线程A、B依次执行,会导致newTableA被newTableB覆盖(或newTableB被newTableA覆盖),若newTableA被newTableB覆盖,此时发生数据丢失:e50
并没有遍历到,不会出现在newTableB中,数据发生丢失。
完成扩容后,调用get(66)时(key=66,不存在且index(66)=2),此时会遍历table[2]中的链表,造成死循环问题。
并发扩容导致的问题
- 数据丢失问题:扩容过程中形成环形链表,在环形链表之后的元素不会被遍历到,造成丢失(上例的
e50
); - 死循环:并发扩容形成环形链表,get(key)(key不存在且index(key)=环形链表所在桶)就会发生死循环问题;
JDK8
JDK8中HashMap采用数组+链表+红黑树的形式,在扩容时,新链表不再采用逆序插入的方式,而是保留头尾节点来完成正序插入,不会在链表处形成死循环;
看其他大佬的文章(HashMap在jdk1.8也会出现死循环的问题(实测)
)说JDK8中有可能会在链表与红黑树转换的过程中发生死循环,不过我没复现出来,不做争论;
总结
- HashMap在并发场景下可能会出现死循环、数据丢失的问题;
- HashMap在JDK7或8中都不是线程安全的类,并发场景下采用
ConcurrentHashMap
才是正解;