Java併發11:Java記憶體模型、指令重排、記憶體屏障、happens-before原則
本章主要對Java併發中非常重要的概念Java記憶體模型、指令重排和happens-before原則進行學習。
1.記憶體模型
如果想要設計表現良好的併發程式,理解Java記憶體模型是非常重要的。
Java執行緒之間的通訊由Java記憶體模型(Java Memory Model,簡稱JMM)控制。
JMM決定一個執行緒對共享變數的寫入何時對另一個執行緒可見。
JMM把JVM內部劃分為執行緒棧(Thread stack)和堆(Heap),這張圖演示了JMM的邏輯檢視:
說明:
- 每個執行緒都擁有自己的執行緒棧。
- 執行緒棧包含了當前執行緒執行的方法呼叫相關資訊,我們也把它稱作呼叫棧。
- 執行緒棧還包含了當前方法的所有本地變數資訊。
- 一個執行緒只能讀取自己的執行緒棧,每個執行緒中的本地變數都會有自己的版本,執行緒中的本地變數對其它執行緒是不可見的。
- 所有原始型別(8種)的本地變數都直接儲存線上程棧當中,執行緒可以傳遞原始型別的副本給另一個執行緒,執行緒之間無法共享原始型別的本地變數。
- 堆區包含了Java應用建立的所有物件資訊。
- 堆區包含了所有執行緒建立的物件資訊。
- 堆區包含了原始型別的封裝類(如Byte、Integer、Long等等)的物件資訊。
下圖展示了呼叫棧和本地變數都儲存在棧區,物件都儲存在堆區:
- 區域性的基本型別:完全儲存於Thread Stack中。
- 區域性的物件引用:引用會被儲存於Thread Stack中,物件本身會被儲存於Heap中。
- 物件的成員方法中的區域性變數:如果方法中包含本地變數,則會儲存在Thread Stack中。
- 物件的成員變數:不管它是原始型別還是包裝型別,都會被儲存到Heap區。
- Static型別的變數:儲存於Heap中。
- 類本身相關資訊:儲存於Heap中。
堆中的物件可以被多執行緒共享:
一個執行緒如果要使用一個共享變數,則首先需要從Heap中載入這個共享變數,並在Thread Stack中形成這個共享變數的副本。
一個執行緒使用完這個共享變數之後,還需要將Thread Stack中共享變數的副本更新到Heap中。
競爭:
如果多個執行緒共享一個物件,如果它們同時修改這個共享物件,這就產生了競爭現象。
舉例:
Obj.count的初始值是0,執行緒A和執行緒B同時對Obj.count做自增操作。
序列情況之一:
- 執行緒A在Thread Stack中建立Obj.count的副本,執行緒A的棧區中的Obj.count=0。
- 執行緒A在Thread Stack中對Obj.count執行自增操作,執行緒A的棧區中的Obj.count=1。
- 執行緒A將Thread Stack中Obj.count的副本更新到Heap中,這時,Heap中的Obj.count=1。
- 執行緒B在Thread Stack中建立Obj.count的副本,執行緒B的棧區中的Obj.count=1。
- 執行緒B在Thread Stack中對Obj.count執行自增操作,執行緒B的棧區中的Obj.count=2。
- 執行緒B將Thread Stack中Obj.count的副本更新到Heap中,這時,Heap中的Obj.count=2。
- 經過兩次自增操作,最終Obj.count=2。
並行情況之一:
- 執行緒A在Thread Stack中建立Obj.count的副本,執行緒A的棧區中的Obj.count=0。
- 執行緒B在Thread Stack中建立Obj.count的副本,執行緒B的棧區中的Obj.count=0。
- 執行緒A在Thread Stack中對Obj.count執行自增操作,執行緒A的棧區中的Obj.count=1。
- 執行緒B在Thread Stack中對Obj.count執行自增操作,執行緒B的棧區中的Obj.count=1。
- 執行緒A將Thread Stack中Obj.count的副本更新到Heap中,這時,Heap中的Obj.count=1。
- 執行緒B將Thread Stack中Obj.count的副本更新到Heap中,這時,Heap中的Obj.count=1。
- 經過兩次自增操作,最終Obj.count=1。
經過學習JMM,可以加深對併發會安全問題的理解。
2.指令重排
CPU執行單元的速度要遠超主存訪問速度。
在執行程式時,為了提高效能,編譯器和處理器會對指令做重排序。
- 編譯器優化重排序:編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。
- 指令級並行的重排序:如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。
- 記憶體系統的重排序:處理器使用快取和讀寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。
- 通過記憶體屏障禁止重排序:JMM通過插入特定型別的記憶體屏障,來禁止特定型別的編譯器重排序和處理器重排序。
2.1.單執行緒程式語義
編譯器、runtime和處理器都必須遵守as-if-serial語義。
不管怎麼重排序,單執行緒的執行結果不會改變。
2.2.資料依賴性
如果兩個操作訪問同一個變數,其中一個為寫操作,此時這兩個操作之間存在資料依賴性。
編譯器和處理器不會改變存在資料依賴性關係的兩個操作的執行順序,即不會重排序。
2.3.記憶體屏障
記憶體屏障或記憶體柵欄(Memory Barrier),是讓一個CPU處理單元中的記憶體狀態對其它處理單元可見的一項技術。
記憶體屏障有兩個能力:
- 就像一套柵欄分割前後的程式碼,阻止柵欄前後的沒有資料依賴性的程式碼進行指令重排序,保證程式在一定程度上的有序性。
- 強制把寫緩衝區/快取記憶體中的髒資料等寫回主記憶體,讓快取中相應的資料失效,保證資料的可見性。
記憶體屏障有三種類型和一種偽型別:
- lfence:即讀屏障(Load Barrier),在讀指令前插入讀屏障,可以讓快取記憶體中的資料失效,重新從主記憶體載入資料,以保證讀取的是最新的資料。
- sfence:即寫屏障(Store Barrier),在寫指令之後插入寫屏障,能讓寫入快取的最新資料寫回到主記憶體,以保證寫入的資料立刻對其他執行緒可見。
- mfence,即全能屏障,具備ifence和sfence的能力。
- Lock字首:Lock不是一種記憶體屏障,但是它能完成類似全能型記憶體屏障的功能。
在Java中:
實現了記憶體屏障的技術有volatile。
volatile就是用Lock字首方式的記憶體屏障偽型別來實現的。
學習指令重排,讓我們明白,因為指令重排,多執行緒開發中,是存在很多有序性和可見性問題的。
3.happens-before原則
從jdk5開始,java使用新的JSR-133記憶體模型,基於happens-before的概念來闡述操作之間的記憶體可見性。
happens-before定義:
Two actions can be ordered by a happens-before relationship. If one action happens-before another, then the first is visible to and ordered before the second.
如果一個操作 happens-before 第二個操作,則第一個操作對第二個操作是可見的,並且一定發生在第二個操作之前。
重要的happens-before原則:
- If x and y are actions of the same thread and x comes before y in program order, then hb(x, y).
- If an action x synchronizes-with a following action y, then we also have hb(x, y).
- If hb(x, y) and hb(y, z), then hb(x, z).
- An unlock on a monitor happens-before every subsequent lock on that monitor.
- A write to a volatile field happens-before every subsequent read of that field.
- A call to start() on a thread happens-before any actions in the started thread.
- All actions in a thread happen-before any other thread successfully returns from a join() on that thread.
理解如下:
- 執行緒內部規則:在同一個執行緒內,前面操作的執行結果對後面的操作是可見的。
- 同步規則:如果一個操作x與另一個操作y在同步程式碼塊/方法中,那麼操作x的執行結果對操作y可見。
- 傳遞規則:如果操作x的執行結果對操作y可見,操作y的執行結果對操作z可見,則操作x的執行結果對操作z可見。
- 物件鎖規則:如果執行緒1解鎖了物件鎖a,接著執行緒2鎖定了a,那麼,執行緒1解鎖a之前的寫操作的執行結果都對執行緒2可見。
- volatile變數規則:如果執行緒1寫入了volatile變數v,接著執行緒2讀取了v,那麼,執行緒1寫入v及之前的寫操作的執行結果都對執行緒2可見。
- 執行緒start原則:如果執行緒t在start()之前進行了一系列操作,接著進行了start()操作,那麼執行緒t在start()之前的所有操作的執行結果對start()之後的所有操作都是可見的。
- 執行緒join規則:執行緒t1寫入的所有變數,在任意其它執行緒t2呼叫t1.join()成功返回後,都對t2可見。
- 學習happens-before原則,讓我們瞭解了JVM本身提供的關於併發安全(主要是有序性和可見性)的保障規則。
參考文獻
[1] 聊聊高併發(三十五)Java記憶體模型那些事(三)理解記憶體屏障
[2] 記憶體屏障或記憶體柵欄【轉】
[3] 全面理解Java記憶體模型
[4] happens-before俗解
[5] Chapter 17. Threads and Locks