java多執行緒-記憶體模型
併發處理的廣泛應用是使得amdahl定律代替摩爾定律成為計算機效能發展源動力的根本原因,是人類壓榨計算機運算能力的最有力武器。
上一篇《java 多執行緒—執行緒怎麼來的 》中我們瞭解了執行緒在作業系統中的是如何派生出來的,這一篇我們聊聊jvm的記憶體模型,瞭解一些jvm在記憶體操作中如何保證一致性問題的。
本篇主要包含以下內容:
硬體的記憶體模型
jvm的記憶體模型
happens-before
硬體的記憶體模型
物理機併發處理的方案對於jvm的記憶體模型實現,也有很大的參考作用,畢竟jvm也是在硬體層上來做事情,底層架構也決定了上層的建築建模方式。
計算機併發並非只是多個處理器都參與進來計算就可以了,會牽扯到一些列硬體的問題,最直接的就是要和記憶體做互動。但計算機的儲存裝置與處理器的預算速度相差太大,完全不能滿足處理器的處理速度,怎麼辦,這就是後續加入的一層讀寫速度接近處理器運算速度的快取記憶體來作為處理器和記憶體之間的緩衝。
快取記憶體一邊把使用的資料,從記憶體複製搬入,方便處理器快速運算,一邊把運算後的資料,再同步到主記憶體中,如此處理器就無需等待了。
快取記憶體雖然解決了處理器和記憶體的矛盾,但也為計算機帶來了另一個問題:快取一致性。特別是當多個處理器都涉及到同一塊主記憶體區域的時候,將可能會導致各自的快取資料不一致。
那麼出現不一致情況的時候,以誰的為準?
為了解決這個問題,處理器和記憶體之間的讀寫的時候需要遵循一定的協議來操作,這類協議有:MSI、MESI、MOSI、Synapse、Firefly 以及 Dragon Protocol等。這就是上圖中處理器、快取記憶體、以及記憶體之間的處理方式。
另外除了快取記憶體之外,為了充分利用處理器,處理器還會把輸入的指令碼進行亂序執行優化,只要保證輸出一致,輸入的資訊可以亂序執行重組,所以程式中的語句計算順序和輸入程式碼的順序並非一致。
JVM記憶體模型
上面我們瞭解了硬體的記憶體模型,以此為借鑑,我們看看jvm的記憶體模型。
jvm定義的一套java記憶體模型為了能夠跨平臺達到一致的記憶體訪問效果,從而遮蔽掉了各種硬體和作業系統的記憶體訪問差異。這點和c和c++並不一樣,C和C++會直接使用物理硬體和作業系統的記憶體模型來處理,所以在各個平臺上會有差異,這一點java不會。
java的記憶體模型規定了所有的變數都儲存在主記憶體中,每個執行緒擁有自己的工作記憶體,工作記憶體儲存了該執行緒使用到的變數的主記憶體拷貝,執行緒對變數所有操作,讀取,賦值,都必須在工作記憶體中進行,不能直接寫主記憶體變數,執行緒間變數值的傳遞均需要主記憶體來完成。
記憶體互動
一個變數如何從主記憶體拷貝到工作記憶體,然後發生改變又從工作記憶體同步到主記憶體的,jvm定義了8中操作來完成,並保證每一種操作都是原子的。我們來看看有那些操作。
上圖就是一個變數從主記憶體到工作記憶體,經過使用和賦值之後,又同步到主記憶體之中。
讀取:
1、read:讀取主記憶體的變數,傳送到工作記憶體中。
2、load: 把剛讀取的變數,放入到工作記憶體的變數副本中。
修改:
3、use:把工作記憶體變數的值傳遞給執行引擎
4、assign: 把執行引擎收到的值賦值給工作記憶體的變數
寫入:
5、store:把工作記憶體的變數傳送會主記憶體中
6、write:把剛store的變數放入到主記憶體中
鎖定:
除了以上三種分類,還有鎖定操作,用來處理執行緒獨佔狀態。
lock:把主記憶體的一個變數鎖定。
unlock: 把主記憶體內,lock的變數釋放解鎖,釋放後可以被其他執行緒訪問。
如果把變數從主變數複製到工作記憶體中,就要順序的執行read和load操作,如果要把變數從工作記憶體同步回主記憶體,就要順序的執行strore和write操作,不允許read和load、store和write操作之一單獨出現,也不允許一個執行緒丟棄assign操作,也就是改變後必須同步到主記憶體中。
另外還有有些其他的規則,比如變數不允許在工作執行緒中誕生,只可以在主記憶體中誕生,所以方法內的區域性變數也是在主記憶體中初始化的,並非在工作執行緒中誕生。如此的多的規則,要記住不容易,下面講到的happen-before會將這些規則整合一起,相信看完happen-before之後,會加深理解。
特徵
變數從誕生到賦值再回寫,這麼簡單的一個過程要分解為8個操作,目的是為了讓記憶體在高速讀取的同時,也能保持資料的一致性。
jvm記憶體模型是圍繞著併發過程中如何處理原子性、可見性和有序性來建立的,我們看下這三個特徵。
原子性:
jvm記憶體模型中直接保證的原子變數操作包括read、load、assign、use、store、write,基本資料的訪問讀寫都是具備原子性的。這就解釋了為何多執行緒下對變數賦值操作不是安全的,因為一個賦值會包含5個操作。
如果要保持原子性,jvm提供了lock和unlock,這個直接在程式碼層就是synchronized關鍵字,擁有synchronized關鍵字的同步塊,在對資料操作的時候,執行緒會先對變數lock,等操作完了在unlock,如此也具備原子操作了。
可見性:
可見性是指當一個執行緒修改了共享變數的值,其他執行緒可以立即得知 。比如輕同步關鍵字volatile就可以保證這點,這個下次單獨講。除了volatile,synchronized和final關鍵字定義的變數,也實現了可見性。其他的就沒辦法保證了,目前的可共享記憶體的多處理器架構上,一個執行緒無法馬上(甚至永遠)看到另一個執行緒操作產生的結果的。
有序性:
前面有講過,處理器為了加快處理速度,會把執行順序打亂,只保證結果一致,而不保證順序一致。這就是指令的重排序。
具體的編譯器實現可以產生任意它喜歡的程式碼 -- 只要所有執行這些程式碼產生的結果,能夠和記憶體模型預測的結果保持一致。這為編譯器實現者提供了很大的自由,包括操作的重排序。
jvm對定義了volatile和synchronize的關鍵變數,可以保證操作的有序性,比如禁止指令重排序,保證執行緒之間的操作有序,讓一個變數在同一時刻只能有個執行緒對其lock操作。
happens-before
前面鋪墊了這麼多的基礎知識,一直沒有講到jvm究竟是如何併發期間,保證對定義有synchronize和volatile變數的一致性?
happen-before,是判斷資料是否存在競爭、執行緒是否安全的主要依據,依靠這個原則,我們可以一攬子解決併發環境下兩個操作是否可能存在衝突的所有問題。
我們先看規則:
- 程式次序法則:如果在程式中,所有動作 A 出現在動作 B 之前,則執行緒中的每動作 A 都 happens-before 於該執行緒中的每一個動作 B。
- 監視器鎖法則:對一個監視器的解鎖 happens-before 於每個後續對同一監視器的加鎖。
- Volatile 變數法則:對 Volatile 域的寫入操作 happens-before 於每個後續對同一 Volatile 的讀操作。
- 傳遞性:如果 A happens-before 於 B,且 B happens-before C,則 A happens-before C。
- 執行緒啟動規則: thread物件的start()方法線性發生與此執行緒每個動作。
- 執行緒終止規則:執行緒中的所有操作都線性發生對此執行緒的終止檢測。
- 執行緒中斷規則:對執行緒interrupt()方法的呼叫先行發生與被中斷執行緒的程式碼檢測。
- 物件終結規則:一個物件的初始化完成線性發生於finalize()。
目前我們只關注前4個就可以了,後續的用到了再聊。volatile我們這次還是先不聊,我們總結一下1、2、4,很明顯是講一個對一個同步塊內的邏輯進行序列化操作,看下示例
int count = 0;
public synchronized void increCount() {
count++;
}
上例子中我們對increCount方法進行了同步處理,那麼比如我們有執行緒A、B、C同時呼叫這個方法會怎樣處理?
從上圖中我們可以看到,對於increCount來說,多個執行緒對其呼叫在jvm這裡是線性序列執行的,A中執行緒的監視器是this(當前物件),A中的監視器的解鎖 happens-before 與B執行緒的,如果同步中有兩個方法 a,b那麼ab的順序也是確認的。
所以說,時間先後順序與happens-before 原則沒有太大關係,我們衡量並非安全問題4的時候一切可以依據線性發生原則為準。