Java記憶體模型及volatile

塵虛緣_KY發表於2017-06-04

工作後時間緊張,但是還是要不忘學習。本片主要記錄了java記憶體模型和併發程式設計中的三個特性:原子性,可見性,有序性,然後分別從這三個方面瞭解了volatile這個關鍵字的用法及注意的地方。

目錄

java記憶體區域劃分

JAVA記憶體模型

併發程式設計的三個概念

原子性

可見性

有序性

JMM提供的解決方案

happens-before 原則

volatile關鍵字

記憶體屏障 Memory Barrier

volatile保證可見性嗎?

volatile保證原子性嗎?

volatile保證有序性嗎?

volatiile的實現原理

volatile的使用

總結


java記憶體區域劃分

Java虛擬機器在執行程式時會把其自動管理的記憶體劃分為:共享記憶體【方法區和堆】和私有記憶體【虛擬機器棧/本地方法棧/程式計數器】。

java記憶體模型

方法區(Method Area):

方法區屬於執行緒共享的記憶體區域,又稱Non-Heap(非堆),主要用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料,根據Java 虛擬機器規範的規定,當方法區無法滿足記憶體分配需求時,將丟擲OutOfMemoryError 異常。值得注意的是在方法區中存在一個叫執行時常量池(Runtime Constant Pool)的區域,它主要用於存放編譯器生成的各種字面量和符號引用,這些內容將在類載入後存放到執行時常量池中,以便後續使用。

JVM堆(Java Heap):

Java 堆也是屬於執行緒共享的記憶體區域,它在虛擬機器啟動時建立,是Java 虛擬機器所管理的記憶體中最大的一塊,主要用於存放物件例項,幾乎所有的物件例項都在這裡分配記憶體,注意Java 堆是垃圾收集器管理的主要區域,因此很多時候也被稱做GC 堆,如果在堆中沒有記憶體完成例項分配,並且堆也無法再擴充套件時,將會丟擲OutOfMemoryError 異常。

程式計數器(Program Counter Register):

屬於執行緒私有的資料區域,是一小塊記憶體空間,主要代表當前執行緒所執行的位元組碼行號指示器。位元組碼直譯器工作時,通過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。

雖然JVM中的程式計數器並不像組合語言中的程式計數器一樣是物理概念上的CPU暫存器,但是JVM中的程式計數器的功能跟組合語言中的程式計數器的功能在邏輯上是等同的,也就是說是用來指示 執行哪條指令的。

虛擬機器棧(Java Virtual Machine Stacks):

屬於執行緒私有的資料區域,與執行緒同時建立,總數與執行緒關聯,代表Java方法執行的記憶體模型。每個方法執行時都會建立一個棧楨來儲存方法的的變數表、運算元棧、動態連結方法、返回值、返回地址等資訊。具體棧幀如下:

 

棧幀

本地方法棧(Native Method Stacks):

本地方法棧屬於執行緒私有的資料區域,這部分主要與虛擬機器用到的 Native 方法相關,一般情況下,我們無需關心此區域。

這裡之所以簡要說明這部分內容,注意是為了區別Java記憶體模型與Java記憶體區域的劃分,畢竟這兩種劃分是屬於不同層次的概念。

JAVA記憶體模型

       Java記憶體模型(即Java Memory Model,簡稱JMM)本身是一種抽象的概念,並不真實存在,它描述的是一組規則或規範,通過這組規範定義了程式中各個變數(包括例項欄位,靜態欄位和構成陣列物件的元素)的訪問方式。由於JVM執行程式的實體是執行緒,而每個執行緒建立時JVM都會為其建立一個工作記憶體(有些地方稱為棧空間),用於儲存執行緒私有的資料,而Java記憶體模型中規定所有變數都儲存在主記憶體,主記憶體是共享記憶體區域,所有執行緒都可以訪問,但執行緒對變數的操作(讀取賦值等)必須在工作記憶體中進行,首先要將變數從主記憶體拷貝的自己的工作記憶體空間,然後對變數進行操作,操作完成後再將變數寫回主記憶體,不能直接操作主記憶體中的變數,工作記憶體中儲存著主記憶體中的變數副本拷貝,前面說過,工作記憶體是每個執行緒的私有資料區域,因此不同的執行緒間無法訪問對方的工作記憶體,執行緒間的通訊(傳值)必須通過主記憶體來完成。

執行緒之間通訊主要通過兩種方式(通訊是指執行緒之間以何種機制來交換資訊)

  • 共享記憶體:通過讀-寫記憶體中的公共區域進行隱式通訊。
  • 訊息傳遞:執行緒之間傳送訊息進行顯式通訊。

 

執行緒不能自己建立變數,執行緒在工作記憶體中操作的變數全都是從主記憶體【堆空間】裡copy到【執行緒棧空間】的變數副本。

JMM中規定了8種操作來完成工作記憶體和主記憶體的互動:

  • 加鎖 lock:把主記憶體中的一個變數標識為一條執行緒獨佔的狀態。
  • 解鎖 unlock:把主記憶體中處於加鎖狀態的變數釋放出來。
  • 讀取 read:把主記憶體的變數的值傳輸到工作記憶體中。
  • 載入load:把主記憶體傳輸過來的變數值放進工作記憶體的變數副本中。
  • 使用 use:把變數副本的值傳遞給執行引擎進行運算操作。
  • 賦值 assign:工作記憶體接受執行引擎傳遞過來的值放進變數副本里。
  • 儲存 store:把工作記憶體的變數副本的值傳輸給主記憶體。
  • 寫入 write :把工作記憶體接收到的值放進主記憶體的變數中。

由於共享資料區【堆】有多執行緒的操作和訪問,所以存線上程安全問題。但是工作區域內【棧】都是執行緒私有的,所以不存在其他執行緒的訪問,所以不存線上程安全問題。

併發程式設計的三個概念

  • 原子性
  • 有序性
  • 可見性

原子性

原子性:即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行,不可被中斷,不會出現中間狀態。如下例一:

x = 1;         //語句1
y = x;         //語句2
x++;           //語句3
x = x + 1;     //語句4

以上四個語句那幾個是原子操作呢,答案只有語句一。語句一是直接將10寫入到工作記憶體中。

  語句2實際上包含2個操作,(1)、先去讀取x的值;(2)、再將x的值寫入記憶體;單一的操作是原子的,但是兩步合起來就不是了;

  同樣的,x++和 x = x+1包括3個操作:(1)、讀取x的值;(2)、進行加1操作;(3)、寫入新的值。

   所以上面4個語句只有語句1的操作具備原子性。

  也就是說,只有簡單的讀取、賦值(而且必須是將數字賦值給某個變數,變數之間的相互賦值不是原子操作)才是原子操作。

  另外一個很經典的例子就是銀行賬戶轉賬問題:

  比如從賬戶A向賬戶B轉1000元,那麼必然包括2個操作:從賬戶A減去1000元,往賬戶B加上1000元。

  試想一下,如果這2個操作不具備原子性,會造成什麼樣的後果。假如從賬戶A減去1000元之後,操作突然中止。然後又從B取出了500元,取出500元之後,再執行 往賬戶B加上1000元 的操作。這樣就會導致賬戶A雖然減去了1000元,但是賬戶B沒有收到這個轉過來的1000元。

  所以這2個操作必須要具備原子性才能保證不出現一些意外的問題。

       值得注意的是:對於32位系統的來說,對於基本資料型別,byte,short,int,float,boolean,char讀寫是原子操作。而long和double則是64位的儲存單元,對它們的操作不是原子的。這樣會導致一個執行緒在寫時,操作完前32位的原子操作後,輪到B執行緒讀取時,恰好只讀取到了後32位的資料,這樣可能會讀取到一個既非原值又不是執行緒修改值的變數,它可能是“半個變數”的數值,即64位資料被兩個執行緒分成了兩次讀取,需要注意一下。

可見性

  可見性是指當多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值。如下例二:

//執行緒thread1執行的程式碼
int i = 0;
i = 2;
 
//執行緒thread2執行的程式碼
j = i;

  假若執行執行緒1的是CPU1,執行執行緒2的是CPU2。由上面的分析可知,當執行緒1執行 i =10這句時,會先把i的初始值載入到CPU1的快取記憶體中,然後賦值為2,那麼在CPU1的快取記憶體當中i的值變為2了,卻沒有立即寫入到主存當中。

  此時執行緒2執行 j = i,它會先去主存讀取i的值並載入到CPU2的快取當中,注意此時記憶體當中i的值還是0,那麼就會使得j的值為0,而不是2.

  這就是可見性問題,執行緒1對變數i修改了之後,執行緒2沒有立即看到執行緒1修改的值。

       對於序列程式來說,可見性是不存在的,因為我們在任何一個操作中修改了某個變數的值,後續的操作中都能讀取這個變數值,並且是修改過的新值。但在多執行緒環境中可就不一定了,前面我們分析過,由於執行緒對共享變數的操作都是執行緒拷貝到各自的工作記憶體進行操作後才寫回到主記憶體中的,這就可能存在一個執行緒A修改了共享變數x的值,還未寫回主記憶體時,另外一個執行緒B又對主記憶體中同一個共享變數x進行操作,但此時A執行緒工作記憶體中共享變數x對執行緒B來說並不可見,這種工作記憶體與主記憶體同步延遲現象就造成了可見性問題,另外指令重排以及編譯器優化也可能導致可見性問題。

有序性

有序性:即程式執行的順序按照程式碼的先後順序執行。

看下面程式碼,如下例三:

int i = 0;            //語句1
boolean result = true;//語句2
i = 2;                //語句3  
result = false;         //語句4

上面程式碼定義了一個int型變數,定義了一個boolean型別變數,然後分別對兩個變數進行賦值操作。從程式碼順序上看,語句1是在語句2前面的,那麼JVM在真正執行這段程式碼的時候會保證語句1一定會在語句2前面執行嗎?不一定,為什麼呢?這裡可能會發生指令重排序(Instruction Reorder)。

  下面解釋一下什麼是指令重排序,一般來說,處理器為了提高程式執行效率,可能會對輸入程式碼進行優化,它不保證程式中各個語句的執行先後順序同程式碼中的順序一致,但是它通過指令之間的依賴性來保證程式最終執行結果和程式碼順序執行的結果是一致的。指令重排包括:編譯期重排,指令並行的重排和記憶體系統的重排。編譯器優化的重排屬於編譯期重排,指令並行的重排和記憶體系統的重排屬於處理器重排,在多執行緒環境中,這些重排優化可能會導致程式出現記憶體可見性問題

  比如上面的程式碼中,語句1和語句2誰先執行對最終的程式結果並沒有影響,那麼就有可能在執行過程中,語句2先執行而語句1後執行。但是語句2和4之間有依賴關係,指令4依賴指令2,所以2一定在4之前執行。

程式的順序是:1-2-3-4,但是jvm實際執行的順序可以是:2-4-1-3,1-3-2-4,2-1-3-4。

雖然重排序不會影響單個執行緒內程式執行的結果,但是多執行緒就不一定了?如下例四:

//執行緒1:
config = getConfig();   //語句1
inited = true;             //語句2
 
//執行緒2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(config);

上面程式碼中,由於語句1和語句2沒有資料依賴性,因此可能會被重排序。假如發生了重排序,線上程1執行過程中先執行語句2,而此是執行緒2會以為初始化工作已經完成,那麼就會跳出while迴圈,去執行doSomethingwithconfig(config)方法,而此時config並沒有配置完成。

   從上面可以看出,指令重排序不會影響單個執行緒的執行,但是會影響到執行緒併發執行的正確性。

  也就是說,要想併發程式正確地執行,必須要保證原子性、可見性以及有序性。只要有一個沒有被保證,就有可能會導致程式執行不正確。

JMM提供的解決方案

對於上面提到的原子性,可見性以及有序性問題,JMM是如何保證的呢

在Java記憶體模型中都提供一套解決方案供Java工程師在開發過程使用:

原子性問題:除了JVM自身提供的對基本資料型別讀寫操作的原子性外,對於方法級別或者程式碼塊級別的原子性操作,可以使用synchronized關鍵字或者重入鎖(ReentrantLock)保證程式執行的原子性。

可見性問題:對於工作記憶體與主記憶體同步延遲現象導致的可見性,也可以使用synchronized和volatile關鍵字解決,它強制了執行緒的同步序列對臨界資源的修改,使其他執行緒可見。對於指令重排導致的可見性問題和有序性問題,則可以利用volatile關鍵字解決,因為volatile的另外一個作用就是通過記憶體屏障禁止重排序優化;

有序性問題:除了synchronized和volatile關鍵字外,JMM內部還定義一套happens-before 原則來保證多執行緒環境下兩個操作間的原子性、可見性以及有序性。

happens-before 原則

(1)程式順序原則,即在一個執行緒內必須保證語義序列性,也就是說按照程式碼順序執行。

(2)鎖規則 解鎖(unlock)操作必然發生在後續的同一個鎖的加鎖(lock)之前,也就是說,如果對於一個鎖解鎖後,再加鎖,那麼加鎖的動作必須在解鎖動作之後(同一個鎖)。

(3)volatile規則 volatile變數的寫,先發生於讀,這保證了volatile變數的可見性,簡單的理解就是,volatile變數在每次被執行緒訪問時,都強迫從主記憶體中讀該變數的值,而當該變數發生變化時,又會強迫將最新的值重新整理到主記憶體,任何時刻,不同的執行緒總是能夠看到該變數的最新值。

(4)執行緒啟動規則 執行緒的start()方法先於它的每一個動作,即如果執行緒A在執行執行緒B的start方法之前修改了共享變數的值,那麼當執行緒B執行start方法時,執行緒A對共享變數的修改對執行緒B可見

(5)傳遞性 A先於B ,B先於C 那麼A必然先於C

(6)執行緒終止規則 執行緒的所有操作先於執行緒的終結,Thread.join()方法的作用是等待當前執行的執行緒終止。假設線上程B終止之前,修改了共享變數,執行緒A從執行緒B的join方法成功返回後,執行緒B對共享變數的修改將對執行緒A可見。

(7)執行緒中斷規則 對執行緒 interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測執行緒是否中斷。

(8)物件終結規則 物件的建構函式執行,結束先於finalize()方法

注意:上述8條原則無需手動新增任何同步手段(synchronized|volatile)即可達到效果,這個是jvm在執行的時候預設遵守的原則。

volatile關鍵字

volatile是Java虛擬機器提供的輕量級的同步機制。volatile關鍵字有如下兩個作用:

  • 保證被volatile修飾的共享變數對所有執行緒總數可見的;【也就是當一個執行緒修改了一個被volatile修飾共享變數的值,新值總是可以被其他執行緒立即得知】

  • 禁止指令重排序優化。【個人認為並不是完全的禁止指令重排,只是禁止對插入記憶體屏障前後的指令重排】

volatile僅僅用來保證該變數對所有執行緒的可見性,但不保證原子性。

記憶體屏障 Memory Barrier

  記憶體屏障(Memory Barrier),又稱記憶體柵欄,是一個CPU指令。作用如下:

  • 使寫緩衝區的內容重新整理到記憶體,保證對其他執行緒/CPU可見;
  • 禁止讀寫操作的越過記憶體屏障進行重排序;

      編譯器和CPU可以在保證輸出結果一樣的情況下對指令重排序,使效能得到優化。插入一個記憶體屏障,相當於告訴CPU和編譯器先於這個命令的必須先執行,後於這個命令的必須後執行。記憶體屏障另一個作用是強制更新一次不同CPU的快取。

記憶體屏障(Memory Barrier)和volatile什麼關係?

    如果你的欄位是volatile,Java記憶體模型將在寫操作後插入一個寫屏障指令,在讀操作前插入一個讀屏障指令。這意味著如果你對一個volatile欄位進行寫操作,結果就是:

   1)、一旦你完成寫入,任何訪問這個欄位的執行緒將會得到最新的值。

   2)、在你寫入前,會保證所有之前發生的事已經發生,並且任何更新過的資料值也是可見的,因為記憶體屏障會把之前的寫入值都重新整理到快取。

volatile保證可見性嗎?

先來看下面一箇中斷程式,例五:

//中斷程式
//執行緒1
boolean volatile stop = false;
while(!stop){
    doSomething();
}
 
//執行緒2
stop = true;


     在中斷程式中,如果stop沒有用volatile修飾,一定會發生中斷嗎,答案是不確定。因為每個執行緒在執行過程中都有自己的工作記憶體,那麼執行緒1在執行的時候,會將stop變數的值拷貝一份放在自己的工作記憶體當中。那麼當執行緒2更改了stop變數的值之後,但是還沒來得及寫入主存當中,執行緒2轉去做其他事情了,那麼執行緒1由於不知道執行緒2對stop變數的更改,這樣會導致執行緒一工作記憶體中的值永遠是false,因此還會一直迴圈下去。

  所以volatile是可以保證可見性的。

導致可見性的原因:

  • 讀寫緩衝區;
  • 快取的使用;
  • 指令重排順序無法確定;

volatile如何保證可見性及禁止指令重排:

  • 使用volatile修飾,對該變數的讀取會插入一條記憶體屏障lock,cpu不會將其後面的指令放在其前面執行,反之亦然;
  • 使用volatile修飾,會強制將修改的值重新整理到主存;
  • 使用volatile修飾,對變數的寫操作會導致其他快取行的值無效,再次讀取時會到主存去讀取;

volatile保證原子性嗎?

我們先看下面一個測試

public class VolatileAtomicTest {

    public volatile int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) {

         VolatileAtomicTest atomicTest = new VolatileAtomicTest();
        for (int i = 0; i < 3; i++) {
            new Thread() {
                public void run() {
                    for (int j = 0; j < 1000; j++)
                        atomicTest.increase();
                }
            }.start();
        }
        try {
            sleep(1000);  //保證所有的執行緒都執行完畢
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(atomicTest.inc);
    }

}

  這個結果或是多少呢,我們的理想值是3000,但實際上每次都不一樣,並且該值是一個小於3000的值。可能這裡就會有疑問了,不對啊,上面是對變數inc進行自增操作,由於volatile保證了可見性,那麼在每個執行緒中對inc自增完之後,在其他執行緒中都能看到修改後的值啊,所以有3個執行緒分別進行了1000次操作,那麼最終inc的值應該是1000*3=3000。

  這裡面就有一個誤區了,volatile關鍵字能保證可見性沒有錯,但是上面的程式錯在沒能保證原子性。可見性只能保證每次讀取的是最新的值,但是volatile沒辦法保證對變數的操作的原子性。在前面例一中已經提到過,自增操作是不具備原子性的,它包括讀取變數的原始值、進行加1操作、寫入工作記憶體。那麼就是說自增操作的三個子操作可能會分割開執行,就有可能導致下面這種情況出現:

  假如某個時刻變數inc的值為10,

  執行緒1對變數進行自增操作,執行緒1先讀取了變數inc的原始值,然後執行緒1被阻塞了;

  然後執行緒2對變數進行自增操作,執行緒2也去讀取變數inc的原始值,由於執行緒1只是對變數inc進行讀取操作,而沒有對變數進行修改操作,所以不會導致執行緒2的工作記憶體中快取變數inc的快取行無效,所以執行緒2會直接去主存讀取inc的值,發現inc的值時10,然後進行加1操作,並把11寫入工作記憶體,最後寫入主存。

  然後執行緒1接著進行加1操作,由於已經讀取了inc的值,注意此時線上程1的工作記憶體中inc的值仍然為10,所以執行緒1對inc進行加1操作後inc的值為11,然後將11寫入工作記憶體,最後寫入主存。

  那麼兩個執行緒分別進行了一次自增操作後,inc只增加了1。

  解釋到這裡,可能有朋友會有疑問,不對啊,前面不是保證一個變數在修改volatile變數時,會讓快取行無效嗎?然後其他執行緒去讀就會讀到新的值,對,這個沒錯。這個就是上面的happens-before規則中的volatile變數規則,但是要注意,執行緒1對變數進行讀取操作之後,被阻塞了的話,並沒有對inc值進行修改。然後雖然volatile能保證執行緒2對變數inc的值讀取是從記憶體中讀取的,但是執行緒1沒有進行修改,所以執行緒2根本就不會看到修改的值。

  根源就在這裡,自增操作不是原子性操作,而且volatile也無法保證對變數的任何操作都是原子性的

我們可以採用synchronize/lock/automicInteger中的任何一個都可以達到上面的目標。

    // 法一:加入synchrozied,保證執行的原子性
    public synchronized void  increase() {
        inc++;
    }

    //方法二:採用lock機制
    Lock lock = new ReentrantLock();
    
    public  void increase() {
        lock.lock();
        try {
            inc++;
        } finally{
            lock.unlock();
        }
    }

    方法三:採用AtomicInteger機制
    public  AtomicInteger inc = new AtomicInteger();
     
    public  void increase() {
        inc.getAndIncrement();
    }

        atomic是利用CAS來實現原子性操作的(Compare And Swap),CAS實際上是利用處理器提供的CMPXCHG指令實現的,而處理器執行CMPXCHG指令是一個原子性操作。

    //AtomicInteger類的getAndIncrement的原始碼
    public final int getAndIncrement() {
        for (; ; ) {
            int current = get();  // 取得AtomicInteger裡儲存的數值
            int next = current + 1;  // 加1
            if (compareAndSet(current, next))   // 呼叫compareAndSet執行原子更新操作
                return current;
        }
    }

cas利用了基於衝突檢測的樂觀併發策略 ,CAS自旋volatile變數,可以很高效的解決原子問題。在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作類,即對基本資料型別的 自增(加1操作),自減(減1操作)、以及加法操作(加一個數),減法操作(減一個數)進行了封裝,保證這些操作是原子性操作。

volatile保證有序性嗎?

由於volatile能禁止指令的重排,所以在一定程度上可以保證有序性。前面也講過,volatile主要通過記憶體屏障保證了值在記憶體中的可見性及有序性。主要有以下兩點:

    (1)當程式執行到volatile變數的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對後面的操作可見;在其後面的操作肯定還沒有進行;

 (2)在進行指令優化時,不能將在對volatile變數訪問的語句放在其後面執行,也不能把volatile變數後面的語句放到其前面執行。

例如針對例四,我們做如下改動,例六:

//執行緒1:
x = 1;                              //語句1
y = 4;                              //語句2
config = getConfig();               //語句3

volatile inited = false;             //語句4  volatile


//執行緒2:
while( !inited ){
  x = x + 1;                          //語句5
  y = y + 1;                          //語句6
}
doSomethingwithconfig(config);

      我們知道在例四中由於語句3-4沒有依賴性,可能會發生指令重排,可能導致config沒有獲取到配置資訊,當執行緒2去執行的時候出錯。但是當我們加上volatile後,就可以避免此類問題的發生。因為volatile保證了在執行語句4的時候,語句1-2-3一定執行完了,1-2-3的執行結果對語句5-6是可見的,禁止了volatile前後語句的指令重排序,保證來指令執行的有序,避免了例四的問題發生。但是語句1-2-3和語句5-6的執行順序是不做保證的。

另外還有一個經典的double-check單例模式的應用,如下例七:

    //TODO 通過volatile設定記憶體屏障,禁止指令排序,使寫先與讀;
    private static volatile DoubleCheckLockSingletonTest singleInstance;

    private DoubleCheckLockSingletonTest() {
    }
    public static DoubleCheckLockSingletonTest getInstance() {
        if (singleInstance == null) {
            synchronized (DoubleCheckLockSingletonTest.class) {
                if (singleInstance == null) {
                    singleInstance = new DoubleCheckLockSingletonTest();
                }
            }
        }
        return singleInstance;
    }

以上程式碼中instance為什麼需要用volatile來修飾,主要設計到指令的重排和原子操作。

主要在於singleInstance = new DoubleCheckLockSingletonTest();這句,這並非是一個原子操作,事實上在 JVM 中這句話大概做了下面 3 件事情:

memory = allocate();                   //第一步:給 singleton 分配記憶體;
DoubleCheckLockSingletonTest(memory);  //第二步:呼叫 建構函式來初始化成員變數,形成例項;
singleInstance = memory;               //第三步:將singleInstance物件指向分配的記憶體空間(執行完這步 singleInstance才是非 null 了);

但是由於步驟2-3之間沒有依賴性,所以步驟2-3可能會發生指令的重排序。這種重排序在序列的單執行緒是OK的,但是如果發生在高併發的多執行緒將產生不可估計的後果。有可能產生如下的執行順序:

//2-3步發生來指令的重排序
memory = allocate();                   //第一步:給 singleton 分配記憶體;
singleInstance = memory;               //第三步:將singleInstance物件指向分配的記憶體空間(執行完這步 singleInstance才是非 null 了);
DoubleCheckLockSingletonTest(memory);  //第二步:呼叫 建構函式來初始化成員變數,形成例項;

如果此時執行緒一正執行到重排後的第三步還未完成,此時執行緒2請求到達後,判斷singleInstance不為空,但是例項化為完成,此時執行緒2返回的將是一個【執行緒一初始化未完成的例項這樣一箇中間狀態的值】,所以肯定會出問題。

這裡的關鍵是:執行緒一沒有完成初始化,執行緒2就讀取來其中的內容。所以volatile就派上來用場,volatile關鍵字的一個作用是禁止指令重排,把singleInstance宣告為volatile之後,對它的寫操作就會有一個記憶體屏障,這樣在singleInstance的賦值寫操作完成之前,就不用會呼叫對其讀操作,從而保證了有序性。

注意:volatile阻止的不singleton = new Singleton()這句話內部[1-2-3]步的指令重排,而是保證了在一個寫操作([1-2-3])步完成之前,不會呼叫讀操作(if (singleInstance == null))。

volatiile的實現原理

  “觀察加入volatile關鍵字和沒有加入volatile關鍵字時所生成的彙編程式碼發現,加入volatile關鍵字時,會多出一個lock字首指令”

  lock字首指令實際上相當於一個記憶體屏障(也成記憶體柵欄),上面也有講過,記憶體屏障會提供3個功能:

  1.   它確保指令重排序時不會把其後面的指令排到記憶體屏障之前的位置,也不會把前面的指令排到記憶體屏障的後面;即在執行到記憶體屏障這句指令時,在它前面的操作已經全部完成;
  2.   它會強制將對快取的修改操作立即寫入主存;
  3.   如果是寫操作,它會導致其他CPU中對應的快取行無效。

volatile的使用

注意:      

   synchronized關鍵字是防止多個執行緒同時執行一段程式碼,那麼就會很影響程式執行效率,而volatile關鍵字在某些情況下效能要優於synchronized,但是要注意volatile關鍵字是無法替代synchronized關鍵字的,因為volatile關鍵字無法保證操作的原子性,存在依賴關係的時候。

     ##只有保證操作的原子性,才能保證使用volatile關鍵字的程式在併發時能夠正確執行,否則將會發生意想不到的事情。

      不要將volatile用在getAndOperate場合(這種場合不原子,需要再加鎖synchronized或者lock或者使用Atomic*類),僅僅set或者get的場景是適合volatile的

缺點:

  • 不保證原子性
  • 讀記憶體的開銷

總結

  • synchronized: 具有原子性,有序性和可見性
  • volatile:具有有序性和可見性

 

初學java,積少成多。資料參考:

《java併發程式設計的藝術》 方騰飛 程曉明

《深入理解jvm虛擬機器》 周志明

https://blog.csdn.net/caoshangpa/article/details/78853919
https://blog.csdn.net/u012233832/article/details/79619648
https://www.cnblogs.com/dolphin0520/p/3920373.html
https://blog.csdn.net/zdxiq000/article/details/60874848
https://blog.csdn.net/javazejian/article/details/72772461#java%E5%86%85%E5%AD%98%E5%8C%BA%E5%9F%9F
http://www.cnblogs.com/Mainz/p/3556430.html

感謝以上的有心的作者提供了很不錯的學習資料。

 

 

相關文章