HashMap多線程併發問題分析(rehash分析)

HashMap多線程併發問題分析

發表於1年前(2015-03-31 11:40)   閱讀( 2664) | 評論( 1)  12人收藏此文章, 我要收藏
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作併發寫操做,我原覺得只不過會產生髒數據的狀況,但反覆運行這個程序,會出現線程t一、t2被hang住的狀況,多數狀況下是一個線程被hang住另外一個成功結束,偶爾會10個線程都被hang住。 安全

產生這個死循環的根源在於對一個未保護的共享變量 — 一個"HashMap"數據結構的操做。當在全部操做的方法上加了"synchronized"後,一切恢復了正常。這算jvm的bug嗎?應該說不是的,這個現象很早之前就報告出來了。Sun的工程師並不認爲這是bug,而是建議在這樣的場景下應採用"ConcurrentHashMap」, 服務器

CPU利用率太高通常是由於出現了出現了死循環,致使部分線程一直運行,佔用cpu時間。問題緣由就是HashMap是非線程安全的,多個線程put的時候形成了某個key值Entry key List的死循環,問題就這麼產生了。 markdown

當另一個線程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致使出現的是死循環而不是死鎖。 jvm

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

主要問題出在addEntry方法的new Entry (hash, key, value, e),若是兩個線程都同時取得了e,則他們下一個元素都是e,而後賦值給table元素的時候有一個成功有一個丟失。

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出現了違反直覺的現象,那麼可能就是併發致使的了。

HashMap數據結構

我須要簡單地說一下HashMap這個經典的數據結構。

HashMap一般會用一個指針數組(假設爲table[])來作分散全部的key,當一個key被加入時,會經過Hash算法經過key算出這個數組的下標i,而後就把這個 插到table[i]中,若是有兩個不一樣的key被算在了同一個i,那麼就叫衝突,又叫碰撞,這樣會在table[i]上造成一個鏈表。

咱們知道,若是table[]的尺寸很小,好比只有2個,若是要放進10個keys的話,那麼碰撞很是頻繁,因而一個O(1)的查找算法,就變成了鏈表遍歷,性能變成了O(n),這是Hash表的缺陷。

因此,Hash表的尺寸和容量很是的重要。通常來講,Hash表這個容器當有數據要插入時,都會檢查容量有沒有超過設定的thredhold,若是超過,須要增大Hash表的尺寸,可是這樣一來,整個Hash表裏的元素都須要被重算一遍。這叫rehash,這個成本至關的大。

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);
        }
    }
}

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

正常的ReHash過程

畫了個圖作了個演示。

  1. 我假設了咱們的hash算法就是簡單的用key mod 一下表的大小(也就是數組的長度)。
  2. 最上面的是old hash 表,其中的Hash表的size=2, 因此key = 3, 7, 5,在mod 2之後都衝突在table1這裏了。
  3. 接下來的三個步驟是Hash表 resize成4,而後全部的 從新rehash的過程。

在此輸入圖片描述

併發的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);

而咱們的線程二執行完成了。因而咱們有下面的這個樣子。 
在此輸入圖片描述

注意:由於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)。

在此輸入圖片描述 
(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。

三種解決方案

Hashtable替換HashMap

Hashtable 是同步的,但由迭代器返回的 Iterator 和由全部 Hashtable 的「collection 視圖方法」返回的 Collection 的 listIterator 方法都是快速失敗的:在建立 Iterator 以後,若是從結構上對 Hashtable 進行修改,除非經過 Iterator 自身的移除或添加方法,不然在任什麼時候間以任何方式對其進行修改,Iterator 都將拋出 ConcurrentModificationException。所以,面對併發的修改,Iterator 很快就會徹底失敗,而不冒在未來某個不肯定的時間發生任意不肯定行爲的風險。由 Hashtable 的鍵和值方法返回的 Enumeration 不是快速失敗的。

注意,迭代器的快速失敗行爲沒法獲得保證,由於通常來講,不可能對是否出現不一樣步併發修改作出任何硬性保證。快速失敗迭代器會盡最大努力拋出 ConcurrentModificationException。所以,爲提升這類迭代器的正確性而編寫一個依賴於此異常的程序是錯誤作法:迭代器的快速失敗行爲應該僅用於檢測程序錯誤。

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());
}

不聽從此建議將致使沒法肯定的行爲。若是指定映射是可序列化的,則返回的映射也將是可序列化的。

ConcurrentHashMap替換HashMap

支持檢索的徹底併發和更新的所指望可調整併發的哈希表。此類遵照與 Hashtable 相同的功能規範,而且包括對應於 Hashtable 的每一個方法的方法版本。不過,儘管全部操做都是線程安全的,但檢索操做沒必要鎖定,而且不支持以某種防止全部訪問的方式鎖定整個表。此類能夠經過程序徹底與 Hashtable 進行互操做,這取決於其線程安全,而與其同步細節無關。  檢索操做(包括 get)一般不會受阻塞,所以,可能與更新操做交迭(包括 put 和 remove)。檢索會影響最近完成的更新操做的結果。對於一些聚合操做,好比 putAll 和 clear,併發檢索可能隻影響某些條目的插入和移除。相似地,在建立迭代器/枚舉時或自此以後,Iterators 和 Enumerations 返回在某一時間點上影響哈希表狀態的元素。它們不會拋出 ConcurrentModificationException。不過,迭代器被設計成每次僅由一個線程使用。

相關文章
相關標籤/搜索