從JMM透析volatile與synchronized原理,圖文並茂

MageByte發表於2020-12-05

在面試、併發程式設計、一些開源框架中總是會遇到 volatilesynchronizedsynchronized 如何保證併發安全?volatile 語義的記憶體可見性指的是什麼?這其中又跟 JMM 有什麼關係,在併發程式設計中 JMM 的作用是什麼,為什麼需要 JMM?與 JVM 記憶體結構有什麼區別?

「碼哥位元組」 總結出裡面的核心知識點以及面試重點,圖文並茂無畏面試與併發程式設計,全面提升併發程式設計內功!

  1. JMM 與 JVM 記憶體結構有什麼區別?
  2. 到底什麼是 JMM (Java Memory Model) 記憶體模型,JMM 的跟併發程式設計有什麼關係?
  3. 記憶體模型最重要的內容:指令重排、原子性、記憶體可見性
  4. volatile 記憶體可見性指的是什麼?它的運用場景以及常見錯誤使用方式避坑指南。
  5. 分析 synchronized 實現原理跟 monitor 的關係;

JVM 記憶體與 JMM 記憶體模型

「碼哥位元組」會分別圖解下 JVM 記憶體結構和 JMM 記憶體模型,這裡不會講太多 JVM 相關的,未來會有專門講解 JVM 以及垃圾回收、記憶體調優的文章。敬請期待……

接下來我們通過圖文的方式分別認識 JVM 記憶體結構JMM 記憶體模型,DJ, trop the beat, lets’go!

JVM 記憶體結構這麼騷,需要和虛擬機器執行時資料一起嘮叨,因為程式執行的資料區域需要他來劃分各領風騷。

Java 記憶體模型也很妖嬈,不能被 JVM 記憶體結構來搞混淆,實際他是一種抽象定義,主要為了併發程式設計安全訪問資料。

總結下就是:

  • JVM 記憶體結構和 Java 虛擬機器的執行時區域有關;
  • Java 記憶體模型和 Java 的併發程式設計有關。

JVM 記憶體結構

Java 程式碼是執行在虛擬機器上的,我們寫的 .java 檔案首先會被編譯成 .class 檔案,接著被 JVM 虛擬機器載入,並且根據不同作業系統平臺翻譯成對應平臺的機器碼執行,如下如所示:

JVM跨平臺

從圖中可以看到,有了 JVM 這個抽象層之後,Java 就可以實現跨平臺了。JVM 只需要保證能夠正確載入 .class 檔案,就可以執行在諸如 Linux、Windows、MacOS 等平臺上了。

JVM 通過 Java 類載入器載入 javac 編譯出來的 class 檔案,通過執行引擎解釋執行或者 JIT 即時編譯呼叫才呼叫系統介面實現程式的執行。

JVM載入

而虛擬機器在執行程式的時候會把記憶體劃分為不同的資料區域,不同區域負責不同功能,隨著 Java 的發展,記憶體佈局也在調整之中,如下是 Java 8 之後的佈局情況,移除了永久代,使用 Mataspace 代替,所以 -XX:PermSize -XX:MaxPermSize 等引數變沒有意義。 JVM 記憶體結構如下圖所示:

JVM記憶體佈局

執行位元組碼的模組叫做執行引擎,執行引擎依靠程式計數器恢復執行緒切換。本地記憶體包含後設資料區域以及一些直接記憶體。

堆(Heap)

資料共享區域儲存例項物件以及陣列,通常是佔用記憶體最大的一塊也是資料共享的,比如 new Object() 就會生成一個例項;而陣列也是儲存在堆上面的,因為在 Java 中,陣列也是物件。垃圾收集器的主要作用區域。

那一個物件建立的時候,到底是在堆上分配,還是在棧上分配呢?這和兩個方面有關:物件的型別和在 Java 類中存在的位置。

Java 的物件可以分為基本資料型別和普通物件。

對於普通物件來說,JVM 會首先在堆上建立物件,然後在其他地方使用的其實是它的引用。比如,把這個引用儲存在虛擬機器棧的區域性變數表中。

對於基本資料型別來說(byte、short、int、long、float、double、char),有兩種情況。

我們上面提到,每個執行緒擁有一個虛擬機器棧。當你在方法體內宣告瞭基本資料型別的物件,它就會在棧上直接分配。其他情況,通常在在堆上分配,逃逸分析的情況下可能會在棧分配。

注意,像 int[] 陣列這樣的內容,是在堆上分配的。陣列並不是基本資料型別。

虛擬機器棧(Java Virtual Machine Stacks)

Java 虛擬機器棧基於執行緒,即使只有一個 main 方法,都是以執行緒的方式執行,在執行的生命週期中,參與計算的資料會出棧與入棧,而「虛擬機器棧」裡面的每條資料就是「棧幀」,在 Java 方法執行的時候則建立一個「棧幀」併入棧「虛擬機器棧」。呼叫結束則「棧幀」出棧,隨之對應的執行緒也結束。

public int add() {
  int a = 1, b = 2;
  return a + b;
}

add 方法會被抽象成一個「棧幀」的結構,當方法執行過程中則對應著運算元 1 與 2 的運算元棧入棧,並且賦值給區域性變數 a 、b ,遇到 add 指令則將運算元 1、2 出棧相加結果入棧。方法結束後「棧幀」出棧,返回結果結束。

每個棧幀包含四個區域:

  1. 區域性變數表:基本資料型別、物件引用、retuenAddress 指向位元組碼的指標;
  2. 運算元棧
  3. 動態連線
  4. 返回地址

這裡有一個重要的地方,敲黑板了:

  • 實際上有兩層含義的棧,第一層是「棧幀」對應方法;第二層對應著方法的執行,對應著運算元棧。
  • 所有的位元組碼指令,都會被抽象成對棧的入棧與出棧操作。執行引擎只需要傻瓜式的按順序執行,就可以保證它的正確性。

每個執行緒擁有一個「虛擬機器棧」,每個「虛擬機器棧」擁有多個「棧幀」,而棧幀則對應著一個方法。每個「棧幀」包含區域性變數表、運算元棧、動態連結、方法返回地址。方法執行結束則意味著該「棧幀」出棧。

如下圖所示:

JVM虛擬機器棧

方法區(Method Area)元空間

儲存每個 class 類的後設資料資訊,比如類的結構、執行時的常量池、欄位、方法資料、方法建構函式以及介面初始化等特殊方法。

元空間是在堆上麼?

答:不是在堆上分配的,而是在堆外空間分配,方法區就是在元空間中。

字串常量池在那個區域中?

答:這個跟 JDK 不同版本不同區別,JDK 1.8 之前,元空間還沒有出道成團,方法區被放在一個叫永久代的空間,而字串常量就在此間。

JDK 1.7 之前,字串常量池也放在叫作永久帶的空間。 JDK 1.7 之後,字串常量池從永久帶挪到了堆上湊。

所以,從 1.7 版本開始,字串常量池就一直存在於堆上。

本地方法棧(Native Method Stacks)

跟虛擬機器棧類似,區別在於前者是為 Java 方法服務,而本地方法棧是為 native 方法服務。

程式計數器(The PC Register)

儲存當前正在執行的 JVM 指令地址。我們的程式線上程切換中執行,那憑啥指導這個執行緒已經執行到什麼地方呢?

程式計數器是一塊較小的記憶體空間,它的作用可以看作是當前執行緒所執行的位元組碼的行號指示器。這裡面存的,就是當前執行緒執行的進度。

JMM(Java Memory Model,Java 記憶體模型)

DJ, drop the beats!有請“碼哥位元組”,撥弄 Java 記憶體模型這根動人心絃。

首先他不是“真實存在”,而是和多執行緒相關的一組“規範”,需要每個 JVM 的實現都要遵守這樣的“規範”,有了 JMM 的規範保障,併發程式執行在不同的虛擬機器得到出的程式結果才是安全可靠可信賴。

如果沒有 JMM 記憶體模型來規範,就可能會出現經過不同 JVM “翻譯”之後,執行的結果都不相同也不正確。

JMM 與處理器、快取、併發、編譯器有關。它解決了 CPU 多級快取、處理器優化、指令重排等導致的結果不可預期的問題資料,保證不同的併發語義關鍵字得到相應的併發安全的資料資源保護。

主要目的就是讓 Java 程式設計師在各種平臺下達到一致性訪問效果。

是 JUC 包工具類和併發關鍵字的原理保障

volatile、synchronized、Lock 等,它們的實現原理都涉及 JMM。有了 JMM 的參與,才讓各個同步工具和關鍵字能夠發揮作用同步語義才能生效,使得我們開發出併發安全的程式。

JMM 最重要的的三點內容:重排序、原子性、記憶體可見性

指令重排序

我們寫的 bug 程式碼,當我以為這些程式碼的執行順序按照我神來之筆的書寫的順序執行的時候,我發現我錯的。實際上,編譯器、JVM、甚至 CPU 都有可能出於優化效能的目的,並不能保證各個語句執行的先後順序與輸入的程式碼順序一致,而是調整了順序,這就是指令重排序

重排序優勢

可能我們會疑問:為什麼要指令重排序?有啥用?

如下圖:

Java併發程式設計78講

經過重排序之後,情況如下圖所示:

Java併發程式設計78講

重排序後,對 a 操作的指令發生了改變,節省了一次 Load a 和一次 Store a,減少了指令執行,提升了速度改變了執行,這就是重排序帶來的好處。

重排序的三種情況

  • 編譯器優化

    比如當前唐伯虎愛慕 “秋香”,那就把對“秋香”的愛慕、約會放到一起執行效率就高得多。避免在撩“冬香”的時候又跑去約會“秋香”,減少了這部分的時間開銷,此刻我們需要一定的順序重排。不過重排序並不意味著可以任意排序,它需要需要保證重排序後,不改變單執行緒內的語義,不能把對“秋香”說的話傳到“冬香”的耳朵裡,否則能任意排序的話,後果不堪設想,“時間管理大師”非你莫屬。

  • CPU 重排序

    這裡的優化跟編譯器類似,目的都是通過打亂順序提高整體執行效率,這就是為了更快而執行的祕密武器。

  • 記憶體“重排序”

    我不是真正意義的重排序,但是結果跟重排序有類似的成績。因為還是有區別所以我加了雙引號作為不一樣的定義。

    由於記憶體有快取的存在,在 JMM 裡表現為主存本地記憶體,而主存和本地記憶體的內容可能不一致,所以這也會導致程式表現出亂序的行為。

    每個執行緒只能夠直接接觸到工作記憶體,無法直接操作主記憶體,而工作記憶體中所儲存的資料正是主記憶體的共享變數的副本,主記憶體和工作記憶體之間的通訊是由 JMM 控制的。

舉個例子:

執行緒 1 修改了 a 的值,但是修改後沒有來得及把新結果寫回主存或者執行緒 2 沒來得及讀到最新的值,所以執行緒 2 看不到剛才執行緒 1 對 a 的修改,此時執行緒 2 看到的 a 還是等於初始值。但是執行緒 2 卻可能看到執行緒 1 修改 a 之後的程式碼執行效果,表面上看起來像是發生了重順序。

記憶體可見性

先來看為何會有記憶體可見性問題

public class Visibility {
    int x = 0;
    public void write() {
        x = 1;
    }

    public void read() {
        int y = x;
    }
}

記憶體可見性問題:當 x 的值已經被第一個執行緒修改了,但是其他執行緒卻看不到被修改後的值。

假設兩個執行緒執行的上面的程式碼,第 1 個執行緒執行的是 write 方法,第 2 個執行緒執行的是 read 方法。下面我們來分析一下,程式碼在實際執行過程中的情景是怎麼樣的,如下圖所示:

它們都可以從主記憶體中去獲取到這個資訊,對兩個執行緒來說 x 都是 0。可是此時我們假設第 1 個執行緒先去執行 write 方法,它就把 x 的值從 0 改為了 1,但是它改動的動作並不是直接發生在主記憶體中的,而是會發生在第 1 個執行緒的工作記憶體中,如下圖所示。

那麼,假設執行緒 1 的工作記憶體還未同步給主記憶體,此時假設執行緒 2 開始讀取,那麼它讀到的 x 值不是 1,而是 0,也就是說雖然此時執行緒 1 已經把 x 的值改動了,但是對於第 2 個執行緒而言,根本感知不到 x 的這個變化,這就產生了可見性問題。

volatile、synchronized、final、和鎖 都能保證可見性。要注意的是 volatile,每當變數的值改變的時候,都會立馬重新整理到主記憶體中,所以其他執行緒想要讀取這個資料,則需要從主記憶體中重新整理到工作記憶體上。

而鎖和同步關鍵字就比較好理解一些,它是把更多個操作強制轉化為原子化的過程。由於只有一把鎖,變數的可見性就更容易保證。

原子性

我們大致可以認為基本資料型別變數、引用型別變數、宣告為 volatile 的任何型別變數的訪問讀寫是具備原子性的(long 和 double 的非原子性協定:對於 64 位的資料,如 long 和 double,Java 記憶體模型規範允許虛擬機器將沒有被 volatile 修飾的 64 位資料的讀寫操作劃分為兩次 32 位的操作來進行,即允許虛擬機器實現選擇可以不保證 64 位資料型別的 load、store、read 和 write 這四個操作的原子性,即如果有多個執行緒共享一個並未宣告為 volatile 的 long 或 double 型別的變數,並且同時對它們進行讀取和修改操作,那麼某些執行緒可能會讀取到一個既非原值,也不是其他執行緒修改值的代表了“半個變數”的數值。

但由於目前各種平臺下的商用虛擬機器幾乎都選擇把 64 位資料的讀寫操作作為原子操作來對待,因此在編寫程式碼時一般也不需要將用到的 long 和 double 變數專門宣告為 volatile)。這些型別變數的讀、寫天然具有原子性,但類似於 “基本變數++” / “volatile++” 這種複合操作並沒有原子性。比如 i++;

Java 記憶體模型解決的問題

JMM 最重要的的三點內容:重排序、原子性、記憶體可見性。那麼 JMM 又是如何解決這些問題的呢?

JMM 抽象出主儲存器(Main Memory)和工作儲存器(Working Memory)兩種。

  • 主儲存器是例項位置所在的區域,所有的例項都存在於主儲存器內。比如,例項所擁有的欄位即位於主儲存器內,主儲存器是所有的執行緒所共享的。
  • 工作儲存器是執行緒所擁有的作業區,每個執行緒都有其專用的工作儲存器。工作儲存器存有主儲存器中必要部分的拷貝,稱之為工作拷貝(Working Copy)。

執行緒是無法直接對主記憶體進行操作的,如下圖所示,執行緒 A 想要和執行緒 B 通訊,只能通過主存進行交換。

經歷下面 2 個步驟:

1)執行緒 A 把本地記憶體 A 中更新過的共享變數重新整理到主記憶體中去。

2)執行緒 B 到主記憶體中去讀取執行緒 A 之前已更新過的共享變數。

JMM記憶體模型

從抽象角度看,JMM 定義了執行緒與主記憶體之間的抽象關係:

  1. 執行緒之間的共享變數儲存在主記憶體(Main Memory)中;
  2. 每個執行緒都有一個私有的本地記憶體(Local Memory),本地記憶體是 JMM 的一個抽象概念,並不真實存在,它涵蓋了快取、寫緩衝區、暫存器以及其他的硬體和編譯器優化。本地記憶體中儲存了該執行緒以讀/寫共享變數的拷貝副本。
  3. 從更低的層次來說,主記憶體就是硬體的記憶體,而為了獲取更好的執行速度,虛擬機器及硬體系統可能會讓工作記憶體優先儲存於暫存器和快取記憶體中。
  4. Java 記憶體模型中的執行緒的工作記憶體(working memory)是 cpu 的暫存器和快取記憶體的抽象描述。而 JVM 的靜態記憶體儲模型(JVM 記憶體模型)只是一種對記憶體的物理劃分而已,它只侷限在記憶體,而且只侷限在 JVM 的記憶體。

八個操作

為了支援 JMM,Java 定義了 8 種原子操作(Action),用來控制主存與工作記憶體之間的互動:

  1. read 讀取:作用於主記憶體,將共享變數從主記憶體傳動到執行緒的工作記憶體中,供後面的 load 動作使用。
  2. load 載入:作用於工作記憶體,把 read 讀取的值放到工作記憶體中的副本變數中。
  3. store 儲存:作用於工作記憶體,把工作記憶體中的變數傳送到主記憶體中,為隨後的 write 操作使用。
  4. write 寫入:作用於主記憶體,把 store 傳送值寫到主記憶體的變數中。
  5. use 使用:作用於工作記憶體,把工作記憶體的值傳遞給執行引擎,當虛擬機器遇到一個需要使用這個變數的指令,就會執行這個動作。
  6. assign 賦值:作用於工作記憶體,把執行引擎獲取到的值賦值給工作記憶體中的變數,當虛擬機器棧遇到給變數賦值的指令,執行該操作。比如 int i = 1;
  7. lock(鎖定) 作用於主記憶體,把變數標記為執行緒獨佔狀態。
  8. unlock(解鎖) 作用於主記憶體,它將釋放獨佔狀態。

深入淺出Java虛擬機器

如上圖所示,把一個變數資料從主記憶體複製到工作記憶體,要順序執行 read 和 load;而把變數資料從工作記憶體同步回主記憶體,就要順序執行 store 和 write 操作。

由於重排序、原子性、記憶體可見性,帶來的不一致問題,JMM 通過 八個原子動作,記憶體屏障保證了併發語義關鍵字的程式碼能夠實現對應的安全併發訪問。

原子性保障

JMM 保證了 read、load、assign、use、store 和 write 六個操作具有原子性,可以認為除了 long 和 double 型別以外,對其他基本資料型別所對應的記憶體單元的訪問讀寫都是原子的。

但是當你想要更大範圍的的原子性保證就需要使用 ,就可以使用 lock 和 unlock 這兩個操作。

記憶體屏障:記憶體可見性與指令重排序

那 JMM 如何保障指令重排序排序,記憶體可見性帶來併發訪問問題?

記憶體屏障(Memory Barrier)用於控制在特定條件下的重排序和記憶體可見性問題。JMM 記憶體屏障可分為讀屏障和寫屏障,Java 的記憶體屏障實際上也是上述兩種的組合,完成一系列的屏障和資料同步功能。Java 編譯器在生成位元組碼時,會在執行指令序列的適當位置插入記憶體屏障來限制處理器的重排序

組合如下:

  • Load-Load Barriers:load1 的載入優先於 load2 以及所有後續的載入指令,在指令前插入 Load Barrier,使得快取記憶體中的資料失效,強制重新從駐記憶體中載入資料。

  • Load-Store Barriers:確保 load1 資料的載入先於 store2 以及之後的儲存指令重新整理到記憶體。

  • Store-Store Barriers:確保 store1 資料對其他處理器可見,並且先於 store2 以及所有後續的儲存指令。在 Store Barrie 指令後插入 Store Barrie 會把寫入快取的最新資料重新整理到主記憶體,使得其他執行緒可見。

  • Store-Load Barriers:在 Load2 及後續所有讀取操作執行前,保證 Store1 的寫入對所有處理器可見。這條記憶體屏障指令是一個全能型的屏障,它同時具有其他 3 條屏障的效果,而且它的開銷也是四種屏障中最大的一個。

JMM 總結

JMM 是一個抽象概念,由於 CPU 多核多級快取、為了優化程式碼會發生指令重排的原因,JMM 為了遮蔽細節,定義了一套規範,保證最終的併發安全。它抽象出了工作記憶體於主記憶體的概念,並且通過八個原子操作以及記憶體屏障保證了原子性、記憶體可見性、防止指令重排,使得 volatile 能保證記憶體可見性並防止指令重排、synchronised 保證了記憶體可見性、原子性、防止指令重排導致的執行緒安全問題,JMM 是併發程式設計的基礎。

並且 JMM 為程式中所有的操作定義了一個關係,稱之為 「Happens-Before」原則,要保證執行操作 B 的執行緒看到操作 A 的結果,那麼 A、B 之間必須滿足「Happens-Before」關係,如果這兩個操作缺乏這個關係,那麼 JVM 可以任意重排序。

Happens-Before

  • 程式順序原則:如果程式操作 A 在操作 B 之前,那麼多執行緒中的操作依然是 A 在 B 之前執行。
  • 監視器鎖原則:在監視器鎖上的解鎖操作必須在同一個監視器上的加鎖操作之前執行。
  • volatile 變數原則:對 volatile 修飾的變數寫入操作必須在該變數的毒操作之前執行。
  • 執行緒啟動原則:線上程對 Tread.start 呼叫必須在該執行緒執行任何操作之前執行。
  • 執行緒結束原則:執行緒的任何操作必須在其他執行緒檢測到該執行緒結束前執行,或者從 Thread.join 中成功返回,或者在呼叫 Thread.isAlive 返回 false。
  • 中斷原則:當一個執行緒在另一個執行緒上呼叫 interrupt 時,必須在被中斷執行緒檢測到 interrupt 呼叫之前執行。
  • 終結器規則:物件的構造方法必須在啟動物件的終結器之前完成。
  • 傳遞性:如果操作 A 在操作 B 之前執行,並且操作 B 在操作 C 之前執行,那麼操作 A 必須在操作 C 之前執行。

volatile

它是 Java 中的一個關鍵字,當一個變數是共享變數,同時被 volatile 修飾當值被更改的時候,其他執行緒再讀取該變數的時候可以保證能獲取到修改後的值,通過 JMM 遮蔽掉各種硬體和作業系統的記憶體訪問差異 以及 CPU 多級快取等導致的資料不一致問題。

需要注意的是,volatile 修飾的變數對所有執行緒是立即可見的,關鍵字本身就包含了禁止指令重排的語意,但是在非原子操作的併發讀寫中是不安全的,比如 i++ 操作一共分三步操作。

相比 synchronised Lock volatile 更加輕量級,不會發生上下文切換等開銷,接著跟著「碼哥位元組」來分析下他的適用場景,以及錯誤使用場景。

volatile 的作用

  • 保證可見性:Happens-before 關係中對於 volatile 是這樣描述的:對一個 volatile 變數的寫操作 happen-before 後面對該變數的讀操作。

    這就代表瞭如果變數被 volatile 修飾,那麼每次修改之後,接下來在讀取這個變數的時候一定能讀取到該變數最新的值。

  • 禁止指令重排:先介紹一下 as-if-serial 語義:不管怎麼重排序,(單執行緒)程式的執行結果不會改變。在滿足 as-if-serial 語義的前提下,由於編譯器或 CPU 的優化,程式碼的實際執行順序可能與我們編寫的順序是不同的,這在單執行緒的情況下是沒問題的,但是一旦引入多執行緒,這種亂序就可能會導致嚴重的執行緒安全問題。用了 volatile 關鍵字就可以在一定程度上禁止這種重排序。

volatile 正確用法

boolean 標誌位

共享變數只有被賦值和讀取,沒有其他的多個複合操作(比如先讀資料再修改的複合運算 i++),我們就可以使用 volatile 代替 synchronized 或者代替原子類,因為賦值操作是原子性操作,而 volatile 同時保證了 可見性,所以是執行緒安全的。

如下經典場景 volatile boolean flag,一旦 flag 發生變化,所有的執行緒立即可見。

volatile boolean shutdownRequested;

...

public void shutdown() {
    shutdownRequested = true;
}

public void doWork() {
    while (!shutdownRequested) {
        // do stuff
    }
}

執行緒 1 執行 doWork() 的過程中,可能有另外的執行緒 2 呼叫了 shutdown,執行緒 1 裡嗎讀區到修改的值並停止執行。

這種型別的狀態標記的一個公共特性是:通常只有一種狀態轉換shutdownRequested 標誌從false 轉換為true,然後程式停止。

雙重檢查(單例模式)

class Singleton{
    private volatile static Singleton instance = null;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if(instance==null) { // 1
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();  //2
            }
        }
        return instance;
    }
}

在雙重檢查鎖模式中為什麼需要使用 volatile 關鍵字?

假如 Instance 類變數是沒有用 volatile 關鍵字修飾的,會導致這樣一個問題:

線上程執行到第 1 行的時候,程式碼讀取到 instance 不為 null 時,instance 引用的物件有可能還沒有完成初始化。

造成這種現象主要的原因是建立物件不是原子操作以及指令重排序。

第二行程式碼可以分解成以下幾步:

memory = allocate();  // 1:分配物件的記憶體空間
ctorInstance(memory); // 2:初始化物件
instance = memory;  // 3:設定instance指向剛分配的記憶體地址

根源在於程式碼中的 2 和 3 之間,可能會被重排序。例如:

memory = allocate();  // 1:分配物件的記憶體空間
instance = memory;  // 3:設定instance指向剛分配的記憶體地址
// 注意,此時物件還沒有被初始化!
ctorInstance(memory); // 2:初始化物件

這種重排序可能就會導致一個執行緒拿到的 instance 是非空的但是還沒初始化完全。

img

面試官可能會問你,“為什麼要 double-check?去掉任何一次的 check 行不行?”

我們先來看第二次的 check,這時你需要考慮這樣一種情況,有兩個執行緒同時呼叫 getInstance 方法,由於 singleton 是空的 ,因此兩個執行緒都可以通過第一重的 if 判斷;然後由於鎖機制的存在,會有一個執行緒先進入同步語句,並進入第二重 if 判斷 ,而另外的一個執行緒就會在外面等待。

不過,當第一個執行緒執行完 new Singleton() 語句後,就會退出 synchronized 保護的區域,這時如果沒有第二重 if (singleton == null) 判斷的話,那麼第二個執行緒也會建立一個例項,此時就破壞了單例,這肯定是不行的。

而對於第一個 check 而言,如果去掉它,那麼所有執行緒都會序列執行,效率低下,所以兩個 check 都是需要保留的。

volatile 錯誤用法

volatile 不適合運用於需要保證原子性的場景,比如更新的時候需要依賴原來的值,而最典型的就是 a++ 的場景,我們僅靠 volatile 是不能保證 a++ 的執行緒安全的。程式碼如下所示:

public class DontVolatile implements Runnable {
    volatile int a;
    public static void main(String[] args) throws InterruptedException {
        Runnable r =  new DontVolatile();
        Thread thread1 = new Thread(r);
        Thread thread2 = new Thread(r);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(((DontVolatile) r).a);
    }
    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            a++;
        }
    }
}

最終的結果 a < 2000。

synchronised

互斥同步是常見的併發正確性保障方式。同步就好像在公司上班,廁所只有一個,現在一幫人同時想去「帶薪拉屎」佔用廁所,為了保證廁所同一時刻只能一個員工使用,通過排隊互斥實現。

互斥是實現同步的一種手段,臨界區、互斥量(Mutex)和訊號量(Semaphore)都是主要互斥方式。互斥是因,同步是果。

監視器鎖(Monitor 另一個名字叫管程)本質是依賴於底層的作業系統的 Mutex Lock(互斥鎖)來實現的。每個物件都存在著一個 monitor 與之關聯,物件與其 monitor 之間的關係有存在多種實現方式,如 monitor 可以與物件一起建立銷燬或當執行緒試圖獲取物件鎖時自動生成,但當一個 monitor 被某個執行緒持有後,它便處於鎖定狀態。

mutex 的工作方式

在 Java 虛擬機器 (HotSpot) 中,Monitor 是基於 C++ 實現的,由 ObjectMonitor 實現的, 幾個關鍵屬性:

  • _owner:指向持有 ObjectMonitor 物件的執行緒
  • _WaitSet:存放處於 wait 狀態的執行緒佇列
  • _EntryList:存放處於等待鎖 block 狀態的執行緒佇列
  • _recursions:鎖的重入次數
  • count:用來記錄該執行緒獲取鎖的次數

ObjectMonitor 中有兩個佇列,_WaitSet 和 _EntryList,用來儲存 ObjectWaiter 物件列表( 每個等待鎖的執行緒都會被封裝成 ObjectWaiter 物件),_owner 指向持有 ObjectMonitor 物件的執行緒,當多個執行緒同時訪問一段同步程式碼時,首先會進入 _EntryList 集合,當執行緒獲取到物件的 monitor 後進入 _Owner 區域並把 monitor 中的 owner 變數設定為當前執行緒同時 monitor 中的計數器 count 加 1。

若執行緒呼叫 wait() 方法,將釋放當前持有的 monitor,owner 變數恢復為 null,count 自減 1,同時該執行緒進入 WaitSet 集合中等待被喚醒。若當前執行緒執行完畢也將釋放 monitor(鎖)並復位變數的值,以便其他執行緒進入獲取 monitor(鎖)。

在 Java 中,最基本的互斥同步手段就是 synchronised,經過編譯之後會在同步塊前後分別插入 monitorenter, monitorexit 這兩個位元組碼指令,而這兩個位元組碼指令都需要提供一個 reference 型別的引數來指定要鎖定和解鎖的物件,具體表現如下所示:

  • 在普通同步方法,reference 關聯和鎖定的是當前方法示例物件;
  • 對於靜態同步方法,reference 關聯和鎖定的是當前類的 class 物件;
  • 在同步方法塊中,reference 關聯和鎖定的是括號裡制定的物件;

Java 物件頭

synchronised 用的鎖也存在 Java 物件頭裡,在 JVM 中,物件在記憶體的佈局分為三塊區域:物件頭、例項資料、對其填充。

物件頭

  • 物件頭:MarkWord 和 metadata,也就是圖中物件標記和後設資料指標;
  • 例項物件:存放類的屬性資料,包括父類的屬性資訊,如果是陣列的例項部分還包括陣列的長度,這部分記憶體按 4 位元組對齊;
  • 填充資料:由於虛擬機器要求物件起始地址必須是 8 位元組的整數倍。填充資料不是必須存在的,僅僅是為了位元組對齊;

物件頭是 synchronised 實現的關鍵,使用的鎖物件是儲存在 Java 物件頭裡的,jvm 中採用 2 個字寬(一個字寬代表 4 個位元組,一個位元組 8bit)來儲存物件頭(如果物件是陣列則會分配 3 個字寬,多出來的 1 個字寬記錄的是陣列長度)。其主要結構是由 Mark Word 和 Class Metadata Address 組成。

Mark word 記錄了物件和鎖有關的資訊,當某個物件被 synchronized 關鍵字當成同步鎖時,那麼圍繞這個鎖的一系列操作都和 Mark word 有關係。

虛擬機器位數 物件結構 說明
32/64bit Mark Word 儲存物件的 hashCode、鎖資訊或分代年齡或 GC 標誌等資訊
32/64bit Class Metadata Address 型別指標指向物件的類後設資料,JVM 通過這個指標確定該物件是哪個類的例項。
32/64bit Array length 陣列的長度(如果當前物件是陣列)

其中 Mark Word 在預設情況下儲存著物件的 HashCode、分代年齡、鎖標記位等。Mark Word 在不同的鎖狀態下儲存的內容不同,在 32 位 JVM 中預設狀態為下:

鎖狀態 25 bit 4 bit 1 bit 是否是偏向鎖 2 bit 鎖標誌位
無鎖 物件 HashCode 物件分代年齡 0 01

在執行過程中,Mark Word 儲存的資料會隨著鎖標誌位的變化而變化,可能出現如下 4 種資料:

鎖標誌位的表示意義:

  1. 鎖標識 lock=00 表示輕量級鎖
  2. 鎖標識 lock=10 表示重量級鎖
  3. 偏向鎖標識 biased_lock=1 表示偏向鎖
  4. 偏向鎖標識 biased_lock=0 且鎖標識=01 表示無鎖狀態

到目前為止,我們再總結一下前面的內容,synchronized(lock) 中的 lock 可以用 Java 中任何一個物件來表示,而鎖標識的儲存實際上就是在 lock 這個物件中的物件頭內。

Monitor(監視器鎖)本質是依賴於底層的作業系統的 Mutex Lock(互斥鎖)來實現的。Mutex Lock 的切換需要從使用者態轉換到核心態中,因此狀態轉換需要耗費很多的處理器時間。所以 synchronized 是 Java 語言中的一個重量級操作。

為什麼任意一個 Java 物件都能成為鎖物件呢?

Java 中的每個物件都派生自 Object 類,而每個 Java Object 在 JVM 內部都有一個 native 的 C++物件 oop/oopDesc 進行對應。
其次,執行緒在獲取鎖的時候,實際上就是獲得一個監視器物件(monitor) ,monitor 可以認為是一個同步物件,所有的 Java 物件是天生攜帶 monitor。

多個執行緒訪問同步程式碼塊時,相當於去爭搶物件監視器修改物件中的鎖標識, ObjectMonitor 這個物件和執行緒爭搶鎖的邏輯有密切的關係。

總結討論

JMM 總結

JVM 記憶體結構和 Java 虛擬機器的執行時區域有關;

Java 記憶體模型和 Java 的併發程式設計有關。JMM 是併發程式設計的基礎,它遮蔽了硬體於系統造成的記憶體訪問差異,保證了 一致性、原子性、並禁止指令重排保證了安全訪問。通過匯流排嗅探機制使得快取資料失效, 保證 volatile 記憶體可見性。

JMM 是一個抽象概念,由於 CPU 多核多級快取、為了優化程式碼會發生指令重排的原因,JMM 為了遮蔽細節,定義了一套規範,保證最終的併發安全。它抽象出了工作記憶體於主記憶體的概念,並且通過八個原子操作以及記憶體屏障保證了原子性、記憶體可見性、防止指令重排,使得 volatile 能保證記憶體可見性並防止指令重排、synchronised 保證了記憶體可見性、原子性、防止指令重排導致的執行緒安全問題,JMM 是併發程式設計的基礎。

synchronized 原理

提到了鎖的幾個概念,偏向鎖、輕量級鎖、重量級鎖。在 JDK1.6 之前,synchronized 是一個重量級鎖,效能比較差。從 JDK1.6 開始,為了減少獲得鎖和釋放鎖帶來的效能消耗,synchronized 進行了優化,引入了偏向鎖和輕量級鎖的概念。

所以從 JDK1.6 開始,鎖一共會有四種狀態,鎖的狀態根據競爭激烈程度從低到高分別是: 無鎖狀態->偏向鎖狀態->輕量級鎖狀態->重量級鎖狀態。這幾個狀態會隨著鎖競爭的情況逐步升級。為了提高獲得鎖和釋放鎖的效率,鎖可以升級但是不能降級。

同時為了提升效能,還帶來了鎖消除、鎖粗化、自旋鎖和自適應自旋鎖…...

鑑於篇幅原因關於執行緒狀態、鎖的同步過程「碼哥位元組」下回分解,分別介紹加鎖、解鎖以及鎖升級過程中 Mark Word 如何變化。如何正確使用 wait()、 notify() 實現生產-消費模式,講解如何避免常見的易錯知識點,防止掉坑。

敬請期待......

讀者朋友可以加我微信備註 「加群」加入「碼哥位元組」專屬技術讀者群,一起成長。群裡還會分享「阿里」和「騰訊」內推,歡迎大神到碗裡來。

往期推薦

從面試角度一文學完 Kafka

Tomcat 架構原理解析到架構設計借鑑

終極解密輸入網址按回車到底發生了什麼

相關文章