打工人,從 JMM 透析 volatile 與 synchronized 原理

2021-02-15 玉剛說


作者 | 李健青

來源 | 碼哥字節(ID:MageByte)

轉載請聯繫授權(微信ID:MageByte1024)


在面試、並發編程、一些開源框架中總是會遇到 volatile 與 synchronized 。synchronized 如何保證並發安全?volatile 語義的內存可見性指的是什麼?這其中又跟 JMM 有什麼關係,在並發編程中 JMM 的作用是什麼,為什麼需要 JMM?與 JVM 內存結構有什麼區別?

「碼哥字節」 總結出裡面的核心知識點以及面試重點,圖文並茂無畏面試與並發編程,全面提升並發編程內功!

到底什麼是 JMM (Java Memory Model) 內存模型,JMM 的跟並發編程有什麼關係?內存模型最重要的內容:指令重排、原子性、內存可見性volatile 內存可見性指的是什麼?它的運用場景以及常見錯誤使用方式避坑指南。分析 synchronized 實現原理跟 monitor 的關係;JVM 內存與 JMM 內存模型

在這推薦幾篇「碼哥」的炸裂文章給大家,內容與並發無關,就是這麼突然的推薦!

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

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

JVM 內存結構這麼騷,需要和虛擬機運行時數據一起嘮叨,因為程序運行的數據區域需要他來劃分各領風騷。

Java 內存模型也很妖嬈,不能被 JVM 內存結構來搞混淆,實際他是一種抽象定義,主要為了並發編程安全訪問數據。

總結下就是:

JVM 內存結構和 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 出棧相加結果入棧。方法結束後「棧幀」出棧,返回結果結束。

每個棧幀包含四個區域:

局部變量表:基本數據類型、對象引用、retuenAddress 指向字節碼的指針;

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

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

每個線程擁有一個「虛擬機棧」,每個「虛擬機棧」擁有多個「棧幀」,而棧幀則對應著一個方法。每個「棧幀」包含局部變量表、操作數棧、動態連結、方法返回地址。方法運行結束則意味著該「棧幀」出棧。

如下圖所示:

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 定義了線程與主內存之間的抽象關係:

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

八個操作

為了支持 JMM,Java 定義了 8 種原子操作(Action),用來控制主存與工作內存之間的交互:

read 讀取:作用於主內存,將共享變量從主內存傳動到線程的工作內存中,供後面的 load 動作使用。load 載入:作用於工作內存,把 read 讀取的值放到工作內存中的副本變量中。store 存儲:作用於工作內存,把工作內存中的變量傳送到主內存中,為隨後的 write 操作使用。write 寫入:作用於主內存,把 store 傳送值寫到主內存的變量中。use 使用:作用於工作內存,把工作內存的值傳遞給執行引擎,當虛擬機遇到一個需要使用這個變量的指令,就會執行這個動作。assign 賦值:作用於工作內存,把執行引擎獲取到的值賦值給工作內存中的變量,當虛擬機棧遇到給變量賦值的指令,執行該操作。比如 int i = 1;lock(鎖定) 作用於主內存,把變量標記為線程獨佔狀態。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。

synchronized

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

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

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

mutex 的工作方式

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

_owner:指向持有 ObjectMonitor 對象的線程_WaitSet:存放處於 wait 狀態的線程隊列_EntryList:存放處於等待鎖 block 狀態的線程隊列

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 對象頭

synchronized 用的鎖也存在 Java 對象頭裡,在 JVM 中,對象在內存的布局分為三塊區域:對象頭、實例數據、對其填充。

對象頭對象頭:MarkWord 和 metadata,也就是圖中對象標記和元數據指針;實例對象:存放類的屬性數據,包括父類的屬性信息,如果是數組的實例部分還包括數組的長度,這部分內存按 4 字節對齊;填充數據:由於虛擬機要求對象起始地址必須是 8 字節的整數倍。填充數據不是必須存在的,僅僅是為了字節對齊;

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

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

虛擬機位數對象結構說明32/64bitMark Word存儲對象的 hashCode、鎖信息或分代年齡或 GC 標誌等信息32/64bitClass Metadata Address類型指針指向對象的類元數據,JVM 通過這個指針確定該對象是哪個類的實例。32/64bitArray length數組的長度(如果當前對象是數組)

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

鎖狀態25 bit4 bit1 bit 是否是偏向鎖2 bit 鎖標誌位無鎖對象 HashCode對象分代年齡001

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

鎖標誌位的表示意義:

偏向鎖標識 biased_lock=1 表示偏向鎖偏向鎖標識 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 開始,鎖一共會有四種狀態,鎖的狀態根據競爭激烈程度從低到高分別是: 無鎖狀態->偏向鎖狀態->輕量級鎖狀態->重量級鎖狀態。這幾個狀態會隨著鎖競爭的情況逐步升級。為了提高獲得鎖和釋放鎖的效率,鎖可以升級但是不能降級。

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




編程·思維·職場
歡迎掃碼關注


相關焦點

  • 詳解鎖原理,synchronized、volatile+cas底層實現
    當嘗試給資源加鎖卻被其他線程先鎖定時,不是阻塞等待而是循環再次加鎖在鎖常被短暫持有的場景下,線程阻塞掛起導致CPU上下文頻繁切換,這可用自旋鎖解決;但自旋期間它佔用CPU空轉,因此不適用長時間持有鎖的場景2 synchronized底層原理代碼使用synchronized加鎖,在編譯之後的字節碼是怎樣的呢
  • 深入synchronized和volatile底層原理
    volatile的原理,volatile 的底層實現原理是內存屏障,Memory Barrier(Memory Fence)volatile如何保證可見性寫屏障(sfence):保證在該屏障之前的,對共享變量的改動,都同步到主存當中。
  • volatile和synchronized到底啥區別?多圖文講解告訴你
    和 synchronized 二者的區別, 他的問題主要可以歸納為這幾個:volatile 與 synchronized 在處理哪些問題是相對等價的?今天的兩位主角閃亮登場,不過在說明 volatile關鍵字之前,我們先來說說你最熟悉的 synchronized 關鍵字synchronized遇到線程不安全的問題,習慣性的會想到用 synchronized 關鍵字來解決問題,暫且先不論該辦法是否合理,我們來看 synchronized 關鍵字是怎麼解決上面提到的共享變量內存可見性問題的【進入】synchronized
  • Java 多線程 —— 深入理解 volatile 的原理以及應用
    推薦閱讀:《java 多線程—線程怎麼來的》這一篇主要講解一下volatile的原理以及應用,想必看完這一篇之後,你會對volatile的應用原理以及使用邊界會有更深刻的認知。本篇主要內容:volatile 讀寫同步原理volatile重排序原則volatile應用關鍵字volatile是jvm提供的輕量級的同步機制,但它並不容易理解,而且在多數情況下用不到,被多數開發者拋棄並採用synchronized代替,synchronized屬於重度鎖,如果你對性能有高的要求,那麼同等情況下,變量聲明volatile會減小更少的同步開銷
  • 並發編程(一)| Volatile 與 Synchronized 深度解析
    Java 語言提供了 volatile,在某些情況下比鎖要更加的方便。如果一個欄位被聲明成 Volatile,Java 線程內存模型確保所有線程看到這個變量的值是一致的。1.4 為什麼 volatile++ 複合操作不具備原子性呢?為了保證處理器中緩存一致性,會將當前的處理器緩存設置為無效的,當處理器發現自己緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器對這個數據進行修改操作的時候,會重新從系統內存中把數據讀到處理器緩存裡。注意的是這裡的修改操作,是指的一個操作。可以知道自增操作是三個原子操作組合而成的複合操作。
  • 1分鐘讀懂java中的volatile關鍵字
    注意,volatile不具備原子性,這是volatile與java中的synchronized、java.util.concurrent.locks.Lock最大的功能差異,這一點在面試中也是非常容易問到的點。
  • 我說:volatile 是輕量級的 synchronized,面試官讓我回去等通知
    volatile 是並發編程的重要組成部分,也是面試常被問到的問題之一。不要向小強那樣,因為一句:volatile 是輕量級的 synchronized,而與期望已久的大廠失之交臂。volatile 有兩大特性:保證內存的可見性和禁止指令重排序。那什麼是可見性和指令重排呢?接下來我們一起來看。
  • 可惜了,面試敗在了volatile關鍵字上,直擊痛點搞懂volatile
    當然,想要適應社會的進步,程式設計師也要不斷的給自己充電,但人能忘本,基礎知識還是要學紮實的。這不,有位同學就來找我訴苦了,前兩次面試都挺順利的,到了三面竟然栽在了volatile關鍵字上。i = 0;public synchronized int getI(){return i;}public synchronized void setI(int j){this.i = j;}public void
  • Java並發編程徹底搞懂volatile關鍵字
    背景Java線程控制中常用的兩個關鍵字:synchronized、volatile因上篇文章《程式設計師眼中的Synchronized同步鎖》對synchronized總結volatile和synchronized兩者之間比較:關鍵字volatile是線程同步的輕量級實現,所以volatile性能肯定比synchronized要好;
  • Java程式設計師面試必備:Volatile全方位解析
    ,總線,MESI協議,嗅探技術) 4.Java內存模型(JMM) 5.並發編程的3個特性(原子性、可見性、有序性、happen-before、as-if-serial、指令重排) 6.volatile的底層原理(如何保證可見性,如何保證指令重排,內存屏障) 7.volatile的典型場景(狀態標誌,DCL單例模式) 8.volatile常見面試題
  • 如何理解volatile關鍵字
    面試中常常會問道,說說你對volatile的理解?在講解之前,我們先來了解一個Java並發編程中可見性的問題。這時候只要給變量data定義時增加一個volatile ,就可以解決多個線程之間變量可見性的問題。
  • 面試官最愛的 volatile 關鍵字,這些問題你都搞懂了沒?
    volatile關鍵字基本介紹volatile可以看成是synchronized的一種輕量級的實現,但volatile並不能完全代替synchronized,volatile有synchronized可見性的特性,但沒有synchronized原子性的特性。
  • Java中volatile關鍵字概覽
    接下來為大家介紹兩種方案:第一種是加鎖,第二種是使用volatile關鍵字解決方案加鎖// main方法while(true) {synchronized (t) {if(t.isFlag()){System.out.println("主線程進入循環執行
  • 死磕Volatile底層源碼,這篇就足矣
    不知大家有沒有發現沒有,平時在練習或者學習Java編程時,volatile關鍵字幾乎沒有用到過,而平時在看開源項目時,經常會看某些類變量定義為volatile,今天我們來解釋volatile的重要性。閱讀導航一、volatile關鍵字的作用二、volatile如何保證可見性?
  • 再有人問你volatile是什麼,把這篇文章也發給他.
    的用法、原理以及特性。下表描述了和volatile有關的指令重排禁止行為:從上表我們可以看出:當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。這個規則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之後。當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。
  • Java的synchronized 能防止指令重排序嗎?
    「二胖:」 這不就是要考我 synchronized 和volatile 這個我擅長啊,我特意背過的,synchronized 是java提供的一個關鍵字它主要能保證原子性、有序性它的底層主要是通過Monitor來實現的。volatile也是java的一個關鍵字它的主要作用是可以保證可見性。。。。此處省略1000字。
  • 線程中volatile實際場景應用(代碼示例)不看你會後悔的
    前言:上一篇文章主要介紹了volatile的概念以及使用場景,但是沒有具體展示代碼示例,所謂不見真相就是不信嘛,或者看了概念也不是很理解,看下代碼可能就讓你豁然開朗了。例子1、一次性標誌,先看下沒有使用volatile會出現什麼問題
  • Java中 volatile 關鍵字的最全總結,趕快給自己查缺補漏吧!
    Java 語言包含兩種內在的同步機制:同步塊(或方法)和 volatile 變量,相比於synchronized(synchronized通常稱為重量級鎖),volatile更輕量級,因為它不會引起線程上下文的切換和調度。但是volatile 變量的同步性較差(有時它更簡單並且開銷更低),而且其使用也更容易出錯。
  • JMM、Volatile、重排序、happen-before原則
    這時候,你給ready變量,加上volatile後,再試試volatile boolean ready = false; 或volatile boolean [] ready = new boolean[3];你會發現,T2一執行,T1馬上跳出了循環。
  • volatile關鍵字詳解
    ;    }}class Mythread{    //不加volatile,主線程無法得知num的值發生了改變,從而陷入死循環    volatile int num = 0;    public void increment(){        ++num;    }}