一文教会你HashMap为啥线程不安全(多图VIP版)

首先思考一下,为啥 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 如下图所示:
一文教会你HashMap为啥线程不安全(多图VIP版)_第1张图片
现在线程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的数组:
一文教会你HashMap为啥线程不安全(多图VIP版)_第2张图片
紧接着,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 循环的代码⑤:
一文教会你HashMap为啥线程不安全(多图VIP版)_第3张图片
此时,线程2因为调度原因被挂起,只有线程1继续执行。

线程1执行完第 1 次 while 循环:
一文教会你HashMap为啥线程不安全(多图VIP版)_第4张图片

线程1执行完第 2 次 while 循环:
一文教会你HashMap为啥线程不安全(多图VIP版)_第5张图片

线程1执行完第 3 次 while 循环:
一文教会你HashMap为啥线程不安全(多图VIP版)_第6张图片

此时线程1的 e 为 null,跳出 while 循环。

此时,线程2调度回来继续执行

回顾下初始状态:
一文教会你HashMap为啥线程不安全(多图VIP版)_第7张图片
线程2执行完第 1 次 while 循环:
一文教会你HashMap为啥线程不安全(多图VIP版)_第8张图片
线程2执行完第 2 次 while 循环:
一文教会你HashMap为啥线程不安全(多图VIP版)_第9张图片
接下来,线程2开始执行第 3 次 while 循环,该次循环是重头戏,我们将每一步的过程展示出来:
一文教会你HashMap为啥线程不安全(多图VIP版)_第10张图片
一文教会你HashMap为啥线程不安全(多图VIP版)_第11张图片
可以看到,这一步就产生了环形链表,也就是死循环的根源!!!

接着往下执行:
一文教会你HashMap为啥线程不安全(多图VIP版)_第12张图片
一文教会你HashMap为啥线程不安全(多图VIP版)_第13张图片
此时线程2的 e 为 null,跳出 while 循环。

到这里,线程1、2的 transfer() 方法均执行完成,继续执行代码⑤:

table = newTable;

如果线程1后执行代码⑤,则新的 HashMap 如下图所示:
一文教会你HashMap为啥线程不安全(多图VIP版)_第14张图片
如果线程2后执行代码⑤,则新的 HashMap 如下图所示:
一文教会你HashMap为啥线程不安全(多图VIP版)_第15张图片
无论哪种情况,key1和key9均存在1个环,会导致死循环。

同时,如果线程2后执行代码⑤,还会丢失掉key5,造成数据丢失问题。

通过分析发现,JDK7 HashMap 在多线程下出现死循环的原因是,扩容的时候采用了头插法,会发生链表反转,在一定情况下会出现环形链表,进而触发死循环。JDK8 虽然将头插法修改为尾插法,但其"增删改"的操作仍旧是非原子的,所以还是线程不安全的。

归根结底,HashMap 设计之初就是用在单线程场景下的,如果要实现其线程安全,需要通过锁或者 CAS的手段将其"增删改"的操作原子化。在多线程环境中,推荐使用 ConcurrentHashMap。

你可能感兴趣的:(Java,Java多线程,java,hashmap,死循环)