做者 | 李健青java
來源 | 碼哥字節(ID:MageByte)程序員
在面試、併發編程、一些開源框架中老是會遇到 volatile
與 synchronized
。synchronized
如何保證併發安全?volatile
語義的內存可見性指的是什麼?這其中又跟 JMM 有什麼關係,在併發編程中 JMM 的做用是什麼,爲何須要 JMM?與 JVM 內存結構有什麼區別?
web
「碼哥字節」 總結出裏面的核心知識點以及面試重點,圖文並茂無畏面試與併發編程,全面提高併發編程內功!面試
-
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 虛擬機的運行時區域有關; -
Java 內存模型和 Java 的併發編程有關。
JVM 內存結構
Java 代碼是運行在虛擬機上的,咱們寫的 .java 文件首先會被編譯成 .class 文件,接着被 JVM 虛擬機加載,而且根據不一樣操做系統平臺翻譯成對應平臺的機器碼運行,以下如所示:
從圖中能夠看到,有了 JVM 這個抽象層以後,Java 就能夠實現跨平臺了。JVM 只須要保證可以正確加載 .class 文件,就能夠運行在諸如 Linux、Windows、MacOS 等平臺上了。
JVM 經過 Java 類加載器加載 javac 編譯出來的 class 文件,經過執行引擎解釋執行或者 JIT 即時編譯調用才調用系統接口實現程序的運行。
而虛擬機在運行程序的時候會把內存劃分爲不一樣的數據區域,不一樣區域負責不一樣功能,隨着 Java 的發展,內存佈局也在調整之中,以下是 Java 8 以後的佈局狀況,移除了永久代,使用 Mataspace 代替,因此 -XX:PermSize -XX:MaxPermSize
等參數變沒有意義。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 指令則將操做數 一、2 出棧相加結果入棧。方法結束後「棧幀」出棧,返回結果結束。
每一個棧幀包含四個區域:
-
局部變量表:基本數據類型、對象引用、retuenAddress 指向字節碼的指針; -
操做數棧 -
動態鏈接 -
返回地址
這裏有一個重要的地方,敲黑板了:
-
實際上有兩層含義的棧,第一層是「棧幀」對應方法;第二層對應着方法的執行,對應着操做數棧。 -
全部的字節碼指令,都會被抽象成對棧的入棧與出棧操做。執行引擎只須要傻瓜式的按順序執行,就能夠保證它的正確性。
每一個線程擁有一個「虛擬機棧」,每一個「虛擬機棧」擁有多個「棧幀」,而棧幀則對應着一個方法。每一個「棧幀」包含局部變量表、操做數棧、動態連接、方法返回地址。方法運行結束則意味着該「棧幀」出棧。
以下圖所示:
方法區(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 都有可能出於優化性能的目的,並不能保證各個語句執行的前後順序與輸入的代碼順序一致,而是調整了順序,這就是指令重排序。
重排序優點
可能咱們會疑問:爲何要指令重排序?有啥用?
以下圖:
通過重排序以後,狀況以下圖所示:
重排序後,對 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 定義了線程與主內存之間的抽象關係:
-
線程之間的共享變量存儲在主內存(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(解鎖) 做用於主內存,它將釋放獨佔狀態。
如上圖所示,把一個變量數據從主內存複製到工做內存,要順序執行 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 是非空的可是還沒初始化徹底。
面試官可能會問你,「爲何要 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 狀態的線程隊列 -
_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 對象頭
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/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 種數據:
鎖標誌位的表示意義:
-
鎖標識 lock=00 表示輕量級鎖 -
鎖標識 lock=10 表示重量級鎖 -
偏向鎖標識 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 開始,鎖一共會有四種狀態,鎖的狀態根據競爭激烈程度從低到高分別是: 無鎖狀態->偏向鎖狀態->輕量級鎖狀態->重量級鎖狀態。這幾個狀態會隨着鎖競爭的狀況逐步升級。爲了提升得到鎖和釋放鎖的效率,鎖能夠升級可是不能降級。
同時爲了提高性能,還帶來了鎖消除、鎖粗化、自旋鎖和自適應自旋鎖…...
鑑於篇幅緣由關於線程狀態、鎖的同步過程「碼哥字節」下回分解,分別介紹加鎖、解鎖以及鎖升級過程當中 Mark Word 如何變化。如何正確使用 wait()、 notify() 實現生產-消費模式,講解如何避免常見的易錯知識點,防止掉坑。
敬請期待......
讀者朋友能夠加我微信備註 「加羣」加入「碼哥字節」專屬技術讀者羣,一塊兒成長。羣裏還會分享「阿里」和「騰訊」內推,歡迎大神到碗裏來。
往期推薦
點個在看 你最好看
本文分享自微信公衆號 - 碼哥字節(MageByte)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。