Java併發2:JMM,volatile,synchronized,final

mortal同學發表於2018-12-25

併發程式設計的兩個關鍵問題

併發程式設計需要處理兩個關鍵問題:執行緒之間如何通訊以及執行緒之間如何同步

通訊是指執行緒之間以何種機制來交換資訊。執行緒之間的通訊機制有兩種:共享記憶體和訊息傳遞。

共享記憶體模型中,執行緒之間共享程式的公共狀態,通過讀-寫記憶體中的公共狀態進行隱式通訊。多條執行緒共享一片記憶體,傳送者將訊息寫入記憶體,接收者從記憶體中讀取訊息,從而實現了訊息的傳遞。

訊息傳遞模型中,執行緒之間通過傳送訊息來進行顯式通訊。

同步是指程式中用於控制不同執行緒間操作發生相對順序的機制。在共享記憶體模型中,需要進行顯式的同步,程式設計師必須顯式指定某段程式碼需要線上程之間互斥執行;在訊息傳遞模型中,訊息傳送必須在訊息接收之前,因此同步是隱式進行的。

Java採用的是共享記憶體模型。

Java記憶體模型

在 Java 中,所有例項域、靜態域和陣列元素存放在堆記憶體,堆記憶體線上程之間共享。區域性變數、方法定義引數和異常處理器引數不會線上程之間共享。

Java併發2:JMM,volatile,synchronized,final
Java 執行緒之間的通訊由 Java 記憶體模型控制,JMM 決定一個執行緒對共享變數的寫入何時對另一個執行緒可見。執行緒之間的共享變數儲存在主記憶體中,每個執行緒都有一個私有的本地記憶體,本地記憶體中儲存了該執行緒以讀/寫共享變數的副本。

Java併發2:JMM,volatile,synchronized,final
當執行緒A與執行緒B之間要通訊的話,首先執行緒A將本地記憶體中更新過的共享變數重新整理到主記憶體;然後執行緒B到主記憶體去讀取執行緒A之前已經更新過的共享變數。

Java 記憶體模型和硬體的記憶體架構不一致,是交叉關係。無論是堆還是棧,大部分資料都會儲存到記憶體中,一部分棧和堆的資料也有可能存到CPU暫存器中。Java記憶體模型試圖遮蔽各種硬體和作業系統的記憶體訪問差異,以實現讓Java程式在各種平臺下都能達到一致的記憶體訪問效果。

Java併發2:JMM,volatile,synchronized,final

Java 記憶體模型的三大特性:原子性、可見性和順序性

原子性

原子性就是指一個操作中要麼全部執行成功,否則失敗。Java記憶體模型允許虛擬機器將沒有被volatile修飾的64位資料(long,double)的讀寫操作劃分為兩次32位操作進行。

i++這樣的操作,其實是分為獲取i,i自增以及賦值給i三步的,如果要實現這樣的原子操作就需要使用原子類實現,或者也可以使用synchronized互斥鎖來保證操作的原子性。

CAS

CAS 也就是 CompareAndSet, 在Java中可以通過迴圈CAS來實現原子操作。在JVM內部,除了偏向鎖,JVM實現鎖的方式都是用了CAS,也就是當一個執行緒想進入同步塊的時候使用CAS獲取鎖,退出時使用CAS釋放鎖。

可見性

可見性指的是當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改。Java記憶體模型是通過在變數修改後將新值同步回主記憶體,在變數讀取前從主記憶體重新整理變數值來實現可見性的。

重排序

執行程式時,為了提高效能,編譯器和處理器常常會對指令進行重排序。

  • 編譯器優化重排序:編譯器在不改變單執行緒程式語義的前提下,重新安排語句執行順序
  • 指令級並行重排序:處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應及其的執行順序。
  • 記憶體系統的重排序:處理器使用快取和讀/寫緩衝區,使得載入和儲存操作看上去可能是亂序執行。

重排序可能導致多執行緒程式出現記憶體可見性問題。JMM 通過插入特定型別的記憶體屏障指令來禁止特定型別的處理器重排序,確保了不同的編譯器和處理器平臺上,能提供一致的記憶體可見性保證。

資料依賴性

如果兩個操作訪問同一個變數,且這兩個操作中有一個是寫操作,這兩個操作之間就存在資料依賴性。在重排序時,會遵守資料依賴性,不會改變存在資料依賴關係的兩個操作的執行順序,也就是不會重排序。但是,這是針對單個處理器或單個執行緒而言的,多執行緒或多處理器之間的資料依賴性不被考慮在內。

as-if-serial

不管怎麼重排序,單執行緒程式的執行結果不能被改變。as-if-serial 語義使得單執行緒程式設計師無需擔心重排序的干擾。

重排序可能會改變多執行緒程式的執行結果,如下圖所示

Java併發2:JMM,volatile,synchronized,final

happens-before

JMM 一方面要為程式設計師提供足夠強的記憶體可見性保證;另一方面,對編譯器和處理器的限制要儘可能放鬆。

JMM 對不同性質的重排序,採取了不同的策略:

  • 對於會改變程式執行結果的重排序,JMM 要求編譯器和處理器禁止這種重排序
  • 對於不會改變程式執行結果的重排序,JMM 不做要求,允許重排序。 也就是說,JMM 遵循的基本原則是:只要不改變程式的執行結果,編譯器和處理器怎麼優化都行。

JSR-133 中對 happens-before 關係定義如下:

  1. 如果一個操作 happens-before 另一個操作,那麼第一個操作的執行結果將對第二個操作可見,且第一個操作的執行順序排在第二個操作之前。
  2. 兩個操作中間存在 happens-before 關係,如果重排序之後的執行結果與按照 happends-before 執行結果一致,JMM 允許這種重排序。

happens-before 與 as-if-serial 相比,後者保證了單執行緒內程式的執行結果不被改變;前者保證正確同步的多執行緒程式的執行結果不被改變。

JSR-133中定義瞭如下的 happens-before 規則:

  • 單一執行緒原則:在一個執行緒內,程式前面的操作先於後面的操作。
  • 監視器鎖規則:一個unlock操作先於後面對同一個鎖的lock操作發生。
  • volatile變數規則:對一個 volatile 變數的寫操作先行發生於後面對這個變數的讀操作,也就是說讀取的值肯定是最新的。
  • 執行緒啟動規則:Thread物件的start()方法呼叫先行發生於此執行緒的每一個動作。
  • 執行緒加入規則:Thread 物件的結束先行發生於 join() 方法返回。
  • 執行緒中斷規則:對執行緒 interrupt() 方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生,可以通過 interrupted() 方法檢測到是否有中斷髮生。
  • 物件終結規則:一個物件的初始化完成(建構函式執行結束)先行發生於它的 finalize() 方法的開始。
  • 傳遞性:如果操作 A 先行發生於操作 B,操作 B 先行發生於操作 C,那麼操作 A 先行發生於操作 C。

可見性實現

可見性有三種實現方式:

  • volatile
  • synchronized 對一個變數執行 unlock 操作之前,必須把變數值同步回主記憶體
  • final 被 final關鍵字修飾的欄位在構造器中一旦初始化完成,並且沒有發生 this 逃逸(其它執行緒通過 this 引用訪問到初始化了一半的物件),那麼其它執行緒就能看見 final 欄位的值。

順序性

資料競爭

在一個執行緒中寫一個變數,在另一個執行緒中讀一個變數,而且寫和讀沒有通過同步來排序。

JMM 中的順序性

在理想化的順序一致性記憶體模型中,有兩大特性:

  • 一個執行緒中的所有操作必須按照程式的順序來執行
  • 所有執行緒都只能看到一個單一的操作執行順序。

JMM對正確同步的多執行緒程式的記憶體一致性做了如下保證:如果程式是正確同步的,程式的執行將具有順序一致性,也即程式的執行結果與該程式在順序一致性記憶體模型中的執行結果相同。

JMM 的實現方針為:在不改變正確同步的程式執行結果的前提下,儘可能為優化提供方便。因此,JMM 與上述理想化的順序一致性記憶體模型有如下差異:

  • 順序一致性模型保證單執行緒操作按照順序執行;JMM 不保證這一點(臨界區內可以重排序)
  • JMM 不保證所有執行緒看到一致的操作執行順序
  • JMM 不保證對64位的 long 和 double 型別變數的寫操作具有原子性。

Java中可以使用volatile關鍵字來保證順序性,還可以用synchronized和lock來保證。

  • volatile 關鍵字通過新增記憶體屏障的方式來禁止指令重排,即重排序時不能把後面的指令放到記憶體屏障之前。
  • 通過 synchronized 和 lock 來保證有序性,它保證每個時刻只有一個執行緒執行同步程式碼,相當於是讓執行緒順序執行同步程式碼。

volatile

volatile 關鍵字解決的是記憶體可見性的問題,會使得所有對 volatile 變數的讀寫都會直接重新整理到主存,保證了變數的可見性。

要注意的是,使用 volatile 關鍵字僅能實現對原始變數操作的原子性(boolean,int,long等),不能保證符合操作的原子性(如i++)。

一個 volatile 變數的單個讀/寫操作,和使用同一個鎖對普通變數的讀/寫操作進行同步,執行的效果是相同的。鎖的 happens-before 規則保證釋放鎖和獲取鎖的兩個執行緒之間的記憶體可見性,這意味著對一個 volatile 變數的讀,總能看到對這個變數最後的寫入,從而實現了可見性。需要注意的是,對任意單個 volatile 變數的讀/寫具有原子性,但是類似於i++這種複合操作不具有原子性。

當寫一個 volatile 變數時,JMM 會把該執行緒對應的本地記憶體中的共享變數值重新整理到記憶體。 當讀一個 volatile 變數時,JMM 會把執行緒對應的本地記憶體置為無效。執行緒接下來將從主記憶體中讀取共享變數。

具體來說,執行緒A寫一個 volatile 變數,實質上是執行緒A向接下來將要讀這個 volatile 變數的執行緒發出了它修改的資訊;執行緒B讀一個 volatile 變數,實質上是執行緒B接收了之前某個執行緒發出的修改資訊。

synchronized

JVM 是通過進入和退出物件監視器來實現同步的。Java 中的每一個物件都可以作為鎖。

  • 對於普通同步方法,鎖是當前例項物件
  • 對於靜態同步方法,鎖是當前類的Class物件
  • 對於同步程式碼塊,鎖是synchronized括號裡配置的物件

synchronized使用

  • https://juejin.im/post/5c0b6a5e51882521c8116c3c
  • https://juejin.im/post/5c0b9dc4e51d45022a15db8a

鎖優化

JDK 1.6 中對 synchronized 進行了優化,為了減少獲取和釋放鎖帶來的消耗引入了偏向所和輕量鎖。也就是說鎖一共有四種狀態,級別從低到高分別是:無鎖狀態,偏向鎖狀態,輕量級鎖狀態和重量級鎖狀態。鎖可以升級但是不能降級。

Java頭

synchronized 使用的鎖是存放在 Java 物件頭中的。如果物件是陣列型別,則虛擬機器用3個Word(字寬)儲存物件頭,如果物件是非陣列型別,則用2字寬儲存物件頭。

Java 頭中包含了Mark Word,用來儲存物件的 hashCode 或者鎖資訊,在執行期間其中儲存的資料會隨著鎖的標誌位的變化而變化。

Java併發2:JMM,volatile,synchronized,final

偏向鎖

大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由統一執行緒多次獲得,為了讓執行緒獲取鎖的代價更低而引入了偏向鎖。

它的核心思想是:如果一個執行緒獲得了鎖,那麼鎖就進入偏向模式。當這個執行緒再次請求鎖時,無須再做任何同步操作。這樣就節省了大量有關鎖申請的操作,從而提高了程式效能。因此,對於幾乎沒有鎖競爭的場合,偏向鎖有比較好的優化效果,因為連續多次極有可能是同一個執行緒請求相同的鎖。而對於鎖競爭比較激烈的場合,其效果不佳。

釋放鎖:當有另外一個執行緒獲取這個鎖時,持有偏向鎖的執行緒就會釋放鎖,釋放時會等待全域性安全點(這一時刻沒有位元組碼執行),接著會暫停擁有偏向鎖的執行緒,根據鎖物件目前是否被鎖來判定將物件頭中的 Mark Word 設定為無鎖或者是輕量鎖狀態。

Java併發2:JMM,volatile,synchronized,final

輕量級鎖

加鎖: 當程式碼進入同步塊時,如果同步物件為無鎖狀態時,當前執行緒會在棧幀中建立一個鎖記錄(Lock Record)區域,同時將鎖物件的物件頭中 Mark Word 拷貝到鎖記錄中,再嘗試使用 CAS 將 Mark Word 更新為指向鎖記錄的指標。如果更新成功,當前執行緒就獲得了鎖。如果更新失敗 JVM 會先檢查鎖物件的 Mark Word 是否指向當前執行緒的鎖記錄。如果是則說明當前執行緒擁有鎖物件的鎖,可以直接進入同步塊。不是則說明有其他執行緒搶佔了鎖,嘗試使用自旋鎖來獲取鎖。

**解鎖:**輕量鎖的解鎖過程也是利用 CAS 來實現的,會嘗試鎖記錄替換回鎖物件的 Mark Word 。如果替換成功則說明整個同步操作完成,失敗則說明有其他執行緒嘗試獲取鎖,這時就會喚醒被掛起的執行緒(此時已經膨脹為重量鎖)

三種鎖的對比:

鎖型別 優點 缺點 使用場景
偏向鎖 加鎖和解鎖不需要額外的消耗,和執行非同步方法比僅存在納秒級的差距 如果執行緒間存在鎖競爭,會帶來額外的鎖撤銷的消耗 適用於只有一個執行緒訪問同步塊場景
輕量級鎖 競爭的執行緒不會阻塞,提高了程式的響應速度 如果始終得不到鎖競爭的執行緒使用自旋會消耗CPU 追求響應時間,鎖佔用時間很短
重量級鎖 執行緒競爭不使用自旋,不會消耗CPU 執行緒阻塞,響應時間緩慢 追求吞吐量,鎖佔用時間較長

volatile和synchronized比較

  • volatile 本質是告訴jvm當前變數在工作記憶體中的值是不確定的,需要從主存讀取;synchronized 是鎖定當前變數,只有當前執行緒可以訪問該變數,其他執行緒被阻塞
  • volatile 只能使用在變數級別;synchronized 可以使用在變數、方法和類級別
  • volatile 僅能實現變數可見性,不能保證原子性;synchronized 可以保證變數的可見性和原子性
  • volatile 不會造成執行緒阻塞;synchronized 可能會造成執行緒的阻塞
  • volatile 標記的變數不會被編譯器優化,synchronized 標記的變數可以被編譯器優化

鎖記憶體語義

釋放鎖與volatile寫有相同的記憶體語義,執行緒A釋放鎖,是A向要獲取鎖的執行緒發出A對共享變數修改的訊息。 獲取鎖與volatile讀有相同的記憶體語義,是執行緒B接收了之前執行緒發出的堆共享變數做的修改的訊息。

從 ReentrantLock 中可以看到:

  • 公平鎖和非公平鎖的釋放,都需要寫一個volatile變數 state
  • 公平鎖的獲取,首先要讀 volatile 變數
  • 非公平鎖的獲取,用CAS更新volatile變數,同時有volatile讀、寫的記憶體語義

在juc包中原始碼實現,可以發現Java執行緒之間通訊的通用化實現模式:

  1. 首先宣告共享變數為 volatile
  2. 使用CAS的原子條件更新來實現執行緒之間同步
  3. 配合以 volatile 的讀/寫和CAS所具有的讀和寫的記憶體語義來實現執行緒間通訊。

final域

重排序規則

對於 final 域,遵循兩個重排序規則:

  1. 在建構函式內對一個 final 域的寫入,與隨後把這個被構造物件的引用賦值給一個引用變數,這兩個操作不能重排序
  2. 初次讀一個包含 final 域的物件的引用,與隨後初次讀這個 final 域,這兩個操作之間不能重排序。
public class FinalExample{
    int i;
    final int j;
    static FinalExample obj;
    
    public FinalExample(){
        i=1;
        j=2;
    }
    
    public static void writer(){
        obj=new FinalExample();
    }
    
    public static void reader(){
        FinalExample object=obj;
        int a=object.i;
        int b=object.j;
    }
}
複製程式碼

假設執行緒A執行 writer() 方法,執行緒B執行 reader() 方法。

寫final域的重排序規則 寫 final 域的重排序規則禁止把 final 域的寫重排序到建構函式之外。從而確保了在物件引用被任意執行緒可見之前,物件的final域已經被正確的初始化過了。在上述的程式碼中,執行緒B獲得的物件,final域一定被正確初始化,普通域i卻不一定。

讀final域的重排序規則 在一個執行緒中,初次讀物件引用與初次讀該物件包含的final域,JMM禁止處理器重排序該操作。從而確保在讀一個物件的final域之前,一定會先讀包含這個final域的物件的引用

final域為引用型別 在建構函式內對一個final引用的物件的寫入,與隨後在建構函式外把這個被構造物件的引用賦值給一個引用變數,不能重排序。

但是,要得到上述的效果,需要保證在建構函式內部,不能讓這個被構造物件的引用被其他執行緒所見,也就是不能有this逸出。

雙重檢查鎖定和延遲初始化

https://juejin.im/post/5c122d00e51d4541284cc592


參考資料

  • Java併發程式設計的藝術
  • Java多執行緒程式設計的藝術
  • https://blog.csdn.net/suifeng3051/article/details/52611310
  • https://github.com/CyC2018/CS-Notes/blob/master/docs/notes/Java%20%E5%B9%B6%E5%8F%91.md#%E5%8D%81java-%E5%86%85%E5%AD%98%E6%A8%A1%E5%9E%8B
  • https://blog.csdn.net/u010425776/article/details/54290526

相關文章