1.什么是內存模型?
-
在多核系統中,處理器一般有一層或者多層的緩存,這些的緩存通過加速數據訪問(因為數據距離處理器更近)和降低共享內存在總線上的通訊(因為本地緩存能夠滿足許多內存操作)來提高CPU性能。緩存能夠大大提升性能,但是它們也帶來了許多挑戰。例如,當兩個CPU同時檢查相同的內存地址時會發生什么?在什么樣的條件下它們會看到相同的值?
-
在處理器層面上,內存模型定義了一個充要條件,“讓當前的處理器可以看到其他處理器寫入到內存的數據”以及“其他處理器可以看到當前處理器寫入到內存的數據”。有些處理器有很強的內存模型(strong memory model),能夠讓所有的處理器在任何時候任何指定的內存地址上都可以看到完全相同的值。而另外一些處理器則有較弱的內存模型(weaker memory model),在這種處理器中,必須使用內存屏障(一種特殊的指令)來刷新本地處理器緩存并使本地處理器緩存無效,目的是為了讓當前處理器能夠看到其他處理器的寫操作或者讓其他處理器能看到當前處理器的寫操作。這些內存屏障通常在lock和unlock操作的時候完成。內存屏障在高級語言中對程序員是不可見的。
-
在強內存模型下,有時候編寫程序可能會更容易,因為減少了對內存屏障的依賴。但是即使在一些最強的內存模型下,內存屏障仍然是必須的。設置內存屏障往往與我們的直覺并不一致。近來處理器設計的趨勢更傾向于弱的內存模型,因為弱內存模型削弱了緩存一致性,所以在多處理器平臺和更大容量的內存下可以實現更好的可伸縮性
-
“一個線程的寫操作對其他線程可見”這個問題是因為編譯器對代碼進行重排序導致的。例如,只要代碼移動不會改變程序的語義,當編譯器認為程序中移動一個寫操作到后面會更有效的時候,編譯器就會對代碼進行移動。如果編譯器推遲執行一個操作,其他線程可能在這個操作執行完之前都不會看到該操作的結果,這反映了緩存的影響。
-
此外,寫入內存的操作能夠被移動到程序里更前的時候。在這種情況下,其他的線程在程序中可能看到一個比它實際發生更早的寫操作。所有的這些靈活性的設計是為了通過給編譯器,運行時或硬件靈活性使其能在最佳順序的情況下來執行操作。在內存模型的限定之內,我們能夠獲取到更高的性能。
-
Java內存模型描述了在多線程代碼中哪些行為是合法的,以及線程如何通過內存進行交互。它描述了“程序中的變量“ 和 ”從內存或者寄存器獲取或存儲它們的底層細節”之間的關系。Java內存模型通過使用各種各樣的硬件和編譯器的優化來正確實現以上事情。
-
Java包含了幾個語言級別的關鍵字,包括:volatile, final以及synchronized,目的是為了幫助程序員向編譯器描述一個程序的并發需求。Java內存模型定義了volatile和synchronized的行為,更重要的是保證了同步的java程序在所有的處理器架構下面都能正確的運行。
2.其他語言,像C++,也有內存模型嗎?
- 大部分其他的語言,像C和C++,都沒有被設計成直接支持多線程。這些語言對于發生在編譯器和處理器平臺架構的重排序行為的保護機制會嚴重的依賴于程序中所使用的線程庫(例如pthreads),編譯器,以及代碼所運行的平臺所提供的保障。
3.Java內存模型FAQ(三)JSR133是什么?
- 從1997年以來,人們不斷發現Java語言規范的17章定義的Java內存模型中的一些嚴重的缺陷。這些缺陷會導致一些使人迷惑的行為(例如final字段會被觀察到值的改變)和破壞編譯器常見的優化能力。
- Java內存模型是一個雄心勃勃的計劃,它是編程語言規范第一次嘗試合并一個能夠在各種處理器架構中為并發提供一致語義的內存模型。不過,定義一個既一致又直觀的內存模型遠比想象要更難。JSR133為Java語言定義了一個新的內存模型,它修復了早期內存模型中的缺陷。為了實現JSR133,final和volatile的語義需要重新定義。
- 完整的語義見:?Java內存模型
- 但是正式的語義不是小心翼翼的,它是令人驚訝和清醒的,目的是讓人意識到一些看似簡單的概念(如同步)其實有多復雜。幸運的是,你不需要懂得這些正式語義的細節——JSR133的目的是創建一組正式語義,這些正式語義提供了volatile、synchronzied和final如何工作的直觀框架。
JSR 133的目標包含了
- 保留已經存在的安全保證(像類型安全)以及強化其他的安全保證。例如,變量值不能憑空創建:線程觀察到的每個變量的值必須是被其他線程合理的設置的。
- 正確同步的程序的語義應該盡量簡單和直觀。
- 應該定義未完成或者未正確同步的程序的語義,主要是為了把潛在的安全危害降到最低。
- 程序員應該能夠自信的推斷多線程程序如何同內存進行交互的。
- 能夠在現在許多流行的硬件架構中設計正確以及高性能的JVM實現。
- 應該能提供安全地初始化的保證。如果一個對象正確的構建了 (意思是它的引用沒有在構建的時候逸出,那么所有能夠看到這個對象的引用的線程,在不進行同步的情況下,也將能看到在構造方法中中設置的final字段的值。
- 應該盡量不影響現有的代碼。
4.重排序意味著什么?
- 在很多情況下,訪問一個程序變量(對象實例字段,類靜態字段和數組元素)可能會使用不同的順序執行,而不是程序語義所指定的順序執行。編譯器能夠自由的以優化的名義去改變指令順序。在特定的環境下,處理器可能會次序顛倒的執行指令。數據可能在寄存器,處理器緩沖區和主內存中以不同的次序移動,而不是按照程序指定的順序。這里主要是因為CPU有緩存且CPU命令是以流水線形式執行。
- 例如,如果一個線程寫入值到字段a,然后寫入值到字段b,而且b的值不依賴于a的值,那么,處理器就能夠自由的調整它們的執行順序,而且緩沖區能夠在a之前刷新b的值到主內存。有許多潛在的重排序的來源,例如編譯器,JIT以及緩沖區。
- 編譯器,運行時和硬件被期望一起協力創建好像是順序執行的語義的假象,這意味著在單線程的程序中,程序應該是不能夠觀察到重排序的影響的。但是,重排序在沒有正確同步了的多線程程序中開始起作用,在這些多線程程序中,一個線程能夠觀察到其他線程的影響,也可能檢測到其他線程將會以一種不同于程序語義所規定的執行順序來訪問變量。
- 大部分情況下,一個線程不會關注其他線程正在做什么,但是當它需要關注的時候,這時候就需要同步了。
5.舊的內存模型有什么問題?
-
舊的內存模型中有幾個嚴重的問題。這些問題很難理解,因此被廣泛的違背。例如,舊的存儲模型在許多情況下,不允許JVM發生各種重排序行為。舊的內存模型中讓人產生困惑的因素造就了JSR-133規范的誕生。
-
例如,一個被廣泛認可的概念就是,如果使用final字段,那么就沒有必要在多個線程中使用同步來保證其他線程能夠看到這個字段的值。盡管這是一個合理的假設和明顯的行為,也是我們所期待的結果。實際上,在舊的內存模型中,我們想讓程序正確運行起來卻是不行的。在舊的內存模型中,final字段并沒有同其他字段進行區別對待——這意味著同步是保證所有線程看到一個在構造方法中初始化的final字段的唯一方法。結果——如果沒有正確同步的話,對一個線程來說,它可能看到一個字段的默認值,然后在稍后的時間里,又能夠看到構造方法中設置的值。這意味著,一些不可變的對象,例如String,能夠改變它們值——這實在很讓人郁悶。這里是因為String類型默認null,而初始化是我們自定義的值,也就是兩種不同的值,仿佛String是可變的一樣
-
舊的內存模型允許volatile變量的寫操作和非volaitle變量的讀寫操作一起進行重排序,這和大多數的開發人員對于volatile變量的直觀感受是不一致的,因此會造成迷惑。
-
最后,我們將看到的是,程序員對于程序沒有被正確同步的情況下將會發生什么的直觀感受通常是錯誤的。JSR-133的目的之一就是要引起這方面的注意。
6.沒有正確同步的含義是什么?
-
沒有正確同步的代碼對于不同的人來說可能會有不同的理解。在Java內存模型這個語義環境下,我們談到“沒有正確同步”,我們的意思是:
- 一個線程中有一個對變量的寫操作,
- 另外一個線程對同一個變量有讀操作,
- 而且寫操作和讀操作沒有通過同步來保證順序。
-
當這些規則被違反的時候,我們就說在這個變量上有一個“數據競爭”(data race)。一個有數據競爭的程序就是一個沒有正確同步的程序。
7.同步會干些什么呢?
-
同步有幾個方面的作用。最廣為人知的就是互斥 ——一次只有一個線程能夠獲得一個監視器,因此,在一個監視器上面同步意味著一旦一個線程進入到監視器保護的同步塊中,其他的線程都不能進入到同一個監視器保護的塊中間,除非第一個線程退出了同步塊。
-
但是同步的含義比互斥更廣。同步保證了一個線程在同步塊之前或者在同步塊中的一個內存寫入操作以可預知的方式對其他有相同監視器的線程可見。當我們退出了同步塊,我們就釋放了這個監視器,這個監視器有刷新緩沖區到主內存的效果,因此該線程的寫入操作能夠為其他線程所見。在我們進入一個同步塊之前,我們需要獲取監視器,監視器有使本地處理器緩存失效的功能,因此變量會從主存重新加載,于是其它線程對共享變量的修改對當前線程來說就變得可見了。
-
依據緩存來討論同步,可能聽起來這些觀點僅僅會影響到多處理器的系統。但是,重排序效果能夠在單一處理器上面很容易見到。對編譯器來說,在獲取之前或者釋放之后移動你的代碼是不可能的。當我們談到在緩沖區上面進行的獲取和釋放操作,我們使用了簡述的方式來描述大量可能的影響。
-
新的內存模型語義在內存操作(讀取字段,寫入字段,鎖,解鎖)以及其他線程的操作(start 和 join)中創建了一個部分排序,在這些操作中,一些操作被稱為happen before其他操作。當一個操作在另外一個操作之前發生,第一個操作保證能夠排到前面并且對第二個操作可見。這些排序的規則如下:
- 線程中的每個操作happens before該線程中在程序順序上后續的每個操作。
- 解鎖一個監視器的操作happens before隨后對相同監視器進行鎖的操作。
- 對volatile字段的寫操作happens before后續對相同volatile字段的讀取操作。
- 線程上調用start()方法happens before這個線程啟動后的任何操作。
- 一個線程中所有的操作都happens before從這個線程join()方法成功返回的任何其他線程。
(注意思是其他線程等待一個線程的join()方法完成,那么,這個線程中的所有操作happens before其他線程中的所有操作)
補充:happens-before 偏序關系(離散數學)
偏序又分非嚴格偏序(自反偏序)與嚴格偏序(反自反偏序)
自反偏序 給定集合S,“≤”是S上的二元關系,若“≤”滿足:
- 自反性:?a∈S,有a≤a
- 反對稱性:?a,b∈S,a≤b且b≤a,則a=b
- 傳遞性:?a,b,c∈S,a≤b且b≤c,則a≤c
反自反偏序 給定集合S,“<”是S上的二元關系,若“<”滿足:
- 反自反性:?a∈S,有a≮a
- 非對稱性:?a,b∈S,a<b ? b≮a
- 傳遞性:?a,b,c∈S,a<b且b<c,則a<c
注意:這里的符號不是簡單的表示大小,與JMM中的happens-before不是表示時間的前后是一樣,理解偏序是關鍵的一步。
延申:
嚴格偏序與有向無環圖(dag)有直接的對應關系。一個集合上的嚴格偏序的關系圖就是一個有向無環圖。其傳遞閉包是它自己。
有向無環圖的判斷:
- 深度優先遍歷
- 拓撲排序
- 求關鍵路徑的前提是無環,能不能判斷嚴格來說也可以
更加通俗具體的happens-before講解
注:《深入理解Java虛擬機》中曾指出,happens-before規則誕生之前是使用八大具體的原子操作定義發生的順序
這意味著:任何內存操作,這個內存操作在退出一個同步塊前對一個線程是可見的,對任何線程在它進入一個被相同的監視器保護的同步塊后都是可見的,因為所有內存操作happens before釋放監視器以及釋放監視器happens before獲取監視器。 其他如下模式的實現被一些人用來強迫實現一個內存屏障的,不會生效:
synchronized (new Object()) {}
這段代碼其實不會執行任何操作,你的編譯器會把它完全移除掉,因為編譯器知道沒有其他的線程會使用相同的監視器進行同步。要看到其他線程的結果,你必須為一個線程建立happens before關系。
- 重點注意:對兩個線程來說,為了正確建立happens before關系而在相同監視器上面進行同步是非常重要的。以下觀點是錯誤的:當線程A在對象X上面同步的時候,所有東西對線程A可見,線程B在對象Y上面進行同步的時候,所有東西對線程B也是可見的。釋放監視器和獲取監視器必須匹配(也就是說要在相同的監視器上面完成這兩個操作),否則,代碼就會存在“數據競爭”。
8.final字段如何改變它們的值
-
我們可以通過分析String類的實現具體細節來展示一個final變量是如何可以改變的。
-
String對象包含了三個字段:一個character數組,一個數組的offset和一個length。實現String類的基本原理為:它不僅僅擁有character數組,而且為了避免多余的對象分配和拷貝,多個String和StringBuffer對象都會共享相同的character數組。因此,String.substring()方法能夠通過改變length和offset,而共享原始的character數組來創建一個新的String。對一個String來說,這些字段都是final型的字段。
String s1 = "/usr/tmp"; String s2 = s1.substring(4); 字符串s2的offset的值為4,length的值為4。但是,在舊的內存模型下,對其他線程來說,看到offset擁有默認的值0是可能的,而且 稍后一點時間會看到正確的值4,好像字符串的值從“/usr”變成了“/tmp”一樣。
舊的Java內存模型允許這些行為,部分JVM已經展現出這樣的行為了。在新的Java內存模型里面,這些是非法的。
9.在新的Java內存模型中,final字段是如何工作的?
-
一個對象的final字段值是在它的構造方法里面設置的。假設對象被正確的構造了,一旦對象被構造,在構造方法里面設置給final字段的的值在沒有同步的情況下對所有其他的線程都會可見。另外,引用這些final字段的對象或數組都將會看到final字段的最新值。
-
對一個對象來說,被正確的構造是什么意思呢?簡單來說,它意味著這個正在構造的對象的引用在構造期間沒有被允許逸出。(參見安全構造技術)。換句話說,不要讓其他線程在其他地方能夠看見一個構造期間的對象引用。不要指派給一個靜態字段,不要作為一個listener注冊給其他對象等等。這些操作應該在構造方法之后完成,而不是構造方法中來完成。
class FinalFieldExample {final int x;int y;static FinalFieldExample f;public FinalFieldExample() {x = 3;
y = 4;}static void writer() {f = new FinalFieldExample();}static void reader() {if (f != null) {int i = f.x;int j = f.y;}}
}
上面的類展示了final字段應該如何使用。一個正在執行reader方法的線程保證看到f.x的值為3,因為它是final字段。它不保證看到f.y的值為4,因為f.y不是final字段。如果FinalFieldExample的構造方法像這樣:
public FinalFieldExample() { // bad!x = 3;y = 4;// bad construction - allowing this to escapeglobal.obj = this;
}
那么,從global.obj中讀取this的引用線程不會保證讀取到的x的值為3。
- 能夠看到字段的正確的構造值固然不錯,但是,如果字段本身就是一個引用,那么,你還是希望你的代碼能夠看到引用所指向的這個對象(或者數組)的最新值。如果你的字段是final字段,那么這是能夠保證的。因此,當一個final指針指向一個數組,你不需要擔心線程能夠看到引用的最新值卻看不到引用所指向的數組的最新值。重復一下,這兒的“正確的”的意思是“對象構造方法結尾的最新的值”而不是“最新可用的值”。
- 現在,在講了如上的這段之后,如果在一個線程構造了一個不可變對象之后(對象僅包含final字段),你希望保證這個對象被其他線程正確的查看,你仍然需要使用同步才行。例如,沒有其他的方式可以保證不可變對象的引用將被第二個線程看到。使用final字段的程序應該仔細的調試,這需要深入而且仔細的理解并發在你的代碼中是如何被管理的。
- 如果你使用JNI來改變你的final字段,這方面的行為是沒有定義的。
10.volatile是干什么用的?
- Volatile字段是用于線程間通訊的特殊字段。每次讀volatile字段都會看到其它線程寫入該字段的最新值;實際上,程序員之所以要定義volatile字段是因為在某些情況下由于緩存和重排序所看到的陳舊的變量值是不可接受的。編譯器和運行時禁止在寄存器里面分配它們。它們還必須保證,在它們寫好之后,它們被從緩沖區刷新到主存中,因此,它們立即能夠對其他線程可見。相同地,在讀取一個volatile字段之前,緩沖區必須失效,因為值是存在于主存中而不是本地處理器緩沖區。在重排序訪問volatile變量的時候還有其他的限制。
- 在舊的內存模型下,訪問volatile變量不能被重排序,但是,它們可能和訪問非volatile變量一起被重排序。這破壞了volatile字段從一個線程到另外一個線程作為一個信號條件的手段。
- 在新的內存模型下,volatile變量仍然不能彼此重排序。和舊模型不同的時候,volatile周圍的普通字段的也不再能夠隨便的重排序了。寫入一個volatile字段和釋放監視器有相同的內存影響,而且讀取volatile字段和獲取監視器也有相同的內存影響。事實上,因為新的內存模型在重排序volatile字段訪問上面和其他字段(volatile或者非volatile)訪問上面有了更嚴格的約束。當線程A寫入一個volatile字段f的時候,如果線程B讀取f的話 ,那么對線程A可見的任何東西都變得對線程B可見了。 如下例子展示了volatile字段應該如何使用:
class VolatileExample {int x = 0;volatile boolean v = false;public void writer() {x = 42;v = true;}public void reader() {if (v == true) {//uses x - guaranteed to see 42.}}
}
假設一個線程叫做“writer”,另外一個線程叫做“reader”。對變量v的寫操作會等到變量x寫入到內存之后,然后讀線程就可以看見v的值。因此,如果reader線程看到了v的值為true,那么,它也保證能夠看到在之前發生的寫入42這個操作。而這在舊的內存模型中卻未必是這樣的。如果v不是volatile變量,那么,編譯器可以在writer線程中重排序寫入操作,那么reader線程中的讀取x變量的操作可能會看到0。
- 實際上,volatile的語義已經被加強了,已經快達到同步的級別了。為了可見性的原因,每次讀取和寫入一個volatile字段已經像一個半同步操作了
- 重點注意:對兩個線程來說,為了正確的設置happens-before關系,訪問相同的volatile變量是很重要的。以下的結論是不正確的:當線程A寫volatile字段f的時候,線程A可見的所有東西,在線程B讀取volatile的字段g之后,變得對線程B可見了。釋放操作和獲取操作必須匹配(也就是在同一個volatile字段上面完成)。
11.新的內存模型是否修復了雙重鎖檢查問題?
- 臭名昭著的雙重鎖檢查(也叫多線程單例模式)是一個騙人的把戲,它用來支持lazy初始化,同時避免過度使用同步。 在非常早的JVM中,同步非常慢,開發人員非常希望刪掉它。雙重鎖檢查代碼如下:
// double-checked-locking - don't do this!
private static Something instance = null;public Something getInstance() {if (instance == null) {synchronized (this) {if (instance == null)instance = new Something();}}return instance;
}
- 這看起來好像非常聰明——在公用代碼中避免了同步。這段代碼只有一個問題 —— 它不能正常工作。為什么呢?最明顯的原因是,初始化實例的寫入操作和實例字段的寫入操作能夠被編譯器或者緩沖區重排序,重排序可能會導致返回部分構造的一些東西。就是我們讀取到了一個沒有初始化的對象。這段代碼還有很多其他的錯誤,以及為什么對這段代碼的算法修正是錯誤的。在舊的java內存模型下沒有辦法修復它。更多深入的信息可參見:Double-checkedlocking: Clever but broken and The “DoubleChecked Locking is broken” declaration
- 許多人認為使用volatile關鍵字能夠消除雙重鎖檢查模式的問題。在1.5的JVM之前,volatile并不能保證這段代碼能夠正常工作(因環境而定)。在新的內存模型下,實例字段使用volatile可以解決雙重鎖檢查的問題,因為在構造線程來初始化一些東西和讀取線程返回它的值之間有happens-before關系。
- 然后,對于喜歡使用雙重鎖檢查的人來說(我們真的希望沒有人這樣做),仍然不是好消息。雙重鎖檢查的重點是為了避免過度使用同步導致性能問題。從java1.0開始,不僅同步會有昂貴的性能開銷,而且在新的內存模型下,使用volatile的性能開銷也有所上升,幾乎達到了和同步一樣的性能開銷。因此,使用雙重鎖檢查來實現單例模式仍然不是一個好的選擇。(修訂—在大多數平臺下,volatile性能開銷還是比較低的)。 使用IODH來實現多線程模式下的單例會更易讀:
// 還可以使用枚舉,靜態內部類實現
private static class LazySomethingHolder {public static Something something = new Something();
}public static Something getInstance() {return LazySomethingHolder.something;
}
這段代碼是正確的,因為初始化是由static字段來保證的。如果一個字段設置在static初始化中,對其他訪問這個類的線程來說是能正確的保證它的可見性的。
12.為什么我需要關注Java內存模型?
- 為什么你需要關注java內存模型?并發程序的bug非常難找。它們經常不會在測試中發生,而是直到你的程序運行在高負荷的情況下才發生,非常難于重現和跟蹤。你需要花費更多的努力提前保證你的程序是正確同步的。這不容易,但是它比調試一個沒有正確同步的程序要容易的多。
?
?