在知識星球中,有個小夥伴提了一個問題: 有一個關於JVM名詞定義的問題,說」JVM內存模型「,有人會說是關於JVM內存分佈(堆棧,方法區等)這些介紹,也有地方說(深刻理解JVM虛擬機)上說Java內存模型是JVM的抽象模型(主內存,本地內存)。這兩個到底怎麼區分啊?有必然關係嗎?好比主內存就是堆,本地內存就是棧,這種說法對嗎?程序員
時間久了,我也把內存模型和內存結構給搞混了,因此抽了時間把JSR133規範中關於內存模型的部分從新看了下。面試
後來聽了好多人反饋:在面試的時候,有面試官會讓你解釋一下Java的內存模型,有些人解釋對了,結果面試官說不對,應該是堆啊、棧啊、方法區什麼的(這不是半吊子面試麼,本身概念都不清楚)緩存
JVM中的堆啊、棧啊、方法區什麼的,是Java虛擬機的內存結構,Java程序啓動後,會初始化這些內存的數據。bash
內存結構就是上圖中內存空間這些東西,而Java內存模型,徹底是另外的一個東西。多線程
在多CPU的系統中,每一個CPU都有多級緩存,通常分爲L一、L二、L3緩存,由於這些緩存的存在,提供了數據的訪問性能,也減輕了數據總線上數據傳輸的壓力,同時也帶來了不少新的挑戰,好比兩個CPU同時去操做同一個內存地址,會發生什麼?在什麼條件下,它們能夠看到相同的結果?這些都是須要解決的。架構
因此在CPU的層面,內存模型定義了一個充分必要條件,保證其它CPU的寫入動做對該CPU是可見的,並且該CPU的寫入動做對其它CPU也是可見的,那這種可見性,應該如何實現呢?併發
有些處理器提供了強內存模型,全部CPU在任什麼時候候都能看到內存中任意位置相同的值,這種徹底是硬件提供的支持。app
其它處理器,提供了弱內存模型,須要執行一些特殊指令(就是常常看到或者聽到的,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有多種語義,其中最容易理解的是互斥,對於一個monitor對象,只可以被一個線程持有,意味着一旦有線程進入了同步代碼塊,那麼其它線程就不能進入直到第一個進入的線程退出代碼塊(這由於都能理解)。
可是更多的時候,使用synchronization並不是單單互斥功能,Synchronization保證了線程在同步塊以前或者期間寫入動做,對於後續進入該代碼塊的線程是可見的(又是可見性,不過這裏須要注意是對同一個monitor對象而言)。在一個線程退出同步塊時,線程釋放monitor對象,它的做用是把CPU緩存數據(本地緩存數據)刷新到主內存中,從而實現該線程的行爲能夠被其它線程看到。在其它線程進入到該代碼塊時,須要得到monitor對象,它在做用是使CPU緩存失效,從而使變量從主內存中從新加載,而後就能夠看到以前線程對該變量的修改。
但從緩存的角度看,彷佛這個問題只會影響多處理器的機器,對於單核來講沒什麼問題,可是別忘了,它還有一個語義是禁止指令的重排序,對於編譯器來講,同步塊中的代碼不會移動到獲取和釋放monitor外面。
下面這種代碼,千萬不要寫,會讓人笑掉大牙:
synchronized (new Object()) {
}
複製代碼
這其實是沒有操做的操做,編譯器完成能夠刪除這個同步語義,由於編譯知道沒有其它線程會在同一個monitor對象上同步。
因此,請注意:對於兩個線程來講,在相同的monitor對象上同步是很重要的,以便正確的設置happens-before關係。
若是一個類包含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。
臭名昭著的雙重檢查(其中一種單例模式),是一種延遲初始化的實現技巧,避免了同步的開銷,由於在早期的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來得容易的多。