Java內存模型與volatile關鍵字解析

提示:文末有本篇提綱

一.Java內存模型

1.硬件的效率與一致性(硬件層面)

在講解java虛擬機并發知識之前,我們首先應該了解一下物理計算機中(硬件層面)的中的并發問題,它和下面要講的軟件層面的并發模型有一定的相似之處。我們知道,現代計算機大多是基于馮·諾伊曼結構來設計的,這種結構將CPU與存儲器分開。由于CPU需要和儲存器進行讀寫(“I/0”)操作,而計算機的儲存設備與處理器的運算速度有幾個數量級的差距,為了減少在“I/0”操作上面浪費的時間,現代計算機不得不加入一層讀寫速度盡可能接近處理器運算速度的高速緩存將運算所需要的數據復制到高速緩存中,讓運算能快速進行,當運算結束后再從緩存同步回內存中,這樣處理器就無需等待緩慢的內存讀寫了。

處理器、高速緩存、主內存之間的關系如圖:

處理器、高速緩存、主內存之間的關系.png

基于高速緩存的儲存結構很好的解決了處理器與內存的速度矛盾,但是也帶來了另一個問題——緩存一致性問題。在多處理器系統中,每個處理器都有自己的高速緩存,而他們又共享一個主內存,當多個處理器的運算任務都涉及同一塊內存區域時,將可能導致各自的緩存數據不一致。為了解決緩存一致性問題,我們需要各個處理器在訪問主內存和緩存時都要遵守一些協議,這里邊比較著名的有:MESI、MSI協議等。

2.并發編程模型的分類(軟件層面)

在并發編程中,我們需要處理的兩個關鍵的問題是:線程之間如何通信以及線程之間如何同步(線程!線程!線程!)
??其中通信是指線程之間以何種方式交換信息,在命令式編程中,通信的方式有兩種:共享內存信息傳遞。在共享內存的并發模型中,線程之間共享程序的公共狀態,線程之間通過讀-寫內存中的公共狀態來進行隱式通信;在消息傳遞的并發模型中,線程之間沒有公共狀態,線程之間必須通過明確的發送消息來顯示的通信(類似于Android中的Handler)
??同步是指程序用于控制不同線程之間操作發生順序的機制。在共享內存模型中,同步是顯示執行的,程序員必須顯示的指定某個方法或者某段代碼需要在線程之間互斥的執行(如Java的關鍵字synchronized用法)。在消息傳遞的并發模型中,由于消息的發送必須在消息接收之前,因此同步是隱式進行的。

3.Java內存模型

(1)什么是Java內存模型

不同架構的物理機器可以擁有不一樣的內存模型,而Java虛擬機也有自己的內存模型。這里的“內存模型”可以理解成在特定的操作協議下,對特定內存或者高速緩存進行讀寫訪問的過程的抽象。Java并發采用的是上述共享內存模型
??在Java中,所有的實例域數組元素儲存在堆內存中,堆內存在線程之間共享;而局部變量、異常處理參數存在于方法棧中,不會在線程之間共享,他們不會有內存可見性問題,也不受內存模型的影響。

(2)Java內存模型是用來干什么的

Java線程之間的通信由Java內存模型(Java Memory Model,簡稱JMM)控制,JMM的主要目標是定義程序中各個共享變量的訪問規則,即通過在虛擬機中將共享變量儲存到內存和從內存中取出變量這樣的底層細節,來屏蔽各個硬件平臺和操作系統的內存訪問差異,以實現讓Java程序在各個平臺下都能達到一致的內存訪問效果。
??我們再強調一遍,這里以及下面要講的“變量”,都指的是“共享變量”,也就是我們上面說的儲存在堆內存中的實例域數組元素,而不包括局部變量等。

(3)“工作內存”的概念

JMM中Java線程與主內存的通信過程,非常類似于最開始我們提到的處理器與主內存之間的通信過程,只不過一個是軟件層面,一個是硬件層面。Java線程之間的共享變量儲存在主內存中,每個線程有一個私有的工作內存,工作內存中儲存了該線程需要操作的共享變量的主內存副本拷貝線程對共享變量的操作均必須在工作內存中進行,而不能直接讀寫主內存中的變量;不同線程之間也無法直接訪問對方工作內存中的變量,線程間變量的值的傳遞均需要通過主內存來傳遞。

上面這幾段話中,我們需要解釋兩點:
??①“工作內存”是JMM的一個抽象概念,并不是真實的存在,它涵蓋了:緩存、寫緩沖區、寄存器以及其他的硬件和編譯優化。
??②對于工作內存中主內存副本拷貝,并不是說真的會把一整個對象拷貝出來(當然要整個拷貝也可以,但是沒有虛擬機會這么蠢的實現),而是將這個對象的引用對象在某個線程中被訪問到的字段被拷貝出來。

線程、工作內存的、主內存關系如下圖:

![線程、工作內存的、主內存之間的關系]

二.并發編程中的三個概念

上面我們已經講了JMM的一些基本的東西,下面我們來聊一聊JMM中進行的一些具體的操作。

1.原子性

原子性是指:即一個或多個操作,作為單獨的不可分割的單元運行,中間不能被打斷, 直到語句執行完畢。這句話應該很好理解,不做過多說明,下面會舉例子。Java中定義了8種原子操作。分別是:

①lock(鎖定):作用于主內存中的變量,把一個變量標識為一條線程獨自占用的狀態。
②unlock(解鎖):作用于主內存中的變量,把一個處于鎖定狀態的變量釋放出來,釋放后的變量才可以被其他線程使用。
③read(讀取):作用于主內存中的變量,它把一個變量的值從主內存中傳輸到線程的工作內存,以便隨后的load動作使用。
④load(載入):作用于工作內存中的變量,它把read操作從主內存中得到的變量值放入工作內存的副本中。
⑤use(使用):作用于工作內存中的變量,它把工作內存中的一個變量的值傳遞給執行引擎。
⑥assgin(賦值):作用于工作內存中的變量,它把一個從執行引擎中接受到的值,賦給工作內存中的變量。
⑦store(儲存):作用于工作內存中的變量,它把工作內存中的一個變量的值傳送到主內存中,以便隨后的write操作使用。
⑧write(寫入):作用于主內存中的變量,它把store操作中從工作內存中得到的變量的值放入主內存中的變量中。

舉個例子:int a = 10;這句代碼是一個賦值操作,屬于上面8中原子操作中的一種,線程執行這個語句會直接將數值10寫入工作內存中,沒有什么問題;
??那么我們再看b = a(b為int型)這句代碼,他是不是原子語句呢?答案是否定的,為什么呢?因為這句代碼實際上包含兩個操作:第一步,先去讀取的變量a的值(read);第二步,將a的值寫入工作內存,雖然這兩步操作都是原子操作,但是合起來就不是原子操作了。同樣的,a = a + 10也不是原子操作,它包括三個過程:讀取內存中a的值;進行加操作;像內存中寫入新值。

上面說了一些最基本的讀取、賦值等操作是原子操作,如果要實現更大范圍內的的原子語句,可以通過synchronized操作來實現。對于synchronized關鍵字,我們在Android設計模式之——單例模式這篇文章中“雙重檢查鎖”一段中已經做了較為詳細的講解,這里再強調一下,synchronized可以保證任一時刻只有一個線程執行該代碼塊,那么自然就不存在原子性問題了,從而保證了原子性。

2.可見性

可見性是指當多個線程需要使用同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改后的值。舉個例子,假設現在有兩個線程,線程1執行:

int a = 0;
a = 1;

線程2執行:

b = a

當線程1執行a = 1;這條語句時,會把變量a的初始值0加載到線程1的工作內存中,然后賦值為1,這個時候工作內存中的a的值已經為1了,但是還沒有來得及刷新主內存中變量a的值,此時線程2開始執行b = a這條語句,這個時候由于主內存中a的值仍然是0,因此b最終得到的值仍然是0。
??這就是可見性問題,線程1對主存中的變量的值做了修改,但是線程2并沒有及時的得到反饋。JMM是通過“在變量修改后將新值同步回主內存中,在變量讀取前從內存中刷新變量”這種方法來實現可見性的。無論是普通變量還是后文要講的volatile變量,均是如此。
??Java中的volatile變量能夠實現變量的可見性的原因是,volatile的特殊規則保證了新值能夠立即同步到主內存,以及每次使用前立即從主內存刷新。而普通變量什么時候刷新回主內存,這個是不確定的,因此不能保證可見性。
??除了volatile之外,Java中可以通過synchronizedfinal關鍵字來實現可見性。但是兩者實現的原理不同,synchronized是通過同步塊代碼的“原子性”,也就是對一個變量執行unLock之前,必須把此變量同步回主內存中,之后其他線程才允許訪問該段代碼,來實現的。
??final關鍵字的可見性是指:被final修飾的字段,在構造器中一旦完成初始化操作,那么這個變量在主存中的值就確定了,無論其他幾個線程何時訪問,它的值都是不變的。

3.有序性

(1)有序性及指令重排

有序性:即程序按照代碼的先后順序執行。在執行程序時為了提高性能,編譯器處理器常常會對指令做重排序重排序分三種類型:
??①.編譯器優化重排序。編譯器在不改變單線程程序語義的前提下,可重新安排語句的執行順序。
??②.指令集并行的重排序。現代處理器采用了指令級并行技術來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
??③.內存系統的重排序。由于處理器使用緩存讀/寫緩沖區,這使得加載和儲存操作看上去可能是在亂序執行。

上述的三種重排序的情況,第一種是屬于編譯器重排序,2和3屬于處理器重排序。JMM中允許編譯器和處理器對指令進行重排序,但是重排序的過程不會影響到單線程的執行,卻會影響到多線程并發執行的正確的性。上面的定義比較難以理解,下面我們舉幾個例子來說明:
假如我們的程序中有下面一段代碼:

int a = 10; //①
int b = 1;  //②
a = a + b;  //③
b = b - a;  //④

如果程序遵守有序性執行的原則,這段代碼的執行順序應當是“①②③④”,但是一般來說,處理器為了提高程序的運行效率,就會對輸入的代碼執行順序進行優化,他不保證程序中各個語句的執行順序同代碼中的順序一致,但是保證程序最終的執行結果和代碼順序的執行結果是一樣的,我們舉例來看一下:
??上面的代碼中的①②句,int a = 10;int b = 1;這兩個語句誰先執行對程序的結果并沒有什么影響,那么在執行的過程中就可能發生執行順序的互換;但是可不可以把③④兩句,a = a + b;b = b - a;的順序互換呢?答案是否定的,因為第四句對第三句的結果有數據依賴性,因為第四句要用到第三句的結果,如果兩者一互換,顯然最終的結果會發生改變。
??雖然處理器的重排序不會影響單線程的執行結果,但是在多線程中就可能出現問題,這里我們選取《深入理解Java虛擬機》中的一段代碼來舉例明:

Map configOptions;
char[] configText;
boolean initialized = flase;

/**
 *該段代碼在A線程中執行,模擬讀取配置信息,讀取完后將initialized設置為true以通知其他線程可用
 */
configOptions = new HashMap();
configText = readConfigFile(fileName);  ①
processConfigOptions(configText,configOptions);
initialized = true;     ②

/**
 *假設以下代碼在B線程中執行,等待initialized為true之后,代表線程A已經把配置信息初始化完成
 */
while(!initialized){
    sleep();
}
dosomethingWithConfig();    //使用A線程初始化配置好的信息

在該段代碼中,①與②處的代碼沒有數據依賴性,因此可能被互換順序,假設此時真的發生了互換,那么在還沒有讀取配置信息的時候,initialized的值變為了true,線程B中的while()循環以為已經配置信息已經初始化完成,結束sleep開始做事情,這個時候程序就要炸了。
??因此,指令重排序不會影響單個線程的執行,但是會影響到線程并發執行的正確性。也就是說,要想并發程序正確地執行,必須要保證原子性、可見性以及有序性。只要有一個沒有被保證,就有可能會導致程序運行不正確。

(2)JMM中的有序性相關

很多時候,Java中的有序性可以用synchronizedvolatile關鍵字實現,關于他們的實現機制,我們在下文中會講解。
??另外,在Java內存模型中存在一些先天的“有序性“,這些先天的有序性總結一下就是happens-before(線性發生)原則。如果兩個操作的執行次序不符合happens-before原則中的任意一條,那么它們就不能保證它們的有序性,虛擬機可以隨意地對它們進行重排序。
ppens-before原則總結起來有8條(摘自《深入理解Java虛擬機》):

①程序次序規則:一個線程內,按照代碼順序,書寫在前面的操作先行發生于書寫在后面的操作
②鎖定規則:一個unLock操作先行發生于后面對同一個鎖額lock操作
③volatile變量規則:對一個變量的寫操作先行發生于后面對這個變量的讀操作
④線程啟動規則:Thread對象的start()方法先行發生于此線程的每個一個動作
⑤線程終止規則:線程中所有的操作都先行發生于線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行
⑥線程中斷規則:對線程interrupt()方法的調用先行發生于被中斷線程的代碼檢測到中斷事件的發生
⑦對象終結規則:一個對象的初始化完成先行發生于他的finalize()方法的開始
⑧傳遞規則:如果操作A先行發生于操作B,而操作B又先行發生于操作C,則可以得出操作A先行發生于操作C

上面的8條規則都比較好理解~~這里需要說下第①點,實際上這點就是我們之前將的有序性問題,這里再強調一下:雖然這里說“書寫在前面的操作先行發生于書寫在后面的操作”,但實際上,虛擬機可能會對程序進行指令重排。在單個線程中(這點一定要強調),雖然進行重排序,但是最終執行的結果是與程序順序執行的結果一致的,它只會對不存在數據依賴性的指令進行重排序。這個規則是用來保證程序在單線程中執行結果的正確性,但無法保證程序在多線程中執行的正確性。

三.volatile關鍵字解析

1.volatile關鍵字的作用(保證可見性和有序性)

一旦一個共享變量被volatile修飾之后,那么就具備了兩層語義:
(1)保證了這個變量對所有線程的可見性。
??一個共享變量被volatile修飾之后,假設有多個線程用到了這個變量。如果一個線程中對這個變量的值做了修改。那么第一步:該線程中修改后的值立即刷新同步主內存中對應的值;第二步:通知其他線程,他們的工作內存中原來緩存的該變量已過期無效,需重新從主內存中讀取
(2)禁止進行指令重排序優化,即保證了有序性
??①當程序執行到volatile變量的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對后面的操作可見;在其后面的操作肯定還沒有進行;
??②在進行指令優化時,不能將在對volatile變量訪問的語句放在其后面執行,也不能把volatile變量后面的語句放到其前面執行。
但是應當注意,volatile關鍵字并不一定能保證原子性。
這里還是舉剛才的例子來說明這個問題:

Map configOptions;
char[] configText;
volatile boolean initialized = flase;   //注意initialized變量申明為了volatile類型

/**
 *該段代碼在A線程中執行,模擬讀取配置信息,讀取完后將initialized設置為true以通知其他線程可用
 */
configOptions = new HashMap();
configText = readConfigFile(fileName);  ①
processConfigOptions(configText,configOptions);
initialized = true;     ②

/**
 *假設以下代碼在B線程中執行,等待initialized為true之后,代表線程A已經把配置信息初始化完成
 */
while(!initialized){
    sleep();
}
dosomethingWithConfig();    //使用A線程初始化配置好的信息

這段代碼相比前面的,我們將initialized變量申明為了volatile類型,這個時候就不會存在上面說的可見性以及指令重排序問題了。

2.volatile關鍵字不一定能保證原子性

仍然采用《深入理解Java虛擬機》上面的一個例子來說:

public class Test  {
    public static volatile int race = 0;

    public static void increase() {
        race++;
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保證前面的線程都執行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

如果我們運行上面的代碼,會發現每次運行結果都不一致,都是一個小于10000的數字。這個時候我們可能就會疑惑,上面不是說了volatile關鍵字可以保證可見性嗎?也就是說,當一個線程中的這個變量被修改這之后,會通知其他線程的工作內存中緩存的該變量無效,其他線程就會回到主內存中去讀取該變量的值~~那么這里為什么還會出現這個問題呢?
??問題就出在這個"race++"上面,自增操作不是一個原子操作,他仍然分為三個步驟:先讀race的值,然后進行加1操作,然后再更新主內存中的值。假如某一個線程1讀取了這個變量,但是還沒來得及改變這個變量的值,那么此時主內存中的值當然也是不變的;此時又有一個線程2讀取這個變量的值,那么線程2讀取到的值仍然是原來沒有改變的值,一次類推,最后的結果自然也就不對了。
??由于volatile關鍵字只保證可見性,所以在不符合下面兩條運算規則的場景中,我們仍然需要通過加鎖(使用synchronized)來保證原子性(這也是一般情況下單獨使用volatile關鍵字的場景)。
??①運算結果并不依賴變量的當前值,或者能夠確保只有單一的線程修改變量。
??②變量不需要與其他狀態變量共同參與不變式。
這兩個條件的定義比較拗口,簡單來說,多線程中,只要你們保證代碼塊的原子性,就可以單獨使用volatile關鍵字。

我們給上面的代碼加鎖之后得:

public class Test  {
    public static volatile int race = 0;

    public synchronized static void increase() {
        race++;
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保證前面的線程都執行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

這樣就保證了increase()方法中race++;操作的原子性。

3.volatile的原理和實現機制

關于volatile實現的原理和機制,這里用《深入理解Java虛擬機》中的一段話來解釋:
??"...通過對比就會發現,關鍵變化在于有volatile修飾的變量,賦值后多了一個‘lock add1 $0x0, (%esp)'操作,這個操作相當于一個內存屏障..."內存屏障會提供3個功能:
??①它確保指令重排序時不會把其后面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的后面;即在執行到內存屏障這句指令時,在它前面的操作已經全部完成;
??②它會強制將對緩存的修改操作立即寫入主存;
??③如果是寫操作,它會導致其他CPU中對應的緩存行無效。
文止

提綱.png

為什么提綱要放在文末呢?好吧是因為放在題頭實在太丑了;為什么要放張圖片上去呢?好吧因為簡書不支持MD提綱語法啊~~我能有什么辦法,我也很絕望啊......

站在巨人的肩膀上摘蘋果:
??《深入理解Java虛擬機》
??《深入理解Java內存模型》

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,250評論 6 530
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 97,923評論 3 413
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,041評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,475評論 1 308
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,253評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,801評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,882評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,023評論 0 285
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,530評論 1 331
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,494評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,639評論 1 366
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,177評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,890評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,289評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,552評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,242評論 3 389
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,626評論 2 370

推薦閱讀更多精彩內容

  • 雨一直在下 水珠給天地相連 天似乎要大地感應它的恩澤 水珠急切的敲打著大地 彈起一人多高的迷霧 心肅靜的穿梭在雨幕...
    淘猴侯孫行閱讀 292評論 25 12
  • #玩卡不卡·每日一抽# 每一位都可以通過這張卡片覺察自己 1、直覺他叫什么名字?胖妹 2、他幾歲了? 45歲 3、...
    劉聰穎閱讀 129評論 0 0