HashMap在併發下可能出現的問題分析

咱們都知道,HashMap在併發環境下使用可能出現問題,可是具體表現,以及爲何出現併發問題,
可能並非全部人都瞭解,這篇文章記錄一下HashMap在多線程環境下可能出現的問題以及如何避免。html

在分析HashMap的併發問題前,先簡單瞭解HashMap的put和get基本操做是如何實現的。java

>>HashMap的put和get操做

你們知道HashMap內部實現是經過拉鍊法解決哈希衝突的,也就是經過鏈表的結構保存散列到同一數組位置的兩個值,shell

put操做主要是判空,對key的hashcode執行一次HashMap本身的哈希函數,獲得bucketindex位置,還有對重複key的覆蓋操做數組

對照源碼分析一下具體的put操做是如何完成的:安全

public V put(K key, V value) {
	if (key == null)
		return putForNullKey(value);
	//獲得key的hashcode,同時再作一次hash操做
	int hash = hash(key.hashCode());
	//對數組長度取餘,決定下標位置
	int i = indexFor(hash, table.length);
	/**
	  * 首先找到數組下標處的鏈表結點,
	  * 判斷key對一個的hash值是否已經存在,若是存在將其替換爲新的value
	  */
	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;
}

涉及到的幾個方法:數據結構

static int hash(int h) {
	h ^= (h >>> 20) ^ (h >>> 12);
	return h ^ (h >>> 7) ^ (h >>> 4);
}
     
static int indexFor(int h, int length) {
	return h & (length-1);
}

數據put完成之後,就是如何get,咱們看一下get函數中的操做:多線程

public V get(Object key) {
        if (key == null)
            return getForNullKey();
        int hash = hash(key.hashCode());
        /**
          * 先定位到數組元素,再遍歷該元素處的鏈表
          * 判斷的條件是key的hash值相同,而且鏈表的存儲的key值和傳入的key值相同
          */
        for (Entry e = table[indexFor(hash, table.length)];e != null;e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
                return e.value;
        }
        return null;
}

看一下鏈表的結點數據結構,保存了四個字段,包括key,value,key對應的hash值以及鏈表的下一個節點:併發

static class Entry implements Map.Entry {
       final K key;//Key-value結構的key
       V value;//存儲值
       Entry next;//指向下一個鏈表節點
       final int hash;//哈希值
 }

>>Rehash/再散列擴展內部數組長度

哈希表結構是結合了數組和鏈表的優勢,在最好狀況下,查找和插入都維持了一個較小的時間複雜度O(1),
不過結合HashMap的實現,考慮下面的狀況,若是內部Entry[] tablet的容量很小,或者直接極端化爲table長度爲1的場景,那麼所有的數據元素都會產生碰撞,
這時候的哈希表成爲一條單鏈表,查找和添加的時間複雜度變爲O(N),失去了哈希表的意義。
因此哈希表的操做中,內部數組的大小很是重要,必須保持一個平衡的數字,使得哈希碰撞不會太頻繁,同時佔用空間不會過大。函數

這就須要在哈希表使用的過程當中不斷的對table容量進行調整,看一下put操做中的addEntry()方法:oop

void addEntry(int hash, K key, V value, int bucketIndex) {
   Entry e = table[bucketIndex];
       table[bucketIndex] = new Entry(hash, key, value, e);
       if (size++ >= threshold)
           resize(2 * table.length);
   }

這裏面resize的過程,就是再散列調整table大小的過程,默認是當前table容量的兩倍。

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];
       //初始化一個大小爲oldTable容量兩倍的新數組newTable
       transfer(newTable);
       table = newTable;
       threshold = (int)(newCapacity * loadFactor);
   }

關鍵的一步操做是transfer(newTable),這個操做會把當前Entry[] table數組的所有元素轉移到新的table中,
這個transfer的過程在併發環境下會發生錯誤,致使數組鏈表中的鏈表造成循環鏈表,在後面的get操做時e = e.next操做無限循環,Infinite Loop出現。

 

下面具體分析HashMap的併發問題的表現以及如何出現的。

>>HashMap在多線程put後可能致使get無限循環 

HashMap在併發環境下多線程put後可能致使get死循環,具體表現爲CPU使用率100%,
看一下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);
            }
        }
    }

這裏對併發狀況的描述起來比較繞,先偷個懶,直接引用酷殼陳皓的博文

http://coolshell.cn/articles/9606.html?spm=5176.100239.blogcont38431.3.N5Q7rB

併發下的Rehash

1)假設咱們有兩個線程。我用紅色和淺藍色標註了一下。

咱們再回頭看一下咱們的 transfer代碼中的這個細節:

do {
    Entry next = e.next;// <--假設線程一執行到這裏就被調度掛起了
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
} while (e != null);

而咱們的線程二執行完成了。因而咱們有下面的這個樣子。

注意,由於Thread1的 e 指向了key(3),而next指向了key(7),其在線程二rehash後,指向了線程二重組後的鏈表。咱們能夠看到鏈表的順序被反轉後。

2)線程一被調度回來執行。

  • 先是執行 newTalbe[i] = e;
  • 而後是e = next,致使了e指向了key(7),
  • 而下一次循環的next = e.next致使了next指向了key(3)

3)一切安好。

線程一接着工做。把key(7)摘下來,放到newTable[i]的第一個,而後把e和next往下移

4)環形連接出現。

e.next = newTable[i] 致使  key(3).next 指向了 key(7)

注意:此時的key(7).next 已經指向了key(3), 環形鏈表就這樣出現了。

因而,當咱們的線程一調用到,HashTable.get(11)時,悲劇就出現了——Infinite Loop。

針對上面的分析模擬這個例子,

這裏在run中執行了一個自增操做,i++非原子操做,使用AtomicInteger避免可能出現的問題:

public class MapThread extends Thread{
        /**
         * 類的靜態變量是各個實例共享的,所以併發的執行此線程一直在操做這兩個變量
         * 選擇AtomicInteger避免可能的int++併發問題
         */
         private static AtomicInteger ai = new AtomicInteger(0);
         //初始化一個table長度爲1的哈希表
         private static HashMap map = new HashMap(1);
         //若是使用ConcurrentHashMap,不會出現相似的問題
//       private static ConcurrentHashMap map = new ConcurrentHashMap(1);
             
         public void run()
          {
              while (ai.get() < 100000)
              {  //不斷自增
                  map.put(ai.get(), ai.get());
                  ai.incrementAndGet();
               }
               
              System.out.println(Thread.currentThread().getName()+"線程即將結束");
          }
    }

測試一下:

public static void main(String[] args){
         MapThread t0 = new MapThread();
         MapThread t1 = new MapThread();
         MapThread t2 = new MapThread();
         MapThread t3 = new MapThread();
         MapThread t4 = new MapThread();
         MapThread t5 = new MapThread();
         MapThread t6 = new MapThread();
         MapThread t7 = new MapThread();
         MapThread t8 = new MapThread();
         MapThread t9 = new MapThread();
          
         t0.start();
         t1.start();
         t2.start();
         t3.start();
         t4.start();
         t5.start();
         t6.start();
         t7.start();
         t8.start();
         t9.start();
          
    }

注意併發問題並非必定會產生,能夠多執行幾回,

我試驗了上面的代碼很容易產生無限循環,控制檯不能終止,有線程始終在執行中,

這是其中一個死循環的控制檯截圖,能夠看到六個線程順利完成了put工做後銷燬,還有四個線程沒有輸出,卡在了put階段,感興趣的能夠斷點進去看一下:

上面的代碼,若是把註釋打開,換用ConcurrentHashMap就不會出現相似的問題。

 

>>多線程put的時候可能致使元素丟失

HashMap另一個併發可能出現的問題是,可能產生元素丟失的現象。

考慮在多線程下put操做時,執行addEntry(hash, key, value, i),若是有產生哈希碰撞,
致使兩個線程獲得一樣的bucketIndex去存儲,就可能會出現覆蓋丟失的狀況:

void addEntry(int hash, K key, V value, int bucketIndex) {
    //多個線程操做數組的同一個位置
    Entry e = table[bucketIndex];
        table[bucketIndex] = new Entry(hash, key, value, e);
        if (size++ >= threshold)
            resize(2 * table.length);
    }

sdf

>>使用線程安全的哈希表容器

那麼如何使用線程安全的哈希表結構呢,這裏列出了幾條建議:

使用Hashtable 類,Hashtable 是線程安全的; 使用併發包下的java.util.concurrent.ConcurrentHashMap,ConcurrentHashMap實現了更高級的線程安全; 或者使用synchronizedMap() 同步方法包裝 HashMap object,獲得線程安全的Map,並在此Map上進行操做。

相關文章
相關標籤/搜索