JUC-volatile關鍵字作用

一、前言

本文意在講解java中volatile關鍵字的作用以及原理,因爲該關鍵字可以說是JMM模型封裝底層原語而提供出來的API,他的實現涉及到cpu的指令以及線程內存與主存間的交互過程,因此本文會從cpu到緩存內存,再到線程以及JMM,通過簡單的介紹線程、緩存、cpu的大致流程後,在此基礎之上講解volatile的作用以及原理,以便於更加深刻的理解volatile關鍵字的含義。

二、計算機內存模型

cpu組成結構?cpu是計算機的核心,也稱作中央處理單元(Central Processing Unit),它支配着整個計算機的運行,可以進行復雜的運算以及任務的調度。爲了便於理解cpu以及本文中心,本文中忽略cpu其他部分,我們僅提出cpu中最重要的兩個單元:Processing Unit(運算邏輯單元 PU)、Architectual State(架構狀態單元 AS),PU主要負責運算,AS主要負責控制、調度、內存訪問等。

超線程概念?按照上面的結構描述,理論上來講cpu每個核心都是由一個PU和一個AS所構成的,也就是說一個核心同時只能處理一個線程(同一時刻PU只能進行單運算),爲了增加cpu中PU工作效率,出現了超線程的概念,其大概實現方式是增加AS的數量,也就是說一個核心有一個PU,在一個AS基礎上再增加一個AS但單元,可以理解爲,本來一個cpu核心處理一個任務隊列中的任務,現在一個內核對應着兩個任務隊列,實現的主要思想就是合理範圍內壓榨cpu,讓其儘可能發揮運算能力,減少停滯的時間。在我們的計算機中會發現關於cpu核心的描述,2核4線程、4核8線程,這種描述可以理解爲是超線程的實現的一個描述:

高速緩存產生?因爲只有一個PU中心,因此我們依然認爲一個核心同時刻只能處理一個線程。每當cpu進行運算時,所需要的數據信息需要從內存獲取,但是隨着cpu迅速的發展,內存數據提取的速度已經遠遠限制了cpu運算速度,在這種背景下,催生出了高速緩存的概念,作爲主存和cpu之間的緩衝地帶,其讀取速度是遠遠大於內存的讀取速度的,可以把數據從主存拿出來後放到高速緩存,這樣下次訪問就可以通過高速緩存,彌補內存和cpu之間的速度差異,這樣可以儘可能避免內存讀取,且儘可能的讓cpu發揮其運算能力。

隨着cpu的發展,一層緩存效果逐漸下降,就應運而生了多級緩存,目前高速緩存大致分爲三層,L1、L2、L3(有的處理器沒有L3),緩存級別越高,成本越高,緩存容量越小,簡易的核心緩存和線程結構如下(不同cpu緩存級別不同):

 對我們程序而言會產生什麼問題?通過上圖可以瞭解到,多核心的cpu,其每個核心有自己的L1、(L2),共享L2、(L3),因爲核心之間的高速緩存中緩存着內存中的共享變量,而核心之間的高速緩存又因爲隔離性互相不可知,只能通過主存方式進行交互,這種內存交互方式可能會引發緩存一致性的問題。

處理器優化、指令重排?除了緩存一致性問題之外,cpu有時候爲了充分利用資源,會根據情況進行指令優化的操作,也就是說可能會對輸入的代碼按照非輸入順序執行,也就是所謂的處理器優化,雖然在單線程內不會影響指令段的結果,但是多心線程的操作下有可能會引發問題,下面會通過實例講解。另外,cpu以及一些編譯器會對代碼進行指令的優化處理,發生指令重排。比如jvm編譯器在運行時可能發生此操作,此刻new一個對象,因爲初始化內存是比較耗時的,所以可能會先把對象指針指向該內存地址,然後在初始化對象。以上兩種情形,發生的必要條件是單線程內,指令順序改變不會影響該線程程序的執行結果。

我們知道併發編程中有三個特點屬性,且我們上述提到的三個問題,也正是破壞這三個要素的相應體現:

可見性:線程之間對共享變量的修改,互相可知;(緩存一致性)

原子性:一個或者多個操作指令要麼全部執行成功,要不都不執行;(處理器優化)

順序性:程序的執行按照代碼的順序進行;(指令重排)

三、JMM模型

上面提到的幾個問題,在jvm中提供了相對應的解決方案(底層通過調用cpu指令),定義了一套關於jvm中線程工作內存和主存的通信規範,以及封裝了一些源語,在java中以關鍵字等方式提供出來,例如關鍵字volatile等,這個方案就是JMM模型。下圖展示了jvm中線程通信的方式,以及jmm模型的工作範圍:

可以看到JMM主要的工作區域是工作內存和主存之間的交互通信,包括jvm裏這種工作內存-主存的內存架構方式也是JMM模型定義的一種規範。每個線程都有自己的工作內存,不可以直接在內存中直接操作變量,需要從主存取出放到自己的工作內存,然後在刷入主存,線程之間的工作內存互不可見,相互隔離。

JMM模型如何解決破壞併發三要素的問題?也就是JMM提供了哪些方式,讓我們可以保證併發編程原子性、可見性、順序性呢?

原子性:保證命令的執行原子性,我們可以使用java中提供的synchronized關鍵字,這個關鍵字實際上是封裝了底層的原語而暴露於java中的一個關鍵字,可以使用在加載方法、代碼塊上。兩種方式在底層的實現上是有一些差別的,使用了不同的字節碼命令,前者是給方法加了一個ACC_SYNCHRONIZED標誌,訪問此方法的線程需要先獲取鎖,後者則是在同步代碼塊的前加monitorenter(加鎖),後加monterexit命令(釋放鎖)。

可見性:上面提到JMM模型定義的java內存交互是以工作內存和主存的方式來進行信息交互的,如果要保持變量的可見性,那麼就需要各個線程間的共享變量的工作內存副本互相知道變化,java中的volatile關鍵字可以實現可見性,一個線程操作完共享變量,立刻刷進主存,且使其他線程工作內存中該變量的緩存失效,從而每次讀主存,保證可見性;除了volatile關鍵字可以保證可見性以外,synchronized和final關鍵字也可以保證此功能;

有序性:可以使用volatile關鍵字保證有序性,該關鍵字會建立內存屏障,使用jvm的字節碼指令lock,保證前後的順序,禁止指令重排序,下面會詳細介紹關鍵字volatile;

四、volatile關鍵字

剛剛上文提到volatile可以保證可見性和以及有序性,下面我們詳細的介紹一下該關鍵字實現的原理

作用?當我們用volatile關鍵字修飾一個變量後,會產生兩個作用:

1.當有線程修改該變量後,會立即將該變量刷回主存,同時,會使其它線程工作內存的該變量副本緩存失效;

2.會保證該變量的有序性,避免編譯器處理器的指令重排,該變量前後的指令順序有序;

可見性實現原理?下面從實例方面我們看一下volatile關鍵字的的效果:

package com.ldy.com.test;

public class VolatileTest {
    private static int i = 0;
    static class MyRunnable implements Runnable{
        @Override
        public void run() {
            try {
                Thread.sleep(10L);//避免快於主線程
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            i++;
        }
    }
    public static void main(String[] args) {
        Thread thread = new Thread(new MyRunnable());
        thread.start();
        while(i == 0){//檢測I值變化

        }
        System.out.println("over !");
    }
}

那麼程序的運行結果是什麼呢?也許你可以想到,那就是main函數一直在while循環,儘管線程thread進行了i++操作,因爲按照JMM內存模型的約定,線程的工作內存互相不可見,也就是說子線程thread雖然進行了i++操作,但是並沒有主動通知主線程,告訴他工作內存中的i已經變更了,因此會一直進行while循環,可以看到thread線程睡了10ms,目的是爲了將i的變更延遲到主線程讀取i的時間點,這樣才能驗證該結論,否則主線程可能比thread線程執行的快。

這個時候,我們用volatile關鍵字對i進行修飾,可以知道執行結果,最終會退出:

這個實例可以驗證volatile的第一個作用, 當有線程修改該變量後,會立即將該變量刷回主存,同時,會使其它線程工作內存的該變量副本緩存失效。因此當子線程修改了volatile關鍵字修飾的i後,會立刻將該值刷進主存,同時會使其他線程工作內存中的i的緩存失效,使得他們從主存獲取最新的值,因此主線程檢測到i值改變後,退出循環而結束。

lock前綴關鍵字?底層的原理在上面其實已經提及到了一些,本質上是命令前添加了一個指令前綴,也就是彙編語言中的lock前綴,該指令前綴有保證可見性以及有序性的作用。在cpu的架構中,多個cpu核心是通過總線進行內存操作,他們有各自有各自的高速緩存,但是都是通過總線進行傳遞信息的,因此可以各個核心可以通過“嗅探”總線上的消息,監聽到他們的內存副本關聯是否變更,從而判定緩存是否失效,再次讀取該變量時則重新去主存讀取。

當一個lock前綴修飾的指令進行內存修改操作時,處理器會發出一個lock信號,會進行一個明顯的總線鎖定操作。一般該鎖定是由高速緩存鎖或者總線鎖來做。如果該內存訪問有高速緩存並且隻影響高速緩存的一行,則會使用緩存鎖定,而系統總線和系統內存不會鎖定,並且其他處理器會立即回寫已經修改的數據,同時使他們的高速緩存失效;如果本次內存訪問沒有高速緩存或者佔據了高速緩存不僅僅一行,那麼就會使用總線鎖定,此時總線不會響應其他處理器的總線控制請求,也就是阻塞其他處理器的內存操作,這種鎖定效率是非常低下的,因此現在一般處理器都會採用緩存一致性的方式來保證緩存的數據的可見一致性。

時下比較常用的緩存一致性協議是MESI協議,工作機制使用剛剛上面提到的“嗅探”機制,所有內存的傳輸都發生在一條共享的總線上,而所有的處理器都能看到這條總線:緩存本身是獨立的,但是內存是共享資源,所有的內存訪問都要經過仲裁(同一個指令週期中,只有一個CPU緩存可以讀寫內存)。

CPU緩存不僅僅在做內存傳輸的時候才與總線打交道,而是不停在嗅探總線上發生的數據交換,跟蹤其他緩存在做什麼。所以當一個緩存代表它所屬的處理器去讀寫內存時,其它處理器都會得到通知,它們以此來使自己的緩存保持同步。只要某個處理器一寫內存,其它處理器馬上知道這塊內存在它們的緩存段中已失效。

根據此結論,可以分析上述實例的執行過程,可以看到上面的實例中一共有兩個線程,主線程和手動創建的子線程,

1.當主線程執行到while判斷的時候,會去內存中取i的值,第一次從內存中取的i=0,放入工作緩存中,繼續while判斷;

2.子線程沉睡時間已過,當子線程修改volatile修飾變量i值後,子線程發出lock前綴指令,處理器接收到該指令後鎖總線或鎖定緩存,並將修改的i的最新值刷到主存;

3.主線程在cpu運行期間,嗅探到i值緩存的變化,使得高速緩存失效,需要到內存中獲取最新值,此時i=1,退出while循環。

有序性實現原理?volatile使用內存屏障(Memory Barrier)來保證代碼的順序性,也就是禁止指令重排序,內存屏障的含義如名字所示,加了一道屏障,是不可以逾越的,強制保證某種順序關係,在介紹內存屏障前,我們瞭解一下jmm的8種內存操作類型:

1.lock:鎖定,作用於主存,標記一個變量爲線程獨佔狀態;

2.read:讀取,作用於主存,將主存的變量值傳遞到工作內存;

3.load:加載,作用於工作內存,將load進來的變量存入工作內存變量副本;

4.use:使用,作用於工作內存,將該變量副本傳給執行引擎,每當虛擬機需要使用一個變量的時候,都會出現該指令;

5.assign:賦值,作用於工作內存,將引擎出來的變量值賦值到工作內存中的變量副本,每當執行引擎需要賦值時使用;

6.store:存儲,作用於工作內存,將工作內存中的變量傳遞到主存,以供write使用;

7.write:寫入,作用於主存,將傳遞進來的變量值寫入主存;

8.unlock:解鎖,解除一個變量的鎖定狀態,其他線程可以鎖定;

這些指令使用有一些注意事項,比如read和load、store和write兩對指令必須成對的出現,不允許單一指令出現,但是指令之間可以插入其他的指令;一個變量在同一時刻只能被一個線程lock,但是可以被lock線程多次,unlock也要執行對應次數才能釋放所有的鎖;新的變量只能從主存中產生,也就是說assign、store、write的變量必然經歷了load、use的過程;

        int i = 1;  //1
        int j = 2;  //2
        x = i + j;  //3
        int y = 3;  //4

圖示的代碼,x依賴於i和j的值,y無依賴,因此3肯定發生於1和2之前,但是4可能發生於3之前,這個包括編譯器以及處理器的優化,假如處理器計算x的值時,發現j的值還沒準備好,那麼爲了提高cpu的利用率,在不影響該線程計算結果的情況下,可能會優先執行4,然後回過頭在執行3。

假設有如下代碼片段,其中x是volatile修飾的片段:

        i = 1;  //1
        j = 2;  //2
        x = true;  //3 volatile
        i = 3;  //4
        j = 4;  //5
        

x = true是寫操作,因此volatile關鍵字會在該行代碼前後加上內存屏障,保證在執行該volatile變量的寫指令時,前面都是完成的。

jmm定義了四種內存屏障指令:

LoadLoad屏障:對於這樣的語句Load1; LoadLoad; Load2,在Load2及後續讀取操作要讀取的數據被訪問前,保證Load1要讀取的數據被讀取完畢。

StoreStore屏障:對於這樣的語句Store1; StoreStore; Store2,在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。

LoadStore屏障:對於這樣的語句Load1; LoadStore; Store2,在Store2及後續寫入操作被刷出前,保證Load1要讀取的數據被讀取完畢。

StoreLoad屏障:對於這樣的語句Store1; StoreLoad; Load2,在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。它的開銷是四種屏障中最大的,會把緩存中的變量刷入主存。在大多數處理器的實現中,這個屏障是個萬能屏障,兼具其它三種內存屏障的功能。

在我們jmm中按照保守的策略插入屏障規則如下:

在每一個寫操作前面插入storestore指令;

在每一個寫操作後面插入storeload指令;

在每一個讀操作後面插入loadstore指令;

在每一個讀操作後面插入loadload指令;

因此在上面的例子中,x = true寫操作,前面插入storestore保證其他處理器可見,後面插入storeload刷新髒緩存到主存,且其他處理器該變量緩存失效。

其實內存屏障是硬件層的概念,在不同的硬件平臺中,實現內存屏障的手段並不是一樣,java通過屏蔽這些差異,統一由jvm來生成內存屏障的指令,在根據相應規則插入屏障,Lock是軟件指令。

五、資源地址

官網:http://www.java.com

文檔:《Thinking in java》jdk1.8版本源碼

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