(Java Memory Model——Java內存模型)。什麼是JMM呢?JMM是一個抽象概念,它並不存在。Java虛擬機規範中試圖定義一種Java內存模型(JMM)來屏蔽掉各類硬件和操做系統的內存訪問差別,以實現讓Java程序在各類平臺下都能達到一致的內存訪問效果。在此以前,主流程序語言(如C/C++等)直接使用物理硬件和操做系統的內存模型,所以,會因爲不一樣平臺的內存模型的差別,有可能致使程序在一套平臺上併發徹底正常,而在另外一套平臺上併發訪問卻常常出錯,所以在某些場景就必須針對不一樣的平臺來編寫程序。html
之間的通訊由JMM來控制,JMM決定一個線程共享變量的寫入什麼時候對另外一個線程可見。JMM保證若是程序是正確同步的,那麼程序的執行將具備順序一致性。從抽象的角度看,JMM定義了線程和主內存之間的抽象關係:線程之間的共享變量(實例域、靜態域和數據元素)存儲在主內存(
Main Memory
)中,每一個線程都有一個私有的本地內存(Local Memory
),本地內存中存儲了該線程以讀/寫共享變量的副本(局部變量、方法定義參數和異常處理參數是不會在線程之間共享,它們存儲在線程的本地內存中)。從物理角度上看,主內存僅僅是虛擬機內存的一部分,與物理硬件的主內存名字同樣,二者能夠互相類比;而本地內存,可與處理器高速緩存類比。Java內存模型的抽象示意圖如圖所示: 程序員
關於主內存與本地內存之間具體的交互協議,即一個變量如何從主內存拷貝到本地內存、如何從本地內存同步回主內存之類的實現細節,JMM中定義瞭如下8種操做來完成,虛擬機實現時必須保證下面說起的每種操做都是原子的、不可再分的(對於double和long類型的遍從來說,load、store、read和write操做在某些平臺上容許有例外):面試
若是要把一個變量從主內存模型複製到本地內存,那就要順序的執行read和load操做,若是要把變量從本地內存同步回主內存,就要順序的執行store和write操做。注意,Java內存模型只要求上述兩個操做必須按順序執行,而沒有保證是連續執行。也就是說read與load之間、store與write之間是可插入其餘指令的,如對主內存中的變量a、b進行訪問時,一種可能出現的順序是read a read b、load b、load a。編程
內存屏障是一組處理器指令(前面的8個操做指令),用於實現對內存操做的順序限制。包括LoadLoad, LoadStore, StoreLoad, StoreStore共4種內存屏障。內存屏障存在的意義是什麼呢?它是在Java編譯器生成指令序列的適當位置插入內存屏障指令來禁止特定類型的處理器重排序,從而讓程序按咱們預想的流程去執行,內存屏障是與相應的內存重排序相對應的。JMM把內存屏障指令分爲4類:緩存
若是兩個操做訪問同一個變量,且這兩個操做中有一個爲寫操做,此時這兩個操做之間就存在數據依賴性。數據依賴性分3種類型:寫後讀、寫後寫、讀後寫。這3種狀況,只要重排序兩個操做的執行順序,程序的執行結果就會被改變。編譯器和處理器可能對操做進行重排序。而它們進行重排序時,會遵照數據依賴性,不會改變數據依賴關係的兩個操做的執行順序。bash
名稱 | 代碼 | 示例說明 |
---|---|---|
寫後讀 | a = 1;b = a; | 寫一個變量以後,再讀這個位置。 |
寫後寫 | a = 1;a = 2; | 寫一個變量以後,再寫這個變量。 |
讀後寫 | a = b;b = 1; | 讀一個變量以後,再寫這個變量。 |
這裏所說的數據依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操做,不一樣處理器之間和不一樣線程之間的數據依賴性不被編譯器和處理器考慮。多線程
順序一致性內存模型是一個理論參考模型,在設計的時候,處理器的內存模型和編程語言的內存模型都會以順序一致性內存模型做爲參照。它有兩個特性:併發
從順序一致性模型中,咱們能夠知道程序全部操做徹底按照程序的順序串行執行。而在JMM中,臨界區內的代碼能夠重排序(但JMM不容許臨界區內的代碼「逸出」到臨界區外,那樣就破壞監視器的語義)。app
假設這兩個線程使用監視器鎖來正確同步:A線程的3個操做執行後釋放監視器鎖,隨後B線程獲取同一個監視器鎖。 編程語言
假設這兩個線程沒有作同步: ![]()
![]()
JMM會在退出臨界區和進入臨界區這兩個關鍵時間點作一些特別處理,使得線程在這兩個時間點具備與順序一致性模型相同的內存視圖。雖然線程A在臨界區內作了重排序,但因爲監視器互斥執行的特性,這裏的線程B根本沒法「觀察」到線程A在臨界區內的重排序。這種重排序既提升了執行效率,又沒有改變程序的執行結果。像單例模型[靜態內部類模型]的類初始化解決方案就是採用了這個思想。
as-if-serial的意思是無論怎麼重排序,(單線程)程序的執行結果不能改變。編譯器、runtime和處理器都必須遵照as-if-serial語義。爲了遵照as-if-serial語義,編譯器和處理器不會對存在數據依賴關係的操做作重排序。
as-if-serial語義把單線程程序保護了起來,遵照as-if-serial語義的編譯器、runtime和處理器共同爲編寫單線程程序的程序員建立了一個幻覺:單線程程序是按程序的順序來執行的。as-if-serial語義使單線程程序員無需擔憂重排序會干擾他們,也無需擔憂內存可見性問題。
happens-before是JMM最核心的概念。從JDK5開始,Java使用新的JSR-133內存模型,JSR-133 使用happens-before的概念闡述操做之間的內存可見性,若是一個操做執行的結果須要對另外一個操做可見,那麼這兩個操做之間必須存在happens-before關係。
happens-before規則以下:
終於談到咱們反覆說起的重排序了,重排序是指編譯器和處理器爲了優化程序性能而對指令序列進行從新排序的一種手段。重排序分3種類型。
JMM屬於語言級的內存模型,它確保在不一樣的編譯器和不一樣的處理器平臺之上,經過禁止特定類型的編譯器重排序和處理器重排序,爲程序員提供一致的內存可見性保證。
從JMM設計者的角度來講,在設計JMM時,須要考慮兩個關鍵因素:
JMM設計就須要在這二者之間做出協調。JMM對程序採起了不一樣的策略:
介紹完了這幾個基本概念,咱們不難推斷出JMM是圍繞着在併發過程當中如何處理原子性、可見性和有序性這三個特徵來創建的。
經過前面8個操做指令和happens-before原則介紹,也不難推斷出,volatile和synchronized兩個關鍵字來保證線程之間的有序性,volatile自己就包含了禁止指令重排序的語義,而synchronized則是由監視器法則得到。
也許你對volatile和CAS的底層實現原理不是很瞭解,這裏簡單介紹下它們的底層實現:
Java語言規範第三版對volatile的定義爲:Java編程語言容許線程訪問共享變量,爲了確保共享變量能被準確和一致性的更新,線程應該確保經過排他鎖單獨得到這個變量。若是一個字段被聲明爲volatile,Java內存模型確保這個全部線程看到這個值的變量是一致的。
而volatile是如何來保證可見性的呢?若是對聲明瞭volatile的變量進行寫操做,JVM就會向處理器發送一條Lock前綴的指令,將這個變量所在緩存行的數據寫回到系統內存(Lock指令會在聲言該信號期間鎖總線/緩存,這樣就獨佔了系統內存)。
可是,就算是寫回到內存,若是其餘處理器緩存的值仍是舊的,再執行計算操做就會有問題。因此,在多處理器下,爲了保證各個處理器的緩存是一致的,就會實現緩存一致性協議,每一個處理器經過嗅探在總線(注意處理器不直接跟系統內存交互,而是經過總線)上傳播的數據來檢查本身緩存的值是否是過時了,當處理器發現直接緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器對這個數據進行修改操做的時候,會從新從系統內存中把數據讀處處理器緩存裏。
CAS其實應用挺普遍的,咱們經常聽到的悲觀鎖樂觀鎖的概念,樂觀鎖(無鎖)指的就是CAS。
這裏只是簡單說下在併發的應用,所謂的樂觀併發策略,通俗的說,就是先進性操做,若是沒有其餘線程爭用共享數據,那操做就成功了,若是共享數據有爭用,產生了衝突,那就採起其餘的補償措施(最多見的補償措施就是不斷重試,治到成功爲止,這裏其實也就是自旋CAS的概念),這種樂觀的併發策略的許多實現都不須要把線程掛起,所以這種操做也被稱爲非阻塞同步。而CAS這種樂觀併發策略操做和衝突檢測這兩個步驟具有的原子性,是靠什麼保證的呢?硬件,硬件保證了一個從語義上看起來須要屢次操做的行爲只經過一條處理器指令就能完成。
也許你會存在疑問,爲何這種無鎖的方案通常會比直接加鎖效率更高呢?這裏其實涉及到線程的實現和線程的狀態轉換。實現線程主要有三種方式:使用內核線程實現、使用用戶線程實現和使用用戶線程加輕量級進程混合實現。而Java的線程實現則依賴於平臺使用的線程模型。至於狀態轉換,Java定義了6種線程狀態,在任意一個時間點,一個線程只能有且只有其中的一種狀態,這6種狀態分別是:新建、運行、無限期等待、限期等待、阻塞、結束。
Java的線程是映射到操做系統的原生線程之上的,若是要阻塞或喚醒一個線程,都須要操做系統來幫忙完成,這就須要從用戶態轉換到核心態中,所以狀態轉換須要耗費不少的處理器時間。對於簡單的同步塊(被synchronized修飾的方法),狀態轉換消耗的時間可能比用戶代碼執行的時間還要長。因此出現了這種優化方案,在操做系統阻塞線程之間引入一段自旋過程或一直自旋直到成功爲止。避免頻繁的切入到核心態之中。 可是這種方案其實也並不完美,在這裏就說下CAS實現原子操做的三大問題:
在構造函數內對一個final域的寫入,與隨後把這個被構造對象的引用賦值給一個引用變量,這兩個操做之間不能重排序。 初次讀一個包含final域的對象的引用,與隨後初次讀這個final域,這兩個操做之間不能重排序。
增長了以下規則:在構造函數內對一個final引用的對象的成員域的寫入,與隨後在構造函數外把這個被構造對象的引用賦值給一個引用變量,這兩個操做之間不能重排序。
一道面試題: [不使用volatile怎麼打破循環?]
public class TestThread implements Serializable {
public static void main(String[] args) throws InterruptedException {
Data data = new Data();
new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
data.add();
}).start();
while (data.num == 0) {
//怎麼打破 死循環
}
/**-----------------------無責任分割線1-----------------------------------------------*/
int i = 1;
while (data.num == 0) {
i = i++; //未觸發致使死循環
i = ++i;
}
/**-----------------------無責任分割線2-----------------------------------------------*/
while (data.num == 0) {
synchronized (TestThread.class) {
//同步鎖觸發線程切換 跳出循環
}
}
/**-----------------------無責任分割線3-----------------------------------------------*/
while (data.num == 0) {
Thread.yield();//線程讓步 跳出循環
}
/**-----------------------無責任分割線4-----------------------------------------------*/
while (data.num == 0) {
try {
Thread.sleep(0);//線程休眠讓出CPU 跳出循環
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**-----------------------無責任分割線5-----------------------------------------------*/
while (data.num == 0) {
System.out.println("");//println 有同步鎖 跳出循環
}
/**-----------------------無責任分割線6-----------------------------------------------*/
LongAdder longAdder = new LongAdder();
while (data.num == 0) {
longAdder.decrement();//cas自旋鎖 跳出循環
}
/**-----------------------無責任分割線7-----------------------------------------------*/
System.out.println("哈哈2");
}
static class Data {
volatile int num = 0;
public void add() {
this.num = 60;
}
}
}
複製代碼
本文摘(jie)抄(jian)自 鳴謝原文:從一個簡單的Java單例示例談談併發 JMM JUC