HashMap为什么线程不安全
HashMap的线程不安全体现在会造成死循环、数据丢失、数据覆盖等问题
其中死循环和数据丢失是在JDK1.7中出现的问题,在JDK1.8中已经得到解决,但是1.8中仍会有数据覆盖这样的问题。HashMap是线程不安全的,线程安全场景应该使用ConcurrentHashMap。
HashMap的数据丢失和死循环
HsshMap在JDK1.8(不含)之前对于新增元素的hash冲突的链表插入采用的是头插法,1.8之后开始改用尾插法。
JDK1.7中HashMap的数据结构为数组和链表构成
HashMap添加元素通过哈希函数计算出插入的位置,因为哈希本身就存在概率性,所以极端情况下会有多个元素hash到一个值上,就会形成链表,采用头插法会修改插入的顺序,后插入的在前面,先插入的在后面。因为写这个代码的作者认为后来的值被查找的可能性更大一点,提升查找的效率。
多个线程同时往HashMap添加新元素时,多次resize会有一定概率会使HashMap出现环形链表,因为每次resize需要把旧的数据映射到新的哈希表,
HashMap-jdk1.7.0_80
向HashMap里面添加元素
public V put(K key, V value) {
...
// 添加新元素
addEntry(hash, key, value, i);
return null;
}
void addEntry(int hash, K key, V value, int bucketIndex) {
// 如果当前map大小超出阈值,并且当前值再次触发了hash冲突,则resize map
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[] newTable = new Entry[newCapacity];
// 向新数组转移数据
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
转移数据至新数组
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
// 1. 遍历老table 转移数据
for (Entry<K,V> e : table) {
// 2. 如果元素不为空,遍历Entry元素 遍历链表
while(null != e) {
next = e.next;
...
// 获取数据存取下标
int i = indexFor(e.hash, newCapacity);
// 头插法 hash冲突老数据存放在 新元素后方
e.next = newTable[i];
// 3. 然后 将 新元素 存入 数组
newTable[i] = e;
// 4. 继续遍历Entry子节点
e = next;
}
}
}
单线程流程
假设我们有两个线程。
线程一 仅创建了新的数组 还没有转移数据 后被挂起
线程二 已经完成全部流程
然后 线程一被调度回来执行
线程一开始转移数据
执行下面代码
三次循环分析
// 假设线程一执行到这里就被调度挂起了,执行其他操作 此时 e = key3->key7->key5 所以next = key7
// 二次循环 e = key7->key3 所以 next =key3
// 三次循环 e = key3 所以next = null
next = e.next;
// 线程二执行完成后调度接着直接线程一
// 线程二执行后 key7后面指向了key3 所以next = key7 ->key3 此时原来的元素key5 已丢失
...
// 获取转移数据的存取下标
int i = indexFor(e.hash, newCapacity);
// 头插法 单线程下 此时应是 将数组之前先存放hash冲突下的数据 放在转移元素的后面,
//继续执行一次循环 此时newTable[i] = null 所以 e = key3 -> null
//二次循环 newTable[i] = key3 所以 e = key7->key3
//三次循环 newTable[i] = key7->key3 所以 e = key3->key7->key3 产生闭环
e.next = newTable[i];
//继续执行1 将 e 赋给 newtTable[i] = key3->null
//二次循环 newTable[i] = key7->key3
//三次循环 newTable[i] = key3->key7->key3
newTable[i] = e;
//继续执行1 e = key7 -> key3
//二次循环 e = key3
//三次循环技术 e = null 结束循环 产生环形链表
e = next;
第一次循环 后 结果为
第二次循环后结果为
第三次循环后结果为
在以后对该HashMap进行操作时会出现死循环 Infinite Loop。
HashMap的数据覆盖
JDK1.7出现的问题,在JDK1.8中已经得到了很好的解决,JDK1.8直接在resize函数中完成了数据迁移。在进行元素插入时使用的是尾插法然后在扩容。
但是在1.8中仍会有数据覆盖这样的问题,先看put源码:
HashMap-jdk1.8
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;
if ((p = tab[i = (n - 1) & hash]) == null) //判断是否出现hash碰撞,如果没有hash碰撞则直接插入元素,此处线程不安全
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)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
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;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold) //++size此处线程不安全
resize();
afterNodeInsertion(evict);
return null;
}
其中代码if ((p = tab[i = (n - 1) & hash]) == null)
是判断是否出现hash碰撞:
比如两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所以此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
还有一种情况就是代码 if (++size > threshold)中的++size
:
同样还是线程A、B,这两个线程同时进行put操作时,假设当前HashMap的zise大小为10,当线程A执行到此行代码时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B快乐的拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11写回主内存,然后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操作后,还是将size=11写回内存,此时线程A、B都执行了一次put操作,但是size的值只增加了1,所有说还是由于数据覆盖又导致了线程不安全。