HashMap多執行緒併發問題分析

ccj659發表於2018-06-21

1 併發問題的症狀

1.1 多執行緒put後可能導致get死迴圈

從前我們的Java程式碼因為一些原因使用了HashMap這個東西,但是當時的程式是單執行緒的,一切都沒有問題。後來,我們的程式效能有問題,所以需要變成多執行緒的,於是,變成多執行緒後到了線上,發現程式經常佔了100%的CPU,檢視堆疊,你會發現程式都Hang在了HashMap.get()這個方法上了,重啟程式後問題消失。但是過段時間又會來。而且,這個問題在測試環境裡可能很難重現。

我們簡單的看一下我們自己的程式碼,我們就知道HashMap被多個執行緒操作。而Java的文件說HashMap是非執行緒安全的,應該用ConcurrentHashMap。但是在這裡我們可以來研究一下原因。簡單程式碼如下:

package com.king.hashmap;

import java.util.HashMap;

public class TestLock {

	private HashMap map = new HashMap();

	public TestLock() {
		Thread t1 = new Thread() {
			public void run() {
				for (int i = 0; i < 50000; i++) {
					map.put(new Integer(i), i);
				}
				System.out.println("t1 over");
			}
		};

		Thread t2 = new Thread() {
			public void run() {
				for (int i = 0; i < 50000; i++) {
					map.put(new Integer(i), i);
				}

				System.out.println("t2 over");
			}
		};

		Thread t3 = new Thread() {
			public void run() {
				for (int i = 0; i < 50000; i++) {
					map.put(new Integer(i), i);
				}

				System.out.println("t3 over");
			}
		};

		Thread t4 = new Thread() {
			public void run() {
				for (int i = 0; i < 50000; i++) {
					map.put(new Integer(i), i);
				}

				System.out.println("t4 over");
			}
		};

		Thread t5 = new Thread() {
			public void run() {
				for (int i = 0; i < 50000; i++) {
					map.put(new Integer(i), i);
				}

				System.out.println("t5 over");
			}
		};

		Thread t6 = new Thread() {
			public void run() {
				for (int i = 0; i < 50000; i++) {
					map.get(new Integer(i));
				}

				System.out.println("t6 over");
			}
		};

		Thread t7 = new Thread() {
			public void run() {
				for (int i = 0; i < 50000; i++) {
					map.get(new Integer(i));
				}

				System.out.println("t7 over");
			}
		};

		Thread t8 = new Thread() {
			public void run() {
				for (int i = 0; i < 50000; i++) {
					map.get(new Integer(i));
				}

				System.out.println("t8 over");
			}
		};

		Thread t9 = new Thread() {
			public void run() {
				for (int i = 0; i < 50000; i++) {
					map.get(new Integer(i));
				}

				System.out.println("t9 over");
			}
		};

		Thread t10 = new Thread() {
			public void run() {
				for (int i = 0; i < 50000; i++) {
					map.get(new Integer(i));
				}

				System.out.println("t10 over");
			}
		};

		t1.start();
		t2.start();
		t3.start();
		t4.start();
		t5.start();

		t6.start();
		t7.start();
		t8.start();
		t9.start();
		t10.start();
	}

	public static void main(String[] args) {
		new TestLock();
	}
}
複製程式碼

就是啟了10個執行緒,不斷的往一個非執行緒安全的HashMap中put/get內容,put的內容很簡單,key和value都是從0自增的整數(這個put的內容做的並不好,以致於後來干擾了我分析問題的思路)。對HashMap做併發寫操作,我原以為只不過會產生髒資料的情況,但反覆執行這個程式,會出現執行緒t1、t2被hang住的情況,多數情況下是一個執行緒被hang住另一個成功結束,偶爾會10個執行緒都被hang住

產生這個死迴圈的根源在於對一個未保護的共享變數 — 一個"HashMap"資料結構的操作。當在所有操作的方法上加了"synchronized"後,一切恢復了正常。這算JDK的bug嗎?應該說不是的,這個現象很早以前就報告出來了。Sun的工程師並不認為這是bug,而是建議在這樣的場景下應採用"ConcurrentHashMap"

CPU利用率過高一般是因為出現了出現了死迴圈,導致部分執行緒一直執行,佔用cpu時間。問題原因就是HashMap是非執行緒安全的,多個執行緒put的時候造成了某個key值Entry key List的死迴圈,問題就這麼產生了。

當另外一個執行緒get 這個Entry List 死迴圈的key的時候,這個get也會一直執行。最後結果是越來越多的執行緒死迴圈,最後導致伺服器dang掉。我們一般認為HashMap重複插入某個值的時候,會覆蓋之前的值,這個沒錯。但是對於多執行緒訪問的時候,由於其內部實現機制(在多執行緒環境且未作同步的情況下,對同一個HashMap做put操作可能導致兩個或以上執行緒同時做rehash動作,就可能導致迴圈鍵表出現,一旦出現執行緒將無法終止,持續佔用CPU,導致CPU使用率居高不下),就可能出現安全問題了

使用jstack工具dump出問題的那臺伺服器的棧資訊。死迴圈的話,首先查詢RUNNABLE的執行緒,找到問題程式碼如下:

java.lang.Thread.State:RUNNABLE at java.util.HashMap.get(HashMap.java:303) at com.sohu.twap.service.logic.TransformTweeter.doTransformTweetT5(TransformTweeter.java:183) 共出現了23次。 java.lang.Thread.State:RUNNABLE at java.util.HashMap.put(HashMap.java:374) at com.sohu.twap.service.logic.TransformTweeter.transformT5(TransformTweeter.java:816) 共出現了3次。

注意:不合理使用HashMap導致出現的是死迴圈而不是死鎖。

1.2 多執行緒put的時候可能導致元素丟失

主要問題出在addEntry方法的new Entry<K,V>(hash, key, value, e),如果兩個執行緒都同時取得了e,則他們下一個元素都是e,然後賦值給table元素的時候有一個成功有一個丟失。

1.3 put非null元素後get出來的卻是null

在transfer方法中程式碼如下:

void transfer(Entry[] newTable) {
    Entry[] src = table;
    int newCapacity = newTable.length;
    for (int j = 0; j < src.length; j++) {
        Entry e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
}
複製程式碼

在這個方法裡,將舊陣列賦值給src,遍歷src,當src的元素非null時,就將src中的該元素置null,即將舊陣列中的元素置null了,也就是這一句:

if (e != null) {
    src[j] = null;
複製程式碼

此時若有get方法訪問這個key,它取得的還是舊陣列,當然就取不到其對應的value了。

總結:HashMap未同步時在併發程式中會產生許多微妙的問題,難以從表層找到原因。所以使用HashMap出現了違反直覺的現象,那麼可能就是併發導致的了。

2 HashMap資料結構

HashMap通常會用一個指標陣列(假設為table[])來做分散所有的key,當一個key被加入時,會通過Hash演算法通過key算出這個陣列的下標i,然後就把這個<key, value>插到table[i]中,如果有兩個不同的key被算在了同一個i,那麼就叫衝突,又叫碰撞,這樣會在table[i]上形成一個連結串列。

我們知道,如果table[]的尺寸很小,比如只有2個,如果要放進10個keys的話,那麼碰撞非常頻繁,於是一個O(1)的查詢演算法,就變成了連結串列遍歷,效能變成了O(n),這是Hash表的缺陷。

所以,Hash表的尺寸和容量非常的重要。一般來說,Hash表這個容器當有資料要插入時,都會檢查容量有沒有超過設定的thredhold,如果超過,需要增大Hash表的尺寸,但是這樣一來,整個Hash表裡的元素都需要被重算一遍。這叫rehash,這個成本相當的大。

2.1 HashMap的rehash原始碼

下面,我們來看一下Java的HashMap的原始碼。Put一個Key,Value對到Hash表中:

public V put(K key, V value)
{
    ......
    //算Hash值
    int hash = hash(key.hashCode());
    int i = indexFor(hash, table.length);
    //如果該key已被插入,則替換掉舊的value(連結操作)
    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;
        }
    }
    modCount++;
    //該key不存在,需要增加一個結點
    addEntry(hash, key, value, i);
    return null;
}
複製程式碼

檢查容量是否超標:

void addEntry(int hash, K key, V value, int bucketIndex)
{
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
    //檢視當前的size是否超過了我們設定的閾值threshold,如果超過,需要resize
    if (size++ >= threshold)
        resize(2 * table.length);
}
複製程式碼

新建一個更大尺寸的hash表,然後把資料從老的Hash表中遷移到新的Hash表中。

void resize(int newCapacity)
{
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    ......
    //建立一個新的Hash Table
    Entry[] newTable = new Entry[newCapacity];
    //將Old Hash Table上的資料遷移到New Hash Table上
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}
複製程式碼

遷移的原始碼,注意高亮處:

void transfer(Entry[] newTable)
{
    Entry[] src = table;
    int newCapacity = newTable.length;
    //下面這段程式碼的意思是:
    //  從OldTable裡摘一個元素出來,然後放到NewTable中
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry<K,V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
}
複製程式碼

好了,這個程式碼算是比較正常的。而且沒有什麼問題。

2.2 正常的ReHash過程

畫了個圖做了個演示。

  1. 假設我們的hash演算法就是簡單的用key mod 一下表的大小(也就是陣列的長度)。
  2. 最上面的是old hash 表,其中的Hash表的size=2, 所以key = 3, 7, 5,在mod 2以後都衝突在table[1]這裡了。
  3. 接下來的三個步驟是Hash表 resize成4,然後所有的<key,value> 重新rehash的過程。

HashMap多執行緒併發問題分析

2.3 併發的Rehash過程

(1)假設我們有兩個執行緒。我用紅色和淺藍色標註了一下。我們再回頭看一下我們的 transfer程式碼中的這個細節:

do {
    Entry<K,V> next = e.next; // <--假設執行緒一執行到這裡就被排程掛起了
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
} while (e != null);
複製程式碼

而我們的執行緒二執行完成了。於是我們有下面的這個樣子。

HashMap多執行緒併發問題分析

注意:因為Thread1的 e 指向了key(3),而next指向了key(7),其線上程二rehash後,指向了執行緒二重組後的連結串列。我們可以看到連結串列的順序被反轉後。

(2)執行緒一被排程回來執行。

  1. 先是執行 newTalbe[i] = e。
  2. 然後是e = next,導致了e指向了key(7)。
  3. 而下一次迴圈的next = e.next導致了next指向了key(3)。

HashMap多執行緒併發問題分析

(3)一切安好。 執行緒一接著工作。把key(7)摘下來,放到newTable[i]的第一個,然後把e和next往下移。

HashMap多執行緒併發問題分析

(4)環形連結出現。 e.next = newTable[i] 導致 key(3).next 指向了 key(7)。注意:此時的key(7).next 已經指向了key(3), 環形連結串列就這樣出現了

HashMap多執行緒併發問題分析

於是,當我們的執行緒一呼叫到,HashTable.get(11)時,悲劇就出現了——Infinite Loop。

3 三種解決方案

3.1 Hashtable替換HashMap

Hashtable 是同步的,但由迭代器返回的 Iterator 和由所有 Hashtable 的“collection 檢視方法”返回的 Collection 的 listIterator 方法都是快速失敗的:在建立 Iterator 之後,如果從結構上對 Hashtable 進行修改,除非通過 Iterator 自身的移除或新增方法,否則在任何時間以任何方式對其進行修改,Iterator 都將丟擲 ConcurrentModificationException。因此,面對併發的修改,Iterator 很快就會完全失敗,而不冒在將來某個不確定的時間發生任意不確定行為的風險。由 Hashtable 的鍵和值方法返回的 Enumeration 不是快速失敗的。

注意,迭代器的快速失敗行為無法得到保證,因為一般來說,不可能對是否出現不同步併發修改做出任何硬性保證。快速失敗迭代器會盡最大努力丟擲 ConcurrentModificationException。因此,為提高這類迭代器的正確性而編寫一個依賴於此異常的程式是錯誤做法:迭代器的快速失敗行為應該僅用於檢測程式錯誤。

3.2 Collections.synchronizedMap將HashMap包裝起來

返回由指定對映支援的同步(執行緒安全的)對映。為了保證按順序訪問,必須通過返回的對映完成對底層對映的所有訪問。在返回的對映或其任意 collection 檢視上進行迭代時,強制使用者手工在返回的對映上進行同步

Map m = Collections.synchronizedMap(new HashMap());
...
Set s = m.keySet();  // Needn't be in synchronized block
...
synchronized(m) {  // Synchronizing on m, not s!
Iterator i = s.iterator(); // Must be in synchronized block
    while (i.hasNext())
        foo(i.next());
}
複製程式碼

不遵從此建議將導致無法確定的行為。如果指定對映是可序列化的,則返回的對映也將是可序列化的。

3.3 ConcurrentHashMap替換HashMap

支援檢索的完全併發和更新的所期望可調整併發的雜湊表。此類遵守與 Hashtable 相同的功能規範,並且包括對應於 Hashtable 的每個方法的方法版本。不過,儘管所有操作都是執行緒安全的,但檢索操作不必鎖定,並且不支援以某種防止所有訪問的方式鎖定整個表。此類可以通過程式完全與 Hashtable 進行互操作,這取決於其執行緒安全,而與其同步細節無關。

檢索操作(包括 get)通常不會受阻塞,因此,可能與更新操作交迭(包括 put 和 remove)。檢索會影響最近完成的更新操作的結果。對於一些聚合操作,比如 putAll 和 clear,併發檢索可能隻影響某些條目的插入和移除。類似地,在建立迭代器/列舉時或自此之後,Iterators 和 Enumerations 返回在某一時間點上影響雜湊表狀態的元素。它們不會丟擲 ConcurrentModificationException。不過,迭代器被設計成每次僅由一個執行緒使用。

相關文章