在知識星球中,有個小夥伴提了一個問題: 有一個關於JVM名詞定義的問題,說”JVM記憶體模型“,有人會說是關於JVM記憶體分佈(堆疊,方法區等)這些介紹,也有地方說(深入理解JVM虛擬機器)上說Java記憶體模型是JVM的抽象模型(主記憶體,本地記憶體)。這兩個到底怎麼區分啊?有必然關係嗎?比如主記憶體就是堆,本地記憶體就是棧,這種說法對嗎?
時間久了,我也把記憶體模型和記憶體結構給搞混了,所以抽了時間把JSR133規範中關於記憶體模型的部分重新看了下。
後來聽了好多人反饋:在面試的時候,有面試官會讓你解釋一下Java的記憶體模型,有些人解釋對了,結果面試官說不對,應該是堆啊、棧啊、方法區什麼的(這不是半吊子面試麼,自己概念都不清楚)
JVM中的堆啊、棧啊、方法區什麼的,是Java虛擬機器的記憶體結構,Java程式啟動後,會初始化這些記憶體的資料。
記憶體結構就是上圖中記憶體空間這些東西,而Java記憶體模型,完全是另外的一個東西。
什麼是記憶體模型
在多CPU的系統中,每個CPU都有多級快取,一般分為L1、L2、L3快取,因為這些快取的存在,提供了資料的訪問效能,也減輕了資料匯流排上資料傳輸的壓力,同時也帶來了很多新的挑戰,比如兩個CPU同時去操作同一個記憶體地址,會發生什麼?在什麼條件下,它們可以看到相同的結果?這些都是需要解決的。
所以在CPU的層面,記憶體模型定義了一個充分必要條件,保證其它CPU的寫入動作對該CPU是可見的,而且該CPU的寫入動作對其它CPU也是可見的,那這種可見性,應該如何實現呢?
有些處理器提供了強記憶體模型,所有CPU在任何時候都能看到記憶體中任意位置相同的值,這種完全是硬體提供的支援。
其它處理器,提供了弱記憶體模型,需要執行一些特殊指令(就是經常看到或者聽到的,memory barriers記憶體屏障),重新整理CPU快取的資料到記憶體中,保證這個寫操作能夠被其它CPU可見,或者將CPU快取的資料設定為無效狀態,保證其它CPU的寫操作對本CPU可見。通常這些記憶體屏障的行為由底層實現,對於上層語言的程式設計師來說是透明的(不需要太關心具體的記憶體屏障如何實現)。
前面說到的記憶體屏障,除了實現CPU之前的資料可見性之外,還有一個重要的職責,可以禁止指令的重排序。
這裡說的重排序可以發生在好幾個地方:編譯器、執行時、JIT等,比如編譯器會覺得把一個變數的寫操作放在最後會更有效率,編譯後,這個指令就在最後了(前提是隻要不改變程式的語義,編譯器、執行器就可以這樣自由的隨意優化),一旦編譯器對某個變數的寫操作進行優化(放到最後),那麼在執行之前,另一個執行緒將不會看到這個執行結果。
當然了,寫入動作可能被移到後面,那也有可能被挪到了前面,這樣的“優化”有什麼影響呢?這種情況下,其它執行緒可能會在程式實現“發生”之前,看到這個寫入動作(這裡怎麼理解,指令已經執行了,但是在程式碼層面還沒執行到)。通過記憶體屏障的功能,我們可以禁止一些不必要、或者會帶來負面影響的重排序優化,在記憶體模型的範圍內,實現更高的效能,同時保證程式的正確性。
下面看一個重排序的例子:
Class Reordering {
int x = 0, y = 0;
public void writer() {
x = 1;
y = 2;
}
public void reader() {
int r1 = y;
int r2 = x;
}
}
複製程式碼
假設這段程式碼有2個執行緒併發執行,執行緒A執行writer方法,執行緒B執行reader方法,執行緒B看到y的值為2,因為把y設定成2發生在變數x的寫入之後(程式碼層面),所以能斷定執行緒B這時看到的x就是1嗎?
當然不行! 因為在writer方法中,可能發生了重排序,y的寫入動作可能發在x寫入之前,這種情況下,執行緒B就有可能看到x的值還是0。
在Java記憶體模型中,描述了在多執行緒程式碼中,哪些行為是正確的、合法的,以及多執行緒之間如何進行通訊,程式碼中變數的讀寫行為如何反應到記憶體、CPU快取的底層細節。
在Java中包含了幾個關鍵字:volatile、final和synchronized,幫助程式設計師把程式碼中的併發需求描述給編譯器。Java記憶體模型中定義了它們的行為,確保正確同步的Java程式碼在所有的處理器架構上都能正確執行。
synchronization 可以實現什麼
Synchronization有多種語義,其中最容易理解的是互斥,對於一個monitor物件,只能夠被一個執行緒持有,意味著一旦有執行緒進入了同步程式碼塊,那麼其它執行緒就不能進入直到第一個進入的執行緒退出程式碼塊(這因為都能理解)。
但是更多的時候,使用synchronization並非單單互斥功能,Synchronization保證了執行緒在同步塊之前或者期間寫入動作,對於後續進入該程式碼塊的執行緒是可見的(又是可見性,不過這裡需要注意是對同一個monitor物件而言)。在一個執行緒退出同步塊時,執行緒釋放monitor物件,它的作用是把CPU快取資料(本地快取資料)重新整理到主記憶體中,從而實現該執行緒的行為可以被其它執行緒看到。在其它執行緒進入到該程式碼塊時,需要獲得monitor物件,它在作用是使CPU快取失效,從而使變數從主記憶體中重新載入,然後就可以看到之前執行緒對該變數的修改。
但從快取的角度看,似乎這個問題只會影響多處理器的機器,對於單核來說沒什麼問題,但是別忘了,它還有一個語義是禁止指令的重排序,對於編譯器來說,同步塊中的程式碼不會移動到獲取和釋放monitor外面。
下面這種程式碼,千萬不要寫,會讓人笑掉大牙:
synchronized (new Object()) {
}
複製程式碼
這實際上是沒有操作的操作,編譯器完成可以刪除這個同步語義,因為編譯知道沒有其它執行緒會在同一個monitor物件上同步。
所以,請注意:對於兩個執行緒來說,在相同的monitor物件上同步是很重要的,以便正確的設定happens-before關係。
final 可以影響什麼
如果一個類包含final欄位,且在建構函式中初始化,那麼正確的構造一個物件後,final欄位被設定後對於其它執行緒是可見的。
這裡所說的正確構造物件,意思是在物件的構造過程中,不允許對該物件進行引用,不然的話,可能存在其它執行緒在物件還沒構造完成時就對該物件進行訪問,造成不必要的麻煩。
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欄位,一個執行緒A執行reader方法,如果f已經線上程B初始化好,那麼可以確保執行緒A看到x值是3,因為它是final修飾的,而不能確保看到y的值是4。 如果建構函式是下面這樣的:
public FinalFieldExample() { // bad!
x = 3;
y = 4;
// bad construction - allowing this to escape
global.obj = this;
}
複製程式碼
這樣通過global.obj拿到物件後,並不能保證x的值是3.
###volatile可以做什麼 Volatile欄位主要用於執行緒之間進行通訊,volatile欄位的每次讀行為都能看到其它執行緒最後一次對該欄位的寫行為,通過它就可以避免拿到快取中陳舊資料。它們必須保證在被寫入之後,會被重新整理到主記憶體中,這樣就可以立即對其它執行緒可以見。類似的,在讀取volatile欄位之前,快取必須是無效的,以保證每次拿到的都是主記憶體的值,都是最新的值。volatile的記憶體語義和sychronize獲取和釋放monitor的實現目的是差不多的。
對於重新排序,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.
}
}
}
複製程式碼
同樣的,假設一個執行緒A執行writer,另一個執行緒B執行reader,writer中對變數v的寫入把x的寫入也重新整理到主記憶體中。reader方法中會從主記憶體重新獲取v的值,所以如果執行緒B看到v的值為true,就能保證拿到的x是42.(因為把x設定成42發生在把v設定成true之前,volatile禁止這兩個寫入行為的重排序)。
如果變數v不是volatile,那麼以上的描述就不成立了,因為執行順序可能是v=true, x=42,或者對於執行緒B來說,根本看不到v被設定成了true。
double-checked locking的問題
臭名昭著的雙重檢查(其中一種單例模式),是一種延遲初始化的實現技巧,避免了同步的開銷,因為在早期的JVM,同步操作效能很差,所以才出現了這樣的小技巧。
private static Something instance = null;
public Something getInstance() {
if (instance == null) {
synchronized (this) {
if (instance == null)
instance = new Something();
}
}
return instance;
}
複製程式碼
這個技巧看起來很聰明,避免了同步的開銷,但是有一個問題,它可能不起作用,為什麼呢?因為例項的初始化和例項欄位的寫入可能被編譯器重排序,這樣就可能返回部門構造的物件,結果就是讀到了一個未初始化完成的物件。
當然,這種bug可以通過使用volatile修飾instance欄位進行fix,但是我覺得這種程式碼格式實在太醜陋了,如果真要延遲初始化例項,不妨使用下面這種方式:
private static class LazySomethingHolder {
public static Something something = new Something();
}
public static Something getInstance() {
return LazySomethingHolder.something;
}
複製程式碼
由於是靜態欄位的初始化,可以確保對訪問該類的所以執行緒都是可見的。
對於這些,我們需要關心什麼
併發產生的bug非常難以除錯,通常在測試程式碼中難以復現,當系統負載上來之後,一旦發生,又很難去捕捉,為了確保程式能夠在任意環境正確的執行,最好是提前花點時間好好思考,雖然很難,但還是比除錯一個線上bug來得容易的多。
知識星球可以幹什麼? 1、【分享】高質量的技術文章 2、【沉澱】「戰狼群」高質量問題&解決方案 3、【成長】專案經驗,生活隨筆,學習心得 4、【覆盤】實戰經驗,故障總結 5、【面經】面試經驗分享與總結 6、【推薦】技術書籍,崗位招聘