讀寫一致性的一些思考

做個好人君發表於2019-01-30

先說明下,本文要討論的多執行緒讀寫是指一個執行緒寫,一個或多個執行緒讀,不包括多執行緒同時寫的情況。

更多文章見個人部落格:github.com/farmerjohng…

試想下這樣一個場景:一個執行緒往hashmap中寫資料,一個執行緒往hashmap中讀資料。 這樣會有問題嗎?如果有,那是什麼問題?

相信大家都知道是有問題的,但至於到底是什麼問題,可能就不是那麼顯而易見了。

問題有兩點。
一是記憶體可見性的問題,hashmap儲存資料的table並沒有用voliate修飾,也就是說讀執行緒可能一直讀不到資料的最新值。
二是指令重排序的問題,get的時候可能得到的是一箇中間狀態的資料,我們看下put方法的部分程式碼。

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
       ...
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = new Node<>(hash, key, value, next);
		...
	}
	
複製程式碼

可以看到,在put操作時,如果table陣列的指定位置為null,會建立一個Node物件,並放到table陣列上。但我們知道jvm中tab[i] = new Node<>(hash, key, value, next);這樣的操作不是原子的,並且可能因為指令重排序,導致另一個執行緒呼叫get取tab[i]的時候,拿到的是一個還沒有呼叫完構造方法的物件,導致不可預料的問題發生。

上述的兩個問題可以說都是因為HashMap中的內部屬性沒有被voliate修飾導致的,如果HashMap中的物件全部由voliate修飾,則一個執行緒寫,一個執行緒讀的情況是不會有問題(這裡是我的猜測,證實這個猜測正確性的一點依據是ConcurrentHashMap的get並沒有加鎖,也就是說在Map結構裡讀寫其實是不衝突)

建立物件的原子性問題

有的同學對於Object obj = new Object();這樣的操作在多執行緒的情況下會拿到一個未初始化的物件這點可能有疑惑,這裡也做個簡單的說明。以上java語句分為4個步驟:

  1. 在棧中分配一片空間給obj引用
  2. 在jvm堆中建立一個Object物件,注意這裡僅僅是分配空間,沒有呼叫構造方法
  3. 初始化第2步建立的物件,也就是呼叫其構造方法
  4. 棧中的obj指向堆中的物件

以上步驟看起來也是沒有問題的,畢竟建立的物件要呼叫完構造方法後才會被引用。

但問題是jvm是會對指令進行重排序的,重排之後可能是第4步先於第3步執行,那這時候另外一個執行緒讀到的就是沒有還執行構造方法的物件,導致未知問題。jvm重排只保證重排前和重排後在單執行緒中的結果一致性。

注意java中引用的賦值操作一定是原子的,比如說a和b均是物件的情況下不管是32位還是64位jvm,a=b操作均是原子的。但如果a和b是long或者double原子型資料,那在32位jvm上a=b不一定是原子的(看jvm具體實現),有可能是分成了兩個32位操作。 但是對於voliate的long,double 變數來說,其賦值是原子的。具體可以看這裡https://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.7

資料庫中讀寫一致性

跳出hashmap,在資料庫中都是要用mvcc機制避免加讀寫鎖。也就是說如果不用mvcc,資料庫是要加讀寫鎖的,那為什麼資料庫要加讀寫鎖呢?原因是寫操作不是原子的,如果不加讀寫鎖或mvcc,可能會讀到中間狀態的資料,以HBase為例,Hbase寫流程分為以下幾個步驟:
1.獲得行鎖
2.開啟mvcc
3.寫到記憶體buffer
4.寫到append log
5.釋放行鎖
6.flush log
7.mvcc結束(這時才對讀可見)

試想,如果沒有不走 2,7 也不加讀寫鎖,那在步驟3的時候,其他的執行緒就能讀到該資料。如果說3之後出現了問題,那該條資料其實是寫失敗的。也就是說其他執行緒曾經讀到過不存在的資料。

同理,在mysql中,如果不用mvcc也不用讀寫鎖,一個事務還沒commit,其中的資料就能被讀到,如果用讀寫鎖,一個事務會對中更改的資料加寫鎖,這時其他讀操作會阻塞,直到事務提交,對於效能有很大的影響,所以大多數情況下資料庫都採用MVCC機制實現非鎖定讀。

相關文章