首先思考一下,为啥 HashMap 会存在线程安全性问题?
有的人脱口而出,JDK7 的 HashMap 因为采用头插法,多线程环境下会造成死循环,JDK8 虽然改用了尾插法,但多线程环境下仍然存在丢失更新的问题,所以 HashMap 存在线程安全性问题。
一听就是老八股人了,哈哈哈。
但其实上面的答案并不全面,而且很容易误导编程的新手,让新手总以为 HashMap 只是因为死循环或者丢失更新的问题才导致的线程不安全。
HashMap 之所以存在线程安全性问题,本质上是因为 HashMap 的"增删改"操作均是多步操作的集合,或者说是非原子的。
在多线程环境下,非原子的操作可能因为线程调度的问题,引发各种线程不安全性问题。以 put() 方法为例:
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry 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;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}
无论是 mod++,还是 addEntry() 操作,均是非原子的操作。
综上,HashMap 之所以线程不安全,本质上是因为其"增删改"的操作均是非原子的,死循环和丢失更新只是其中最具代表的线程不安全表现。
下面重点讲一下 JDK7 版本的死循环是如何产生的。
假设当前 HashMap 如下图所示:
现在线程1将 put(13, “H”),线程2将 put(17, “G”),两个线程同时走到了 addEntry(hash, key, value, i); 里:
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);
}
此时,size=3 >= 4*0.75 且 table[1] != null,2个线程均可以通过代码①处的 if 判断,进入到 resize(2 * table.length); 里,进行扩容操作:
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));
// 代码⑤
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
当2个线程执行完代码③后,分别创建了长度为8的数组:
紧接着,2个线程均进入到 transfer(newTable, initHashSeedAsNeeded(newCapacity)); 里,进行元素转移操作:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry e : table) {
while(null != e) {
// 代码⑥
Entry 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;
}
}
}
假设2个线程均执行了第一次 while 循环的代码⑤:
此时,线程2因为调度原因被挂起,只有线程1继续执行。
此时线程1的 e 为 null,跳出 while 循环。
此时,线程2调度回来继续执行。
回顾下初始状态:
线程2执行完第 1 次 while 循环:
线程2执行完第 2 次 while 循环:
接下来,线程2开始执行第 3 次 while 循环,该次循环是重头戏,我们将每一步的过程展示出来:
可以看到,这一步就产生了环形链表,也就是死循环的根源!!!
接着往下执行:
此时线程2的 e 为 null,跳出 while 循环。
到这里,线程1、2的 transfer() 方法均执行完成,继续执行代码⑤:
table = newTable;
如果线程1后执行代码⑤,则新的 HashMap 如下图所示:
如果线程2后执行代码⑤,则新的 HashMap 如下图所示:
无论哪种情况,key1和key9均存在1个环,会导致死循环。
同时,如果线程2后执行代码⑤,还会丢失掉key5,造成数据丢失问题。
通过分析发现,JDK7 HashMap 在多线程下出现死循环的原因是,扩容的时候采用了头插法,会发生链表反转,在一定情况下会出现环形链表,进而触发死循环。JDK8 虽然将头插法修改为尾插法,但其"增删改"的操作仍旧是非原子的,所以还是线程不安全的。
归根结底,HashMap 设计之初就是用在单线程场景下的,如果要实现其线程安全,需要通过锁或者 CAS的手段将其"增删改"的操作原子化。在多线程环境中,推荐使用 ConcurrentHashMap。