你說一下對Java中的volatile的理解吧,以及它是怎麼保證可見性的。

紀莫發表於2020-11-05

前言

volatile相關的知識其實自己一直都是有掌握的,能大概講出一些知識,例如:它可以保證可見性禁止指令重排。這兩個特性張口就來,但要再往深了問,具體是如何實現這兩個特性的,以及在什麼場景下使用volatile,為什麼不直接用synchronized這種深入和擴充套件相關的問題,就回答的不好了。因為volatile是面試必問的知識,所以這次準備把這部分知識也給啃掉。

系統處理效率與Java記憶體模型

在計算機中,每條程式指令都是在CPU中執行的,而CPU執行指令的資料都是臨時儲存在記憶體中的,但是CPU的執行速度遠超記憶體的讀取速度,如果所有的CPU指令都是通過記憶體來讀取資料的話那麼將大大的降低了系統的處理效率,所以現代計算機系統都不得不加入一層或多層讀寫速度儘可能接近處理器運算速度的快取記憶體(Cache)來作為記憶體與處理器之間的緩衝

將運算需要使用的資料複製到快取中,讓運算能快速進行,當運算結束後,在從快取同步回記憶體之中,這樣處理器就無須等待緩慢的記憶體讀寫了。

雖然說增加了快取記憶體提高了CPU的處理效率,但是也帶來了新的問題 :

現代計算機都是多核CPU,一開始,記憶體中的變數A的值是1,第一個CPU讀取了資料,第二個CPU也將資料讀取到了自己的快取記憶體當中,當第一個CPU對變數A進行加1操作時,變數A的值變成了2,然後將將變數A的值寫回記憶體中,這時第二個CPU也對變數A進行加1操作時,由於第二個CPU中快取記憶體中的值還是1,所以加1操作後的結果為2,然後第二個CPU又將變數A的值同步回記憶體中,這樣就導致執行了兩次加1操作後,變數A的值最終是2,而不是3。
這種被多個CPU訪問的變數,通常稱為共享變數。
而產生的上面的問題,就是引入了快取記憶體後的,主記憶體和快取內容不一致的問題。
因為每個處理器有自己的快取記憶體,但是它們又共享同一塊主記憶體,所以必然會出現主記憶體不知該以哪個快取記憶體中的變數為準的情況。
在這裡插入圖片描述
上面這個快取不一致的問題,我們先記下來,繼續來看Java記憶體模型,其實Java記憶體模型描述的上面講的計算機系統快取記憶體和記憶體之間的關係類似。

Java記憶體模型描述了,各種變數的訪問規則,以及將變數儲存到記憶體和從記憶體讀取變數的這種底層細節。

在Java記憶體模型中關注的變數都是共享變數(例項變數、類變數)。
所有的共享變數都是儲存在主記憶體中的,但是每個執行緒在訪問變數的時候也都會在自己的工作記憶體處理器快取記憶體)中保留一份共享變數的副本。

Java記憶體模型(Java Memory Model,簡稱JMM)規定:

執行緒對變數的所有操作(讀,寫)都必須在工作記憶體中進行,不能直接操作主記憶體中的資料。
不同執行緒之間 也不能直接訪問對方工作記憶體中的變數,執行緒間的變數值傳遞必須通過主記憶體進行中轉傳遞。
在JMM中工作記憶體和主記憶體的關係如下圖:
在這裡插入圖片描述

Volatile的可見性(保證立即可見)

繼續我們上面的快取一致性的問題,這個問題,在Java記憶體模型中,就是可見性的問題,即一個執行緒修改了共享變數的值,對另一個執行緒來說是不是立即可見的。如果不是立即可見的,那麼就會出現快取一致性的問題,如果是立即可見的,那麼另一個執行緒在進行操作的時候,拿到的變數值就是最新的。就可以解決可見性的問題。

那麼怎麼解決可見性問題呢?

  • 方案一:加鎖

將共享變數加鎖,無論是synchronized還是Lock都可以,加鎖達到的目的是在同一時間內只能有一個執行緒能對共享變數進行操作,就是說,共享變數從讀取到工作記憶體到更新值後,同步回主記憶體的過程中,其他執行緒是操作不了這個變數的。這樣自然就解決了可見性的問題了,但是這樣的效率比較低,操作不了共享變數的執行緒就只能阻塞。

  • 方案二:volatile修飾修飾共享變數

當一個共享變數被volatile修飾後,會保證每個執行緒將變數修改後的值立即同步回主記憶體中,當其他執行緒有需要讀取變數時會讀取到最新的變數值。

那麼volatile做了些什麼操作就能解決可見性的問題呢?

volatile修飾的變數,在被執行緒操作時,會有這樣的機制:

就是執行緒對變數操作時會從主記憶體中讀取到自己的工作記憶體中,當執行緒對變數進行了修改後,那麼其他已經讀取了此變數的執行緒中的變數副本就會失效,這樣其他執行緒在使用變數的時候,發現已經失效,那麼就會去主記憶體中重新獲取,這樣獲取到的就只最新的值了。

那麼volatile這個關鍵字是如何實現這套機制的呢?

因為一臺計算機有多臺CPU,同一個變數,在多個CPU中快取的值有可能不一樣,那麼以誰快取的值為準呢?

既然大家都有自己的值,那麼各個CPU間就產生了一種協議,來保證按照一定的規律為準,來確定共享變數的準確值,這樣各個CPU在讀寫共享變數時都按照協議來操作。

這就是快取一致性協議。

最著名的快取一致性協議就是Intel的MESI了,說MESI時,先解釋一下,快取行:

快取行(cache line):CPU快取記憶體的中可以分配的最小儲存單位,快取記憶體中的變數都是存在快取行中的。

MESI的核心思想就是,當CPU對變數進行寫操作時發現,變數是共享變數,那麼就會通知其他CPU中將該變數的快取行設定為無效狀態。當其他CPU在操作變數時發現此變數在的快取行已經無效,那麼就會去主記憶體中重新讀取最新的變數。

  • 那麼其他CPU是如何發現變數被修改了的呢?

因為CPU和其他部件的進行通訊是通過匯流排來進行的,所以每個CPU通過嗅探匯流排上的傳播資料,來檢查自己快取的值是不是過期了,當處理器發現自己換成行對應的記憶體地址被修改後,就會將自己工作記憶體中的快取行設定成無須狀態,當CPU對此變數進行修改時會重新從系統主記憶體中讀取變數。

在這裡插入圖片描述

Volatile的有序性(禁止指令重排)

一般來說,我們寫程式的時候,都是要把先程式碼從上往下寫,預設的認為程式是自頂向下順序執行的,但是CPU為了提高效率,在保證最終結果準確的情況下,是會對指令進行重新排序的。就是說寫在前的程式碼不一定先執行,在後面的也不一定晚執行。

舉個例子:

int a = 5; // 程式碼1
int b = 8; // 程式碼2
a = a + 4;	// 程式碼3
int c = a + b;	// 程式碼4

上面四行程式碼的執行順序有可能是
在這裡插入圖片描述
JMM在是允許指令重排序的,在保證最後結果正確的情況下,處理器可以盡情的發揮,提高執行效率。

當多個執行緒執行程式碼的時候重排序的情況就更為突出了,各個CPU為了提高自己的效率,有可能會產生競爭情況,這樣就有可能導致最終執行的正確性。

所以為了保證在多個執行緒下最終執行的正確性,將變數用volatile進行修飾,這樣就會達到禁止指令重排序的效果(其實也可以通過加鎖,還有一些其他已知規則來實現禁止指令重排序,但是我們這裡只討論volatile的實現方式)。

那麼volatile是如何實現指令重排序的呢?

答案是:記憶體屏障

記憶體屏障是一組CPU指令,用於實現對記憶體操作的順序限制。
Java編譯器,會在生成指令系列時,在適當的位置會插入記憶體屏障來禁止處理器對指令的重新排序。

volatile會在變數寫操作的前後加入兩個記憶體屏障,來保證前面的寫指令和後面的讀指令是有序的。
在這裡插入圖片描述

volatile在變數的讀操作後面插入兩個指令,禁止後面的讀指令和寫指令重排序。
在這裡插入圖片描述
有序性,不僅只有volatile能保證,其他的實現方式也能保證,但是如果每一種實現方式都要了解那對於開發人員來說就比較困難了。

所以從JDK5就出現了happen-before原則,也叫先行發生原則。
先行發生原則總結起來就是:如果一個操作A的產生的影響能被另一個操作B觀察到,那麼可以說,這個操作A先行發生與操作B。

這裡所說的影響包括記憶體中的變數的修改,呼叫了方法,傳送量訊息等。

volatile中的先行發生原則是,對一個volatile變數的寫操作,先行發生於後面任何地方對這個變數的讀操作。

Volatile無法保證原子性

原子性,是指一個操作過程要麼都成功,要麼都失敗,是一個獨立的完整的。

就像上面說的,如果多個執行緒對一個變數進行累加,那麼肯定得不到想要的結果,因為累加就不是一個原子操作。

要保證累加最終結果正確,要麼對累加變數加鎖,要麼就用AotomicInteger這樣的變數。

/**
 * 雙重檢查加鎖式單例
 */
public class DoubleCheckLockSingleton implements Serializable{

    /**
     * 靜態變數,用來存放例項。
     */
    private volatile static DoubleCheckLockSingleton doubleCheckLockSingleton = null;

    /**
     * 私有化構造方法,禁止外部建立例項。
     */
    private DoubleCheckLockSingleton(){}

    /**
     * 雙重檢查加鎖的方式保證執行緒安全又能獲得到唯一例項
     * @return
     */
    public static DoubleCheckLockSingleton getInstance(){
        //第一次檢查例項是否已經存在,不存在則進入程式碼塊
        if(null == doubleCheckLockSingleton){
            synchronized (DoubleCheckLockSingleton.class){
                //第二次檢查
                if(null==doubleCheckLockSingleton){
                    doubleCheckLockSingleton = new DoubleCheckLockSingleton();
                }
            }
        }

        return doubleCheckLockSingleton;
    }

}

為什麼要進行雙重檢查呢?
當第一個執行緒走到第一次檢查時發現物件為空,然後進入鎖,第二次就檢查時也為空,那麼就去建立物件,但是這個時候又來了一個執行緒來到了第一次檢查,發現為空,但是這個時候因為鎖被佔用,所以就只能阻塞等待,然後第一個執行緒建立物件成功了,由於物件是被volatile修飾的能夠立即反饋到其他執行緒上,所以在第一個執行緒釋放鎖之後,第二個執行緒進入了鎖,然後進行第二次檢查時,發現物件已經被建立了,那麼就不在建立物件了。從而保證的單例。

還有就是如果建立物件,步驟:

  1. 分配記憶體空間。
  2. 呼叫構造器,例項化。
  3. 返回記憶體地址給引用。

如果這三個指令順序被重排了,那麼當多執行緒來獲取物件的時候就會造成物件雖然例項化了,但是沒有分配記憶體空間,會有空指標的風險。
所以加上了volatile的物件,也保證了在第二次檢查時不會被已經在建立過程中的物件有被檢測為空的風險。

總結一下

volatile其實可以看作是輕量級的synchronized,雖然說volatile不能保證原子性,但是如果在多執行緒下的操作本身就是原子性操作(例如賦值操作),那麼使用volatile會由於synchronized

volatile可以適用於,某個標識flag,一旦被修改了就需要被其他執行緒立即可見的情況。也可以修飾作為觸發器的變數,一旦變數被任何一個執行緒修改了,就去觸發執行某個操作。

volatile的變數寫操作happen-before,後面任何對此volatile變數的讀操作。

相關文章