Java 中 ConcurrentHashMap 原理分析

geeklu發表於2015-06-13

一.Java併發基礎

當一個物件或變數可以被多個執行緒共享的時候,就有可能使得程式的邏輯出現問題。 在一個物件中有一個變數i=0,有兩個執行緒A,B都想對i加1,這個時候便有問題顯現出來,關鍵就是對i加1的這個過程不是原子操作。要想對i進行遞增,第一步就是獲取i的值,當A獲取i的值為0,在A將新的值寫入A之前,B也獲取了A的值0,然後A寫入,i變成1,然後B也寫入i,i這個時候依然是1. 當然java的記憶體模型沒有上面這麼簡單,在Java Memory Model中,Memory分為兩類,main memory和working memory,main memory為所有執行緒共享,working memory中存放的是執行緒所需要的變數的拷貝(執行緒要對main memory中的內容進行操作的話,首先需要拷貝到自己的working memory,一般為了速度,working memory一般是在cpu的cache中的)。Volatile的變數在被操作的時候不會產生working memory的拷貝,而是直接操作main memory,當然volatile雖然解決了變數的可見性問題,但沒有解決變數操作的原子性的問題,這個還需要synchronized或者CAS相關操作配合進行。

多執行緒中幾個重要的概念:

可見性

也就說假設一個物件中有一個變數i,那麼i是儲存在main memory中的,當某一個執行緒要操作i的時候,首先需要從main memory中將i 載入到這個執行緒的working memory中,這個時候working memory中就有了一個i的拷貝,這個時候此執行緒對i的修改都在其working memory中,直到其將i從working memory寫回到main memory中,新的i的值才能被其他執行緒所讀取。從某個意義上說,可見性保證了各個執行緒的working memory的資料的一致性。 可見性遵循下面一些規則:

  • 當一個執行緒執行結束的時候,所有寫的變數都會被flush回main memory中。
  • 當一個執行緒第一次讀取某個變數的時候,會從main memory中讀取最新的。
  • volatile的變數會被立刻寫到main memory中的,在jsr133中,對volatile的語義進行增強,後面會提到
  • 當一個執行緒釋放鎖後,所有的變數的變化都會flush到main memory中,然後一個使用了這個相同的同步鎖的程式,將會重新載入所有的使用到的變數,這樣就保證了可見性。

原子性

還拿上面的例子來說,原子性就是當某一個執行緒修改i的值的時候,從取出i到將新的i的值寫給i之間不能有其他執行緒對i進行任何操作。也就是說保證某個執行緒對i的操作是原子性的,這樣就可以避免資料髒讀。 通過鎖機制或者CAS(Compare And Set 需要硬體CPU的支援)操作可以保證操作的原子性。

有序性

假設在main memory中存在兩個變數i和j,初始值都為0,在某個執行緒A的程式碼中依次對i和j進行自增操作(i,j的操作不相互依賴)

i++;
j++;

由於,所以i,j修改操作的順序可能會被重新排序。那麼修改後的ij寫到main memory中的時候,順序可能就不是按照i,j的順序了,這就是所謂的reordering,在單執行緒的情況下,當執行緒A執行結束的後i,j的值都加1了,線上程自己看來就好像是執行緒按照程式碼的順序進行了執行(這些操作都是基於as-if-serial語義的),即使在實際執行過程中,i,j的自增可能被重新排序了,當然計算機也不能幫你亂排序,存在上下邏輯關聯的執行順序肯定還是不會變的。但是在多執行緒環境下,問題就不一樣了,比如另一個執行緒B的程式碼如下

if(j==1) {
    System.out.println(i);
}

按照我們的思維方式,當j為1的時候那麼i肯定也是1,因為程式碼中i在j之前就自增了,但實際的情況有可能當j為1的時候i還是為0。這就是reordering產生的不好的後果,所以我們在某些時候為了避免這樣的問題需要一些必要的策略,以保證多個執行緒一起工作的時候也存在一定的次序。JMM提供了happens-before 的排序策略。這樣我們可以得到多執行緒環境下的as-if-serial語義。 這裡不對happens-before進行詳細解釋了,詳細的請看這裡http://www.ibm.com/developerworks/cn/java/j-jtp03304/,這裡主要講一下volatile在新的java記憶體模型下的變化,在jsr133之前,下面的程式碼可能會出現問題

Map configOptions;
char[] configText;
volatile boolean initialized = false;
// In Thread A
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
// In Thread B
while (!initialized) 
  sleep();
// use configOptions

jsr133之前,雖然對 volatile 變數的讀和寫不能與對其他 volatile 變數的讀和寫一起重新排序,但是它們仍然可以與對 nonvolatile 變數的讀寫一起重新排序,所以上面的Thread A的操作,就可能initialized變成true的時候,而configOptions還沒有被初始化,所以initialized先於configOptions被執行緒B看到,就產生問題了。

JSR 133 Expert Group 決定讓 volatile 讀寫不能與其他記憶體操作一起重新排序,新的記憶體模型下,如果當執行緒 A 寫入 volatile 變數 V 而執行緒 B 讀取 V 時,那麼在寫入 V 時,A 可見的所有變數值現在都可以保證對 B 是可見的。

結果就是作用更大的 volatile 語義,代價是訪問 volatile 欄位時會對效能產生更大的影響。這一點在ConcurrentHashMap中的統計某個segment元素個數的count變數中使用到了。

二.執行緒安全的HashMap

什麼時候我們需要使用執行緒安全的hashmap呢,比如一個hashmap在執行的時候只有讀操作,那麼很明顯不會有問題,但是當涉及到同時有改變也有讀的時候,就要考慮執行緒安全問題了,在不考慮效能問題的時候,我們的解決方案有Hashtable或者Collections.synchronizedMap(hashMap),這兩種方式基本都是對整個hash表結構做鎖定操作的,這樣在鎖表的期間,別的執行緒就需要等待了,無疑效能不高。

三.ConcurrentHashMap實現原理

資料結構 ConcurrentHashMap的目標是實現支援高併發、高吞吐量的執行緒安全的HashMap。當然不能直接對整個hashtable加鎖,所以在ConcurrentHashMap中,資料的組織結構和HashMap有所區別。

一個ConcurrentHashMap由多個segment組成,每一個segment都包含了一個HashEntry陣列的hashtable, 每一個segment包含了對自己的hashtable的操作,比如get,put,replace等操作,這些操作發生的時候,對自己的hashtable進行鎖定。由於每一個segment寫操作只鎖定自己的hashtable,所以可能存在多個執行緒同時寫的情況,效能無疑好於只有一個hashtable鎖定的情況。

原始碼分析 在ConcurrentHashMap的remove,put操作還是比較簡單的,都是將remove或者put操作交給key所對應的segment去做的,所以當幾個操作不在同一個segment的時候就可以併發的進行。

public V remove(Object key) {
    int hash = hash(key.hashCode());
        return segmentFor(hash).remove(key, hash, null);
    }

而segment中的remove操作除了加鎖之外和HashMap中的remove操作基本無異。

/**
         * Remove; match on key only if value null, else match both.
         */
        V remove(Object key, int hash, Object value) {
            lock();
            try {
                int c = count - 1;
                HashEntry<K,V>[] tab = table;
                int index = hash & (tab.length - 1);
                HashEntry<K,V> first = tab[index];
                HashEntry<K,V> e = first;
                while (e != null && (e.hash != hash || !key.equals(e.key)))
                    e = e.next;

                V oldValue = null;
                if (e != null) {
                    V v = e.value;
                    if (value == null || value.equals(v)) {
                        oldValue = v;
                        // All entries following removed node can stay
                        // in list, but all preceding ones need to be
                        // cloned.
                        ++modCount;
                        HashEntry<K,V> newFirst = e.next;
                        for (HashEntry<K,V> p = first; p != e; p = p.next)
                            newFirst = new HashEntry<K,V>(p.key, p.hash,
                                                          newFirst, p.value);
                        tab[index] = newFirst;
                        count = c; // write-volatile
                    }
                }
                return oldValue;
            } finally {
                unlock();
            }
        }

上面的程式碼中關於volatile型別的變數count值得一提,這裡充分利用了Java 5中對volatile語義的增強,count = c的操作必須在modCount,table等操作的後面,這樣才能保證這些變數操作的可見性。 Segment類繼承於ReentrantLock,主要是為了使用ReentrantLock的鎖,ReentrantLock的實現比 synchronized在多個執行緒爭用下的總體開銷小。 put操作和remove操作類似。

接下來我們來看下get操作。

public V get(Object key) {
        int hash = hash(key.hashCode());
        return segmentFor(hash).get(key, hash);
    }

也是使用了對應的segment的get

V get(Object key, int hash) {
            if (count != 0) { // read-volatile
                HashEntry<K,V> e = getFirst(hash);
                while (e != null) {
                    if (e.hash == hash && key.equals(e.key)) {
                        V v = e.value;
                        if (v != null)
                            return v;
                        return readValueUnderLock(e); // recheck
                    }
                    e = e.next;
                }
            }
            return null;
        }

上面的程式碼中,一開始就對volatile變數count進行了讀取比較,這個還是java5對volatile語義增強的作用,這樣就可以獲取變數的可見性。所以count != 0之後,我們可以認為對應的hashtable是最新的,當然由於讀取的時候沒有加鎖,在get的過程中,可能會有更新。當發現根據key去找元素的時候,但發現找得的key對應的value為null,這個時候可能會有其他執行緒正在對這個元素進行寫操作,所以需要在使用鎖的情況下在讀取一下value,以確保最終的值。

其他相關涉及讀取的操作也都類似。

相關文章