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

併發編程的兩個關鍵問題

併發編程須要處理兩個關鍵問題:線程之間如何通訊以及線程之間如何同步java

通訊是指線程之間以何種機制來交換信息。線程之間的通訊機制有兩種:共享內存和消息傳遞。git

共享內存模型中,線程之間共享程序的公共狀態,經過讀-寫內存中的公共狀態進行隱式通訊。多條線程共享一片內存,發送者將消息寫入內存,接收者從內存中讀取消息,從而實現了消息的傳遞。程序員

消息傳遞模型中,線程之間經過發送消息來進行顯式通訊。github

同步是指程序中用於控制不一樣線程間操做發生相對順序的機制。在共享內存模型中,須要進行顯式的同步,程序員必須顯式指定某段代碼須要在線程之間互斥執行;在消息傳遞模型中,消息發送必須在消息接收以前,所以同步是隱式進行的。編程

Java採用的是共享內存模型。數組

Java內存模型

在 Java 中,全部實例域、靜態域和數組元素存放在堆內存,堆內存在線程之間共享。局部變量、方法定義參數和異常處理器參數不會在線程之間共享。緩存

Java 線程之間的通訊由 Java 內存模型控制,JMM 決定一個線程對共享變量的寫入什麼時候對另外一個線程可見。線程之間的共享變量存儲在主內存中,每一個線程都有一個私有的本地內存,本地內存中存儲了該線程以讀/寫共享變量的副本。

當線程A與線程B之間要通訊的話,首先線程A將本地內存中更新過的共享變量刷新到主內存;而後線程B到主內存去讀取線程A以前已經更新過的共享變量。

Java 內存模型和硬件的內存架構不一致,是交叉關係。不管是堆仍是棧,大部分數據都會存儲到內存中,一部分棧和堆的數據也有可能存到CPU寄存器中。Java內存模型試圖屏蔽各類硬件和操做系統的內存訪問差別,以實現讓Java程序在各類平臺下都能達到一致的內存訪問效果。安全

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 語義使得單線程程序員無需擔憂重排序的干擾。

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

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 或者鎖信息,在運行期間其中存儲的數據會隨着鎖的標誌位的變化而變化。

偏向鎖

大多數狀況下,鎖不只不存在多線程競爭,並且老是由統一線程屢次得到,爲了讓線程獲取鎖的代價更低而引入了偏向鎖。

它的核心思想是:若是一個線程得到了鎖,那麼鎖就進入偏向模式。當這個線程再次請求鎖時,無須再作任何同步操做。這樣就節省了大量有關鎖申請的操做,從而提升了程序性能。所以,對於幾乎沒有鎖競爭的場合,偏向鎖有比較好的優化效果,由於連續屢次極有多是同一個線程請求相同的鎖。而對於鎖競爭比較激烈的場合,其效果不佳。

釋放鎖:當有另一個線程獲取這個鎖時,持有偏向鎖的線程就會釋放鎖,釋放時會等待全局安全點(這一時刻沒有字節碼運行),接着會暫停擁有偏向鎖的線程,根據鎖對象目前是否被鎖來斷定將對象頭中的 Mark Word 設置爲無鎖或者是輕量鎖狀態。

輕量級鎖

加鎖: 當代碼進入同步塊時,若是同步對象爲無鎖狀態時,當前線程會在棧幀中建立一個鎖記錄(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
相關文章
相關標籤/搜索