深刻理解Java內存模型

1、Java內存模型介紹 

內存模型的做用範圍:

在Java中,全部實例域、靜態域和數組元素存放在堆內存中,線程之間共享,下文稱之爲「共享變量」。局部變量、方法參數、異常處理器等不會在線程之間共享,不存在內存可見性問題,也不受內存模型的影響。java

重排序與可見性:

現代編譯器在編譯源碼時會作一些優化處理,對代碼指令進行重排序;現代流水線結構的處理器爲了提升並行度,在執行時也可能對指令作一些順序上的調整。重排序包括編譯器重排序、指令級並行重排序和內存系統重排序等。通常來講,編譯器和處理器在作重排序的時候都會作一些保證,保證程序的執行結果與重排序以前指令的執行結果相同。即as-if-serial,無論怎樣重排序,都不能改變程序的執行結果。程序員

CPU在執行指令時通常都會使用緩存技術來提升效率,若是不一樣線程使用不一樣的緩存空間則會形成一個線程對一個共享變量的更新不能及時反映給其餘線程,也就是多線程對共享變量更新的可見性問題,這個問題是很是複雜的。數組

Java內存模型的抽象:

對於上述問題,Java內存模型(JMM)爲程序員提供了一個抽象層面的描述,咱們不用去關心編譯器、處理器對指令作了怎樣的重排序,也不用關心複雜的系統緩存機制,只要遵循JMM的規則,JMM就能爲咱們提供代碼順序性、共享變量可見性的保證,從而獲得預期的執行結果。緩存

JMM決定了一個線程對共享變量的寫入什麼時候對另外一個線程可見。從抽象來說,線程共享變量存放在主內存(main memory),每一個線程持有一個本地內存(local memory),本地內存中存儲了該線程讀寫共享變量的副本(本地內存是JMM的一個抽象概念,並非真實存在的)。以下圖:安全

image

若是A、B兩個線程要通訊要通過如下兩步:首先線程A將本地內存中更新過的共享變量刷新到主內存中,而後線程B到主內存中讀取A以前更新過的變量。多線程

JMM經過控制主內存與每一個線程的本地內存之間的交互來爲Java程序員提供可見性保證。併發

重排序:

現代編譯器和處理器會對指令執行的順序進行重排序,以此提升程序的性能。這些重排序可能會致使多線程程序出現內存可見性問題。爲了避免改變程序的執行結果,對於編譯器,JMM會禁止特定類型的編譯器重排序;對於處理器重排序,JMM要求在Java編譯生產指令序列時,插入特定類型的內存屏障(memory barriers)來禁止特定類型的重排序。app

JMM把內存屏障分爲如下四類:函數

屏障類型 指令示例 說明
LoadLoad Barriers Load1; LoadLoad; Load2 確保Load1數據的裝載以前於在Load2及其全部後續裝載指令
StoreStore Barriers Store1; StoreStore; Store2 確保Store1刷新數據到內存以前與Store2及其後續存儲指令
LoadStore Barriers Load1; LoadStore; Store2 確保Load1數據裝載以前於Store2及其後續存儲指令
StoreLoad Barriers Store1; StoreLoad; Load2

確保Store1刷新數據到內存以前於Load2及其後續裝載指令。性能

StoreLoad Barriers會使該屏障以前的全部內存訪問指令完成後才執行屏障後的指令。

StoreLoad Barriers是一個全能型屏障,同時具備其餘三個屏障的效果。

Happens-before:

從JDK1.5開始,Java使用新的JSR-133內存模型(如下全部都是針對該內存模型講的),使用happens-before的概念來闡述操做之間的內存可見性。

若是一個操做要對另外一個操做可見,那這兩個操做之間必須存在happens-before關係。這兩個操做能夠在一個線程內,也能夠在不一樣線程之間。ps.(兩個操做存在happens-before關係並不意味着前一個操做必須在後一個操做以前執行,僅僅要求前一個操做對後一個操做可見。)

常見的與程序員相關的happens-before規則以下:

①程序順序規則:一個線程中的每一個操做happens-before於其後的任意操做;

②監視器鎖規則:對一個監視器的解鎖happens-before於隨後對這個監視器的加鎖;

③volatile規則:對一個volatile域的寫happens-before於任意後續對該域的讀操做(該規則多個線程之間也成立);

④傳遞性:若是A happens-before B,且B happens-before C,那麼A happens-before C

image

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

寫後讀

a=1;

b=a;

寫後寫

a=1;

a=2;

讀後寫

a=b;

b=a;

上述三類狀況存在數據依賴性,此時不容許重排序,不然程序的結果可能會改變。

as-if-serial語義:

as-if-serial語義的意思是:在單線程內,無論怎麼重排序,程序的執行結果不變,在程序員看來,就像順序執行的同樣。

示例:

 

a = 1; //A
b = 2; //B
c = a + b; //C

前兩條語句就能夠進行重排序,而第三條語句與前兩條存在依賴關係,不能重排序。

上述A happens-before B,B happens-before C,但並不保證A在B以前執行,只須要保證操做A對B可見(這裏A操做不須要對B可見,所以能夠重排)

重排序對多線程的影響:

示例:

 

class Demo {
    boolean flag = false;
    int a = 0;
    
    public void fun1() {
        a = 1; //A
        flag = true; //B
    }
    
    public void fun2() {
        if (flag) { //C
            a = a + a; //D
        }
    }
}

假設上述類中fun1()和fun2()在不一樣線程中執行,操做A、B沒有依賴關係,可能被重排序;操做C、D雖然存在控制依賴關係,現代編譯器和處理器爲了提升並行度,可能採起激進的方法(即先求出if語句塊中的值存於臨時變量中,若是if條件爲真則使用該值,不然丟棄)對其進行重排序,這均可能改變程序的執行結果。

順序一致性內存模型:

計算機科學家們提出了一個理想化的理論參考模型--順序一致性模型,它爲程序員提供了極強的內存可見性,具備以下兩大特性:

①一個線程中的全部操做必須按照程序順序來執行;

②全部線程(不管同步與否)都只能看到一個單一的操做執行順序。每一個操做都必須是原子的且馬上對全部線程可見。

示例:

假設有A和B兩個線程併發執行,A線程中有三個操做,順序是A1->A2->A3,線程B中也有三個操做,順序是B1->B2->B3。 先假設這兩個線程使用監視器同步,A線程先得到監視器,執行完畢釋放監視器後線程B開始執行。那麼他們在順序一致性模型中執行效果以下:

image

如今咱們再假設這兩個線程未進行同步,其在順序一致性模型中執行效果以下:

image

能夠看到,未同步的程序在順序一致性模型中雖然總體執行順序是無序的,但全部線程都只看到一個一致的總體執行順序。如上圖,線程A和B看到的執行順序都是B1->A1->A2->B2-A3->B3。之因此能獲得這個保證是由於順序一致性內存模型中的每一個操做必須當即對任何線程可見。

可是JMM中沒有這個保證。好比當前線程寫數據到本地內存中,在尚未刷新到主內存以前,這個寫操做只對當前線程可見,從其餘線程角度觀察,能夠認爲這個寫操做根本尚未被當前線程執行過。這種狀況下,當前線程和其餘線程看到的操做執行順序將不一致。

同步程序的一致性效果:

示例:

 

class SynchronizedDemo {
    int a = 0;
    boolean flag = false;
    public synchronized void write() {
        a = 1;
        flag = true;
    }

    public synchronized void read() {
        if(flag) {
            int i = a;
        }
    }
}

上述代碼使用同步方法,線程A先執行write()方法,釋放鎖後線程B獲取鎖並執行read()方法,執行流程以下:

clip_image002[15]

在順序一致性模型中,全部操做按順序執行。在JMM中,臨界區內的代碼能夠重排序(JMM不容許臨界區內的代碼「逸出」到臨界區以外),JMM會在進入和退出臨界區的關鍵點上作一些限定,使得現場在這兩個關鍵點處具備和順序一致性模型具備相同的內存視圖。雖然現場A在臨界區內作了重排序,但因爲監視器的互斥性,這裏線程B根本沒法「觀察」到線程A在臨界區內的重排序,這樣既提升了效率又不改變程序的執行結果。

對於未同步的多線程程序,JMM只提供最小安全性:線程執行讀操做取得的值,要麼是以前線程寫入的,要麼是默認值(0,null,false),JMM保證線程讀取的數據不是無中生有冒出來的。爲了實現最小安全,JVM在堆上分配對象時首先會清空內存空間,而後才分配對象(所以對象分配時,域的默認初始化已經完成)。

此外,JMM的最小安全不保證對64位的long和double型變量的讀寫具備原子性,而順序一致性模型保證對全部內存讀寫操做具備原子性。

2、Volatile特性

volatile變量的單次讀寫,至關於使用了一個鎖對這些單個讀/寫作了同步。

原子性:對volatile變量的單次讀寫操做具備原子性(ps.這裏存在爭議,暫且這麼寫,保留意見);

可見性:鎖的happens-before規則保證釋放鎖和獲取鎖的兩個線程之間的可見性,這意味着對一個volatile變量的讀操做總能看到以前任意線程對這個volatile變量最後的寫入,即對volatile變量的寫操做對其餘線程當即可見。

       當寫一個volatile變量時,JMM會把線程對應的本地內存中的共享變量刷新到主內存;

       當讀一個volatile變量時,JMM會把改下暱稱對應的本地內存置爲無效,接下來從主內存中讀取共享變量的值。

 

從內存語義的角度來講,volatile的寫-讀於鎖的釋放-獲取具備相同的內存效果。所以若是線程A對volatile變量的寫操做在線程B對volatile變量的讀操做以前,則其存在happens-before關係。

示例:

class VolatileDemo {
     volatile boolean flag = false; 
     int a=0;
public void fun1() {
      a=1; //A
      flag = true; //B 
    } 

    public void fun2() { 
        if (flag) { //C 
            a=a+a; //D 
        } 
    } 
}

上述操做A happens-before 操做B,操做C happens-before 操做D,若是線程1調用fun1()方法以後線程2調用fun2()方法,則操做B happens-before 操做C,根據happens-before的傳遞性,則有A happens-before D,所以能夠保證操做D能夠正確讀取到操做A的賦值。

Volatile的內存語義是JMM經過在volatile讀寫操做先後插入內存屏障實現的。

3、鎖的特性

鎖的釋放與獲取遵循happens-before規則,釋放鎖線程臨界區的操做結果對獲取鎖的線程可見。

        當線程釋放鎖時,JMM會把線程對應的本地內存中的共享變量刷新到主內存;

        當線程獲取鎖時,JMM會把改下暱稱對應的本地內存置爲無效,接下來從主內存中讀取共享變量的值。

ReentrantLock是java.util.concurrent.locks包下的一個鎖的實現,依賴對volatile變量的讀寫和compareAndSet(CAS)操做實現鎖機制。其中CAS操做使用不一樣的CPU指令實現單次操做的原子性,具備volatile讀寫操做相同的內存語義。 類圖以下:

ReentrantLock根據對搶佔鎖的線程的處理方式不一樣,分爲公平鎖和非公平鎖,首先看公平鎖,使用公平鎖加鎖時,加鎖方法lock()的方法調用主要有如下四步:

1. ReentrantLock : lock() 
2. FairSync : lock() 
3. AbstractQueuedSynchronizer : acquire(int arg) 
4. ReentrantLock : tryAcquire(int acquires)

在第四步纔開始真正加鎖,該方法的源碼以下:

protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();();//獲取鎖的開始,state是volatile類型變量 
            if (c == 0) {
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }

從上面方法能夠看出,加鎖方法首先讀取volatile變量state。

 

使用公平鎖的unlock()方法調用軌跡以下:

1. ReentrantLock : unlock() 
2. AbstractQueuedSynchronizer : release(int arg) 
3. Sync : tryRelease(int releases)

在第三步調用時才真正開始釋放鎖,該方法源碼以下:

protected final boolean tryRelease (int releases){
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false ;
    if (c == 0){
        free = true ;
        setExclusiveOwnerThread( null );
    }
    setState(c);//釋放鎖後,寫volatile變量state
    return free;
}

從上面代碼能夠看出,在釋放鎖的最後寫volatile變量state。

公平鎖在釋放鎖的最後寫volatile變量state,在獲取鎖的時候首先讀這個volatile變量。根據volatile的happens-before規則,釋放鎖的線程在寫volatile變量以後該變量對獲取鎖的線程可見。

 

Java中的CAS操做同時具備volatile讀和volatile寫的內存語義,所以Java線程之間通訊如今有了如下四種方式:

一、A線程寫volatile變量,隨後B線程讀這個volatile變量。

二、A線程寫volatile變量,隨後B線程用CAS更新這個volatile變量。

三、A線程用CAS更新一個volatile變量,隨後B線程用CAS更新這個volatile變量。

四、A線程用CAS更新一個volatile變量,隨後B線程讀這個volatile變量。A線程寫

 

Java的CAS會使用現代處理器上提供的高效機器級別原子指令,這些原子指令以原子方式對內存執行讀-改-寫操做,同時,volatile變量的讀/寫和CAS能夠實現線程之間的通訊。把這些特性整合在一塊兒,就造成了整個concurrent包得以實現的基石。若是咱們仔細分析concurrent包的源代碼實現,會發現一個通用化的實現模式:

一、首先,聲明共享變量爲volatile;

二、而後,使用CAS的原子條件更新來實現線程之間的同步;

三、同時,配合以volatile的讀/寫和CAS所具備的volatile讀和寫的內存語義來實現線程之間的通訊。

4、Final 的特性

與前面介紹的鎖和volatile相比較,對final域的讀和寫更像是普通的變量訪問。對於final域,編譯器和處理器要遵照兩個(分別對應讀寫)重排序規則:

一、在構造函數內對一個final域的寫入,與隨後把這個被構造對象的引用賦值給一個引用變量,這兩個操做之間不能重排序。

二、初次讀一個包含final域的對象的引用,與隨後初次讀這個final域,這兩個操做之間不能重排序。

寫final域的重排序規則

寫final域的重排序規則禁止把final域的寫重排序到構造函數以外。這個規則的實現包含下面2個方面:

一、JMM禁止編譯器把final域的寫重排序到構造函數以外。

二、編譯器會在final域的寫以後,構造函數return以前,插入一個StoreStore屏障。這個屏障禁止處理器把final域的寫重排序到構造函數以外 。

寫final域的重排序規則能夠確保:在對象引用爲任意線程可見以前,對象的final域已經被正確初始化過了,而普通域不具備這個保障

對於引用類型,寫final域的重排序規則對編譯器和處理器增長了以下約束:

在構造函數內對一個final引用的對象的成員域的寫入,與隨後在構造函數外把這個被構造對象的引用賦值給一個引用變量,這兩個操做之間不能重排序。

讀final域的重排序規則

在一個線程中,初次讀對象引用與初次讀該對象包含的final域,JMM禁止處理器重排序這兩個操做(注意,這個規則僅僅針對處理器)。編譯器會在讀final域操做的前面插入一個LoadLoad屏障。初次讀對象引用與初次讀該對象包含的final域,這兩個操做之間存在間接依賴關係。因爲編譯器遵照間接依賴關係,所以編譯器不會重排序這兩個操做。

讀final域的重排序規則能夠確保:在讀一個對象的final域以前,必定會先讀包含這個final域的對象的引用。在這個示例程序中,若是該引用不爲null,那麼引用對象的final域必定已經被A線程初始化過了。

相關文章
相關標籤/搜索