Java多執行緒/併發14、保持執行緒間的資料獨立:ConcurrentHashMap應用

唐大麥發表於2017-04-28

在Java 1.5之前,如果需要可以在多執行緒和併發的程式中安全使用的Map,只能在HashTable和Collections.synchronizedMap中選擇,因為它們的put、reomve和containsKey方法都是同步的。我們熟知的HashMap不是執行緒安全的,因此在多執行緒環境下開發不能用這個。

HashTable容器使用synchronized來保證執行緒安全,因此讀和寫都是序列的,線上程競爭激烈的情況 下HashTable的效率非常低下。

HashTable容器在競爭激烈的併發環境下表現出效率低下的原因是所有訪問HashTable的執行緒都必須競爭同一把鎖,那假如容器裡有多把 鎖,每一把鎖用於鎖容器其中一部分資料,那麼當多執行緒訪問容器裡不同資料段的資料時,執行緒間就不會存在鎖競爭,從而可以有效的提高併發訪問效率,這就是 ConcurrentHashMap所使用的鎖分段技術,首先將資料分成一段一段的儲存,然後給每一段資料配一把鎖,當一個執行緒佔用鎖訪問其中一個段資料 的時候,其他段的資料也能被其他執行緒訪問。

簡單的說,Hashtable中採用的鎖機制是一次鎖住整個hash表,從而同一時刻只能由一個執行緒對其進行操作;而ConcurrentHashMap中則是 一次鎖住一個桶(表中的一段)。ConcurrentHashMap預設將hash表分為16個桶,諸如get,put,remove等常用操作只鎖當前需要用到的桶。 這樣,原來只能一個執行緒進入,現在卻能同時有16個寫執行緒執行,併發效能的提升是顯而易見的。
上面說到的16個執行緒指的是寫執行緒,而讀操作大部分時候都不需要用到鎖。只有在size等操作時才需要鎖住整個hash表。

效率提升了,但也存在弊端:
由於一些更新操作,如put(),remove(),putAll(),clear()只鎖住操作的部分,所以在檢索操作不能保證返回的是最新的結果。

在迭代方面,ConcurrentHashMap使用了一種不同的迭代方式。在這種迭代方式中,當iterator被建立後集合再發生改變就不再是丟擲ConcurrentModificationException,取而代之的是在改變時new新的資料從而不影響原有的資料,iterator完成後再將頭指標替換為新的資料,這樣iterator執行緒可以使用原來老的資料,而寫執行緒也可以併發的完成改變。

ConcurrentMap介面定義如下:

public interface ConcurrentMap<K, V> extends Map<K, V> {
    V putIfAbsent(K key, V value);
    boolean remove(Object key, Object value);
    boolean replace(K key, V oldValue, V newValue);
    V replace(K key, V value);
}

裡面定義了幾個基於 CAS(Compare and Set)原子操作,使用起來很方便

putifAbsent()方法

很多時候我們希望在元素不存在時插入元素,我們一般會像下面那樣寫程式碼

private final Map<String, Long> map = new Hashmap<>();
public long get(String key) {
  if (map.get(key) == null){
      return map.put(key, getvalue());
  } else{
      return map.get(key);
  }
}

上面這段程式碼在單執行緒開發中是好用的,但在多執行緒中是有出錯的風險的。這是因為在put操作時並沒有對整個Map加鎖,所以一個執行緒正在put(k,v)的時候,另一個執行緒呼叫get(k)會得到null,這就會造成一個執行緒put的值會被另一個執行緒put的值所覆蓋。當然,我們可以將程式碼封裝到synchronized程式碼塊中,這樣雖然執行緒安全了,但會使你的程式碼變成了單執行緒。

ConcurrentHashMap提供的putIfAbsent(key,value)原子方法的實現了同樣的功能,同時避免了上面的執行緒競爭的風險。

private final Map<String, Long> map = new ConcurrentHashMap<>();
public long get(String key) {
Long val =map.get(key);
if(val == null){
    val =  getvalue();
    Long l = map.putIfAbsent(key, val);
    //l != null說明有別的執行緒捷足先登插入了key-value
    if (l != null) {
        val=l;
    }
}
return val;
}

特別注意: putIfAbsent 方法是有返回值的,並且返回值很重要。如果(呼叫該方法時)key-value 已經存在,則返回那個 value 值。如果呼叫時 map 裡沒有找到 key 的 mapping,就插入新的元素並返回一個 null 值。所以,使用 putIfAbsent 方法時切記要對返回值進行判斷。

Replace()方法

舉個例子:統計文字中單詞出現的次數,把單詞出現的次數記錄到一個Map中,程式碼如下:

private final Map<String, Long> wordCounts = new ConcurrentHashMap<>();

public long increase(String word) {
    Long oldValue = wordCounts.get(word);
    if(oldValue == null) {
        wordCounts.put(word, 1L);
    }
    else{
        wordCounts.put(word, oldValue + 1);
    }
    return newValue;
}

如果多個執行緒併發呼叫這個increase()方法,就會出現問題,因為在wordCounts.put時,其它執行緒已經改寫了wordCounts.put的條件。比如在 當前執行緒執行wordCounts.get(word)之後和wordCounts.put(word, 1L);語句之前,另一個執行緒進行了wordCounts.put操作,當前執行緒再執行下去就會put錯誤的值。
下面用原子操作Replace()方法解決這個問題:

private final ConcurrentMap<String, Long> wordCounts = new ConcurrentHashMap<>();

public long increase(String word) {
    Long oldValue, newValue;
    while (true) {
        oldValue = wordCounts.get(word);
        if (oldValue == null) {
            // Add the word firstly, initial the value as 1
            newValue = 1L;
            if (wordCounts.putIfAbsent(word, newValue) == null) {
                break;
            }
        } else {
            newValue = oldValue + 1;
            if (wordCounts.replace(word, oldValue, newValue)) {
                break;
            }
        }
    }
    return newValue;
}

用while (true) 是因為原子操作有失敗的可能,因此需要多次嘗試,直到成功。

相關文章