Java併發程式設計:JMM (Java記憶體模型) 以及與volatile關鍵字詳解

xue無止境發表於2018-12-29

計算機系統的一致性

在現代計算機作業系統中,多工處理幾乎是一項必備的功能,因為嵌入了多核處理器,計算機系統真正做到了同一時間執行若干個任務,是名副其實的多核系統。在多核系統中,為了提升CPU與記憶體的互動效率,一般都設定了一層 “快取記憶體區” 作為記憶體與處理器之間的緩衝,使得CPU在運算的過程中直接從快取記憶體區讀取資料,一定程度上解決了效能的問題。但是,這樣也帶來了一個新問題,就是“快取一致性”的問題。比如,多核的情況下,每個處理器都有自己的快取區,資料如何保持一致性。針對這個問題,現代的計算機系統引入多處理器的資料一致性的協議,包括MOSI、Synapse、Firely、DragonProtocol等。

當處理器通過快取記憶體區與主記憶體發生互動時,對資料的讀寫必須遵循協議規定的標準,用一張關係圖表示的話大概如下:

Java併發程式設計:JMM (Java記憶體模型) 以及與volatile關鍵字詳解
而Java的記憶體模型 (JMM) 可以說與硬體的一致性模型很相似,採用的是共享記憶體的執行緒通訊機制。

Java記憶體模型

Java記憶體模型規定了所有的變數都儲存在主記憶體中,每個執行緒擁有自己的工作記憶體,工作記憶體中儲存了被該執行緒使用的變數的主記憶體副本拷貝,執行緒只能操作自己工作記憶體的變數副本,操作完變數後會更新到主記憶體,通過主記憶體來完成與其他執行緒間變數值的傳遞。此模型的互動關係如下圖所示:

Java併發程式設計:JMM (Java記憶體模型) 以及與volatile關鍵字詳解
然而,Java的記憶體模型只是反映了虛擬機器內部的執行緒處理機制,並不保證程式本身的併發安全性。

舉一個例子,在程式中對一個共享變數做自增操作:

i++;
複製程式碼

假設初始化的時候i=0,當跑到此程式時,執行緒首先從主記憶體讀取i的值,然後複製到自己的工作記憶體,進行i++操作,最後將操作後的結果從工作記憶體複製到主記憶體中。如果是兩個執行緒執行i++的程式,預期的結果是2。但真的是這樣嗎?答案是否定的。

假設執行緒1讀取主記憶體的i=0,複製到自己的工作記憶體,在進行i++的操作後還沒來得及更新到主記憶體,這時執行緒2也讀取i=0,做了同樣的操作,那麼最終得到的結果為1,而不是2。

這是典型的關於多執行緒併發安全例子,也是Java併發程式設計中最值得探討的話題之一,一般來說,處理這種問題有兩種手段:

  • 加鎖,比如同步程式碼塊的方式。保證同一時間只能有一個執行緒能執行i++這條程式。
  • 利用執行緒間的通訊,比如使用物件的wait和notify方法來。

因為本文主要是探究 JMM 和 volatile 關鍵字的知識,具體怎麼實現併發處理就不做深入探討了,改天看看抽個時間再寫篇博文專門講解好了。

記憶體模型的3個重要特徵

初步瞭解完什麼是JMM後,我們來進一步瞭解它的重要特徵。值得說明的是,在Java多執行緒開發中,遵循著三個基本特性,分別是原子性、可見性和有序性,而Java的記憶體模型正是圍繞著在併發過程中如何處理這三個特徵建立的。

原子性

原子性是指操作是原子性的,不可中斷的。舉個例子:

String s="abc";
複製程式碼

這個操作是直接賦值,是原子性操作。而類似下面這段程式碼就不是原子性了:

i++;
複製程式碼

當執行i++時,需要先獲取i的值,然後再執行i+1,相當於包含了兩個操作,所以不是原子性。

可見性

可見性是指共享資料的時候,一個執行緒修改了資料,其他執行緒知道資料被修改,會重新讀取最新的主存的資料。就像前面說的兩個執行緒處理i++的問題,執行緒1改完後沒有更新到主記憶體,所以執行緒2是不知道的。

有序性

是指程式碼執行的有序性,對於一個執行緒執行的程式碼,我們可以認為程式碼是依次執行的,但併發中可能就會出現亂序,因為程式碼有可能發生指令重排序(Instruction Reorder),重排後的指令與原指令的順序未必一致。

指令重排序

編譯器能夠自由的以優化的名義去改變指令順序。在特定的環境下,處理器可能會次序顛倒的執行指令。是為指令的重排序,尤其是併發的情況下。

java提供了volatile和synchronized來保證執行緒之間操作的有序性。volatile含有禁止指令重排序的語義(即它的第二個語義),synchronized規定一個變數在同一時刻只允許一條執行緒對其lock操作,也就是說同一個鎖的兩個同步塊只能序列進入。禁止了指令的重排序。

volatile關鍵字

說到了volatile,我們就有必要了解一下這個關鍵字是做什麼的。

準確來說,volatile是java提供的輕量的同步機制。它有兩個特性:

  1. 保證修飾的變數對所有執行緒的可見性。
  2. 禁止指令的重排序優化。

保證可見性和防止指令重排

簡單寫段程式碼說明一下:

public class VolatileDemo {
    
    private static boolean isReady;
    private static int number;
    
    private static class ReaderThread extends Thread{
        @Override
        public void run() {
            while (!isReady);
            System.out.println("number = "+number);
        }
    }

    public static void main(String[] args) {
        new ReaderThread().start();
        try {
            Thread.sleep(1000);
            number = 42;
            isReady = true;
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
複製程式碼

在上面的程式碼中,ReaderThread只有在isReady 為 true 時才會列印出 number 的值,然而,真實的情況有可能是列印不出來(可能性比較小,但還是有),因為執行緒ReaderThread執行緒無法看到主執行緒中對isReady的修改,導致while迴圈永遠無法退出,同時,因為有可能發生指令重排,導致下面的程式碼不能按順序執行:

number = 42;
isReady = true;
複製程式碼

也就是能列印的話,number值可能是0,不是42。如果在變數加上volatile關鍵字,告訴Java虛擬機器這兩個變數可能會被不同的執行緒修改,那麼就可以防止上述兩種不正常的情況的發生。

不能保證原子性

volatile能保證可見性和有序性,但無法保證原子性,比如下面的例子:

public class VolatileDemo {

    public static volatile int i = 0;

    public static void increase() {
        i++;
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileDemo test = new VolatileDemo();
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++)
                    test.increase();
            }).start();
        }      
        Thread.sleep(1000);
        System.out.println(test.i);
    }
}
複製程式碼

正常情況下,我們期望上面的main函式執行完後輸出的結果是10000,但你會發現,結果總是會小於10000,因為increase()方法中的i++操作不是原子性的,分成了讀和寫兩個操作。假設當執行緒1讀取了 i 的值,還沒有修改,執行緒2這時也進行了讀取。然後,執行緒1修改完了,通知執行緒2重新讀取 i 的值,可這時它不需要讀取 i,它仍執行寫操作,然後賦值給主執行緒,這時資料就會出現問題。

所以,一般針對共享變數的讀寫操作,還是需要用鎖來保證結果,例如加上 synchronized關鍵字。

參考:

《Java高併發程式設計》

《深入理解Java虛擬機器》

相關文章