什麼是Java內存模型

有個小夥伴提了一個問題:

有一個關於JVM名詞定義的問題,說”JVM內存模型“,有人會說是關於JVM內存分佈(堆棧,方法區等)這些介紹,也有地方說(深入理解JVM虛擬機)上說Java內存模型是JVM的抽象模型(主內存,本地內存)。這兩個到底怎麼區分啊?有必然關係嗎?比如主內存就是堆,本地內存就是棧,這種說法對嗎?

時間久了,我也把內存模型和內存結構給搞混了,所以抽了時間把JSR133規範中關於內存模型的部分重新看了下。

後來聽了好多人反饋:在面試的時候,有面試官會讓你解釋一下Java的內存模型,有些人解釋對了,結果面試官說不對,應該是堆啊、棧啊、方法區什麼的(這不是半吊子面試麼,自己概念都不清楚)

如果想學習Java工程化、高性能及分佈式、深入淺出。微服務、Spring,MyBatis,Netty源碼分析的朋友可以加我的Java高級交流:787707172,羣裏有阿里大牛直播講解技術,以及Java大型互聯網技術的視頻免費分享給大家。

JVM中的堆啊、棧啊、方法區什麼的,是Java虛擬機的內存結構,Java程序啓動後,會初始化這些內存的數據。
什麼是Java內存模型

什麼是Java內存模型

內存結構就是上圖中內存空間這些東西,而Java內存模型,完全是另外的一個東西。

什麼是內存模型

在多CPU的系統中,每個CPU都有多級緩存,一般分爲L1、L2、L3緩存,因爲這些緩存的存在,提供了數據的訪問性能,也減輕了數據總線上數據傳輸的壓力,同時也帶來了很多新的挑戰,比如兩個CPU同時去操作同一個內存地址,會發生什麼?在什麼條件下,它們可以看到相同的結果?這些都是需要解決的。

所以在CPU的層面,內存模型定義了一個充分必要條件,保證其它CPU的寫入動作對該CPU是可見的,而且該CPU的寫入動作對其它CPU也是可見的,那這種可見性,應該如何實現呢?

有些處理器提供了強內存模型,所有CPU在任何時候都能看到內存中任意位置相同的值,這種完全是硬件提供的支持。

其它處理器,提供了弱內存模型,需要執行一些特殊指令(就是經常看到或者聽到的,memory barriers內存屏障),刷新CPU緩存的數據到內存中,保證這個寫操作能夠被其它CPU可見,或者將CPU緩存的數據設置爲無效狀態,保證其它CPU的寫操作對本CPU可見。通常這些內存屏障的行爲由底層實現,對於上層語言的程序員來說是透明的(不需要太關心具體的內存屏障如何實現)。
什麼是Java內存模型

什麼是Java內存模型

前面說到的內存屏障,除了實現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.

如果想學習Java工程化、高性能及分佈式、深入淺出。微服務、Spring,MyBatis,Netty源碼分析的朋友可以加我的Java高級交流:787707172,羣裏有阿里大牛直播講解技術,以及Java大型互聯網技術的視頻免費分享給大家。

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來得容易的多。

歡迎工作一到八年的Java工程師朋友們加入Java高級交流:787707172

本羣提供免費的學習指導 架構資料 以及免費的解答

不懂得問題都可以在本羣提出來 之後還會有直播平臺和講師直接交流噢

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章