Java併發編程之Java內存模型

該文章屬於《Java併發編程》系列文章,若是想了解更多,請點擊《Java併發編程之總目錄》java

1、併發的起源

爲了提升計算機處理數據的速度。現代的計算機都支持多任務處理。在32位windows操做系統中 ,多任務處理是指系統可同時運行多個進程,而每一個進程也可同時執行多個線程。一個線程是指程序的一條執行路徑,它在系統指定的時間片中完成特定的功能。系統不停地在多個線程之間切換,因爲時間很短,看上去多個線程在同時運行。或者對於在線程序可並行執行同時服務於多個用戶稱爲多任務處理。程序員

2、物理計算機的內存模型

在理解java內存模型以前,咱們先來了解一下,物理計算機的內存模型,其對Java內存模型有着很大的參考意義。 在物理計算機中,咱們須要處理的數據都在內存中,處理器處理數據,須要從內存中獲取相應的數據,而後存入內存中,爲了提升計算機的處理速度(讀取數據,存儲數據有IO消耗),咱們經常會在CPU(處理器)中加入高速緩存(Cache Memory),也就是將數據緩存處處理器中,當處理器處理完數據後,再將處理的數據結果存儲在內存中。具體以下圖所示:編程

cpu高速緩存.jpg

當CPU(處理器)要讀取一個數據時,首先從一級緩存中查找,若是沒有找到再從二級緩存中查找,若是仍是沒有就從三級緩存或內存中查找。通常來講,每級緩存的命中率大概都在80%左右,也就是說所有數據量的80%均可以在一級緩存中找到,只剩下20%的總數據量才須要從二級緩存、三級緩存或內存中讀取。windows

高速緩存(Cache Memory)是位於CPU與內存之間的臨時存儲器,它的容量比內存小的多可是交換速度卻比內存要快得多。高速緩存的出現主要是爲了解決CPU運算速度與內存讀寫速度不匹配的矛盾,由於CPU運算速度要比內存讀寫速度快不少,這樣會使CPU花費很長時間等待數據到來或把數據寫入內存。在緩存中的數據是內存中的一小部分,但這一小部分是短期內CPU即將訪問的,當CPU調用大量數據時,就可先緩存中調用,從而加快讀取速度。緩存

2.1 物理計算機的數據緩存不一致的問題

雖然高速緩緩衝提升了CPU(處理器)處理數據的速度問題。在多線程中運行就會有問題了。在多核CPU中,每條線程可能運行於不一樣的CPU中,所以每一個線程運行時有本身的高速緩存(對單核CPU來講,其實也會出現這種問題,只不過是以線程調度的形式來分別執行的)。這時CPU緩存中的值可能和緩存中的值不同,這就會出現緩存不一致的問題。爲了解決該問題。物理機算計提供了兩種方案來解決該問題。具體以下圖所示:bash

緩存一致性.png

2.1.1 經過總線加LOCK#鎖的方式

總線(Bus)是計算機各類功能部件之間傳送信息的公共通訊幹線,它是由導線組成的傳輸線束,在計算機中數據是經過總線,在處理器和內存之間傳遞。多線程

總線機制.png
在早期的CPU當中,是經過在總線上加 LOCK#鎖的形式來解決緩存不一致的問題。由於CPU和其餘部件進行通訊都是經過總線來進行的,若是對總線加LOCK#鎖的話,也就是說阻塞了其餘CPU對其餘部件訪問(如內存),從而使得只能有一個CPU能使用這個變量的內存。在總線上發出了LCOK#鎖的信號,那麼只有等待這段代碼徹底執行完畢以後,其餘CPU才能從其內存讀取變量,而後進行相應的操做。這樣就解決了緩存不一致的問題。

2.1.2 經過緩存一致性協議

可是因爲在鎖住總線期間,其餘CPU沒法訪問內存,會致使效率低下。所以出現了第二種解決方案,經過緩存一致性協議來解決緩存一致性問題。最出名的就是Intel 的MESI協議,MESI協議保證了每一個緩存中使用的共享變量的副本是一致的。它核心的思想是:當CPU寫數據時,若是發現操做的變量是共享變量,即在其餘CPU中也存在該變量的副本,會發出信號通知其餘CPU將該變量的緩存行置爲無效狀態,所以當其餘CPU須要讀取這個變量時,發現本身緩存中緩存該變量的緩存行是無效的,那麼它就會從內存從新讀取。併發

2.2 CPU(處理器)的亂序執行(out-of-orderexecution)

除了使用高速緩存來提升CPU(處理器)的數據處理速度,CPU(處理器)還採用了容許將多條指令不按程序規定的順序分開發送給各相應電路單元處理的技術。在這期間不按規定順序執行指令,而後由從新排列單元將各執行單元結果按指令順序從新排列。採用亂序執行技術的目的是爲了使CPU內部電路滿負荷運轉並相應提升了CPU的運行程序的速度。有可能你們很差理解。下面這個例子幫助你們理解。app

假如請A、B、C三個名人爲晚會題寫橫幅「春節聯歡晚會」六個大字,每人各寫兩個字。若是這時在一張大紙上按順序由A寫好"春節"後再交給B寫"聯歡",而後再由C寫"晚會",那麼這樣在A寫的時候,B和C必須等待,而在B寫的時候C仍然要等待而A已經沒事了。函數

順序執行.png

但若是採用三我的分別用三張紙同時寫的作法, 那麼B和C都沒必要須等待就能夠同時各寫各的了,甚至C和B還能夠比A先寫好也不要緊(就象亂序執行),但當他們都寫完後就必須從新在橫幅上(天然能夠由別人作,就象CPU中亂序執行後的從新排列單元)按"春節聯歡晚會"的順序排好才能掛出去。

亂序執行.png

3、Java的內存模型

看到這裏你們必定會發現,咱們所討論的CPU高速緩存、指令重排序等內容都是計算機體系結構方面的東西,並非Java語言所特有的。事實上,不少主流程序語言(如C/C++)都存在緩存不一致的問題,這些語言是藉助物理硬件和操做系統的內存模型來處理緩存不一致問題的,所以不一樣平臺上內存模型的差別,會影響到程序的執行結果。Java虛擬機規範定義了本身的內存模型JMM(Java Memory Model)來屏蔽掉不一樣硬件和操做系統的內存模型差別,以實現讓Java程序在各類平臺下都能達到一致的內存訪問結果。因此對於Java程序員,無需瞭解底層硬件和操做系統內存模型的知識,只要關注Java本身的內存模型,就可以解決這些問題啦。

Java內存模型以下圖所示:

Java內存模型.png

  • 主內存:主要存儲變量(包括。實例字段,靜態字段和構成對象的元素)
  • 工做內存:每一個線程都有本身的工做內存,存儲了對應的引用,方法參數。

若是對應與Java內存中堆與棧的概念的話,主內存對應Java內存中的堆,工做內存對應Java虛擬機的棧。

3.1 內存之間交互

主內存與工做內存之間的內存交互,也就是從線程的私有內存數據同步到主內存中,從主內存的讀取數據到線程的私有內存中。Java內存模型定義了8種操做來完成。虛擬機在實現時保證下面提到的每一種操做都是原子的,不可再分的

8種操做.png

  • lock:做用於主內存的變量,它把一個變量標識爲一條線程獨佔的狀態。
  • unlock:做用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放後的變量才能被其餘線程訪問。
  • read:做用於主內存的變量,它把一個變量的值從主內存傳輸到線程的工做內存中,一遍隨後的load動做使用。
  • load:做用於工做內存的變量,它把read操做從主內存中獲得的變量值放入到工做內存變量副本中。
  • use:做用於工做內存的變量,它把工做內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個須要使用到變量的值的字節碼指令時會執行這個操做。
  • assign:做用於工做內存的變量,它把一個從執行引擎收到的值賦給工做內存的變量。每當虛擬機遇到給變量賦值的字節碼指令時會執行這個操做。
  • store:做用於工做內存的變量,它把工做內存中一個變量值傳送到主內存中。以便隨後的write操做。
  • write:做用於主內存的變量,它把store操做從工做內存中獲得的變量的值,放入主內存的變量中。

3.2 八種原子操做規則

既然Java內存模型規定了內存之間交互的一些操做。那麼咱們來看看,它到底擁有哪些規則呢。

  • 不容許read和load、store和write操做之一單獨出現。即不容許一個變量從主內存讀取了但工做內存不接受。或者從工做內存發起回寫了但主內存不接受的狀況
  • 不容許一個線程丟棄它的最近的assign操做。即變量在工做內存改變了後必須把該變化同步到主內存中。
  • 不容許沒有發生任何的assign操做就把數據同步到主內存中。
  • 一個新的變量只能在主內存中誕生,工做內存要使用或者賦值。必需要通過load或assign操做。
  • 一個變量在同一時刻只容許一條線程進行lock操做,但lock操做能夠被同一線程重複執行屢次,屢次執行lock後,只有執行相同次數的unlock操做,變量纔會被解鎖。
  • 若是對一個變量進行lock操做後,那將會清空工做內存中此變量的值,在執行引擎使用這個變量前,須要從新執行load或assign操做。
  • 若是一個變量事先沒有被lock操做鎖定,那就不容許對它進行unlock操做。也不容許去unlock一個被其餘線程鎖定的變量。
  • 對一個變量執行unLock操做以前,必需要把次變量同步到主內存中(執行store,write操做)。

上述規則規定了Java內存之間交互的流程。保證了數據在單線程情形下傳輸過程當中的準確性與數據一致性。

4、重排序

前面提到過,CPU(處理器)爲了提升處理數據的速度,會進行亂序執行(out-of-orderexecution)。也就是重排序。可是CPU不會對任務操做進行重排序,編譯器與處理器只會對沒有數據依賴性的指令進行重排序。這裏提到了一個關鍵詞數據依賴性。什麼是數據依賴呢?

4.1 數據依賴

若是兩個操做訪問同一個變量,且這兩個操做中有一個爲寫操做,此時這兩個操做之間就存在數據依賴性。以下圖所示:

名稱 代碼示例 說明
寫後讀 a=1;b=a 寫一個變量以後,再讀這個位置
寫後寫 a=1;a=2 寫一個變量以後,再寫這個位置
讀後寫 a=b;b=1 讀一個變量以後,再寫這個位置

上述三種狀況,a與b存在着**「數據依賴性」**,同時你們也要注意。這裏所說的數據依賴性是指單個處理器執行的指令序列和單個線程中執行的操做。多處理器和不一樣線程之間是沒有數據依賴性這種關係的。

4.2 重排序規則(as-if-serial)

既然咱們已經知道了CPU在處理數據時候會出現重排序。那重排序的規則是什麼呢?重排序規則:無論怎麼重排序(編譯器和處理器爲了提升並行度),單線程(程序)執行結果不能被改變。編譯器、runtime和處理器都必須遵照。那麼咱們三角形面積示例代碼說明:

double a = 3;//底
double h = 10;//高
double s = a*h/2//面積
複製代碼

其中上述代碼的依賴關係以下圖所示:

依賴關係.png
如上圖所示:a與s存在數據依賴關係,同時h與s也存在依賴關係。所以在程序的最終指令執行時。s是不能排在a與h以前。由於a與h不存在着數據依賴關係。因此處理器能夠對a與h以前的執行順序重排序。
重排序先後對比.png
通過處理器的重排序後,執行的結果並無發生改變。

5、Java內存模型的須要解決的問題

前面咱們已經瞭解了Java內存模型的大體結構與操做方式,那麼咱們來看看Java內存模型須要解決的問題。

5.1 工做內存的可見性問題

工做內存的可見性問題(這裏和計算機硬件的緩存不一致是同樣的道理)。從上文的Java內存模型分析。咱們已經知道了當多個線程操做同一個共享變量時,若是一個線程修改了其中的變量的值(若是經過Java內存模型的原子操做來表達,一個線程屢次use與assign 操做,而另外一個線程通過read、load以後,另外一線程任然保持着以前從主內存中獲取的值),另外一個線程怎麼感知呢?

5.2 重排序帶來的問題

CPU(處理器)的重排序會對多線程帶來問題。具體問題咱們用下列僞代碼來闡述:

public class Demo {
    private int a = 0;
    private boolean isInit = false;
    private Config config;

    public void init() {
        config = readConfig();//1
        isInit = true;//2
    }
    public void doSomething() {
        if (isInit) {//3
            doSomethingWithconfig();//4
        }
    }
}
複製代碼

isInit用來標誌是否已經初始化配置。其中1,2操做是沒有數據依賴性,同理三、4操做也是沒有數據依賴性的。那麼CPU(處理器)可能對一、2操做進行重排序。對三、4操做進行重排序。如今咱們加入線程A操做Init()方法,線程B操做doSomething()方法,那麼咱們看看重排序對多線程狀況下的影響。

程序執行順序.png

上圖中2操做排在了1操做前面。當CPU時間片轉到線程B。線程B判斷 if (isInit)爲true,接下來接着執行 doSomethingWithconfig(),可是咱們Config尚未初始化。因此在多線程的狀況下。重排序會影響程序的執行結果。

6、Happens-Before 原則

上面咱們討論了Java內存模型須要解決的問題,那Java有不有一個良好的解決辦法來處理以上出現的狀況呢?答案是固然的。爲了方便程序員開發,將底層的煩瑣細節屏蔽掉,JMM定義了Happens-Before原則。只要咱們理解了Happens-Before原則,無需瞭解Java內存模型的內存操做,就能夠解決這些問題(避免工做內存的不可見與重排序帶來的問題)。

Happens-Before原則是一組偏序關係:對於兩個操做A和B,這兩個操做能夠在不一樣的線程中執行。若是A Happens-Before B,那麼能夠保證,當A操做執行完後,A操做的執行結果對B操做是可見的。那麼有哪些知足Happens-Before原則的呢?下面是Java內存模型規定的一些規則。

6.1 程序次序規則

在一個線程內,按照程序代碼順序,書寫在前面的操做先行發生於書寫在後面的操做。這是由於Java語言規範要求Java內存模型在單個線程內部要維護相似嚴格串行的語義,若是多個操做之間有前後依賴關係,則不容許對這些操做進行重排序。

6.2 鎖定規則

對一個unlock操做先行發生於後面對同一個鎖的lock操做。

public class Demo {
    private int value;
    public synchronized void setValue(int value) {
        this.value = value;
    }
    public synchronized int getValue() {
        return value;
    }
}
複製代碼

上面這段代碼,setValue與getValue擁有同一個鎖(也就是當前實例對象),假設setValue方法在線程A中執行,getValue方法在線程B中執行。線程A調用setValue方法會先對value變量賦值,而後釋放鎖。線程B調用getValue方法會先獲取到同一個鎖後,再讀取value的值。那麼B線程獲取的value的值必定是正確的。

6.3 volatlie變量規則

對一個volatile變量的寫操做先行發生於後面這個變量的讀操做。

public class Demo {
    private volatile boolean flag;
    public void setFlag(boolean flag) {
        this.flag = flag;
    }
    public boolean isFlag() {
        return flag;
    }
}
複製代碼

上面這段代碼,假設setFlag方法在線程A中執行,isFlag方法在線程B中執行。線程A調用setFlag方法會先對value變量賦值,而後釋放鎖。線程B調用isFlag方法再讀取value的值。那麼B線程獲取的flag的值必定是正確的。這裏咱們先不對volatlie進行講解,後面系列文章會描述。

6.4 線程啓動規則

Thread對象的start()方法先行發生於此線程的每一個動做。

start方法和新線程中的動做必定是在兩個不一樣的線程中執行。線程啓動規則能夠這樣去理解:調用start方法時,會將start方法以前全部操做的結果同步到主內存中,新線程建立好後,須要從主內存獲取數據。這樣在start方法調用以前的全部操做結果對於新建立的線程都是可見的。

6.5 線程終止規則

線程中的全部操做都先行發生於對此線程的終止檢測。

這裏理解比較抽象。舉個例子,假設兩個線程s、t。在線程s中調用t.join()方法。則線程s會被掛起,等待t線程運行結束才能恢復執行。當t.join()成功返回時,s線程就知道t線程已經結束了。在t線程中對共享變量的修改,對s線程都是可見的。相似的還有Thread.isAlive方法也能夠檢測到一個線程是否結束。也就是說當一個線程結束時,會把本身全部操做的結果都同步到主內存。而任何其它線程當發現這個線程已經執行結束了,就會從主內存中從新刷新最新的變量值。因此結束的線程A對共享變量的修改,對於其它檢測了A線程是否結束的線程是可見的。

6.6 線程中斷規則

對線程interrupt()方法的調用先與被中斷線程的代碼檢查到中斷事件的發生。

假設兩個線程A和B,A先作了一些操做operationA,而後調用B線程的interrupt方法。當B線程感知到本身的中斷標識被設置時(經過拋出InterruptedException,或調用interrupted和isInterrupted),operationA中的操做結果對B都是可見的。

6.7 對象終結規則

一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize()方法的開始。

6.8 傳遞性規則

若是操做A先行與發生於操做B,操做B先行發生於操做C,那麼就能夠得出A先行發生於操做C的結論。

總結

  • 在物理計算機中CPU爲了提升處理速度,添加了高速緩存與CPU亂序執行
  • Java定義了自身的內存模型是爲了屏蔽掉不一樣硬件和操做系統的內存模型差別
  • Java爲了處理內存的不可見性與重排序的問題,定義了Happens-Before 原則
  • Happens-Before 原則的理解:對於兩個操做A和B,這兩個操做能夠在不一樣的線程中執行。若是A Happens-Before B,那麼能夠保證,當A操做執行完後,A操做的執行結果對B操做是可見的。
相關文章
相關標籤/搜索