Java內存模型遇上volatile

Table of Contents

內存模型的定義

內存間交互操作

volatile

可見性

避免指令重排

happens-before


內存模型的定義

計算機內物理存儲結構發展到今日,已經比較複雜了,如圖1所示是計算機存儲結構的一個簡化圖,有處理器、多級高速緩存、內存,再往下還有硬盤等,每級之間讀寫速度和存儲容量都是數量級上的差距,速度越高,受限於成本的原因,容量也就越小。

由於處理器和內存之間的處理速度差距過大,如果處理器計算的中間結果直接存儲於內存上時,會嚴重拖慢運行速度。加入高速緩存,處理器可以把計算的中間結果放到高速緩存上,等計算結束後,再把緩存上的數據寫回內存。但是對於多核處理器,如果兩個處理器上的計算任務用到了內存中的同一部分數據,麻煩就來了。比如,線程t2運行在處理器2上,線程t1運行在處理器1上,t1和t2都會對數據進行寫操作,假設寫操作要依賴數據的當前值,那麼t2修改的數據是否是t1在自己的緩存上修改後並且寫回內存的數據呢,如果無法保證,就會出現緩存不一致的問題。

緩存一致性協議就是來解決共享數據的問題的,這類協議有MSI、MESI、MOSI和Dragon Protocol等。

圖1 存儲結構簡化圖

圖1 存儲結構簡化圖

內存模型是抽象於硬件結構之上的一系列規則的集合,它的目的是保證內存訪問的一致性。由於處理器架構(x86、ARM、MIPS等)和操作系統的不一樣,內存模型也會不同。Java號稱跨平臺,虛擬機規範定義了Java內存模型(Java Memory Model,JMM),來屏蔽硬件和操作系統的差異。

在JDK1.5,實現了JSR-133後,java內存模型趨於成熟和穩定,在JSR-133中對內存模型的定義:

For the Java programming language, the memory model works by examining each read in an execution trace and checking that the write observed by that read is valid according to certain rules.

內存模型描述了程序的可能行爲。只要程序的所有最終執行都產生可以由內存模型預測的結果,實現就可以自由生成任何代碼。這爲執行各種代碼轉換提供了很大的自由度,包括動作的重新排序和不必要的同步的刪除。

int variable;

variable = 2020;

上面的代碼清單中,在多線程環境下,variable是共享變量,在單線程環境中,對variable寫入一個值,再讀出,總能得到相同的值。就是所謂的線程內表現爲串行語義(Within-Thread As-If-Serial Semantics)。但在多線程環境下,一個線程對變量寫入,另外一個變量讀出,有時候並不能讀出符合我們預期的值。

在編譯器中生成的指令順序,可以與源代碼中的順序不同,此外編譯器還會把變量保存在寄存器而不是內存中,處理器可以採用亂序或並行等方式來執行指令,緩存可能會改變將寫入變量提交到主存的次序。而且,保存在處理器本地緩存中的值,對於其他處理器是不可見的。

如果不使用“同步”,上面提到的一些因素都會使得一個線程無法看到變量的最新值,並且會導致其他線程中的內存操作似乎在亂序執行。簡單點來說,內存模型來解決的問題就是:在什麼條件下,讀取variable的線程可以看到該變量的值爲2020。

內存間交互操作

Java內存模型規定了所有的變量都存儲在主內存中,每個線程還有自己的工作內存,線程的工作內存中保存了被該線程使用到的變量的主內存副本拷貝,線程對變量的所有讀寫操作都必須在工作內存中進行,而不能直接讀寫主內存中的變量。不同線程之間也無法直接訪問對方工作內存中的變量,線程間變量值得傳遞均需要通過主內存來完成。《深入理解JVM》一書給出了線程、主內存、工作內存的交互關係,如圖2所示。

圖2 java線程、工作內存和主內存交互關係

圖2 java線程、工作內存和主內存交互關係

JMM定義了8種操作來完成工作內存和主內存的交互。作用於主內存變量的操作有4種:lock(鎖定)、解鎖(unlock)、read(讀取)、write(寫入);作用於工作內存的變量的操作有4種:load(載入)、use(使用)、assign(賦值)和store(存儲)。

對於非long和double類型的變量來說,這8種操作都是原子操作,不可再分。對於64位的數據類型long和double,JMM允許將沒有被volatile修飾的變量的讀寫操作劃分爲兩次32位操作來進行。所以多線程共享的long或double變量需要加上volatile關鍵字來修飾。不過,一般我們不需要這樣做,因爲目前商用虛擬機幾乎都把64位數據的讀寫作爲原子操作來支持。

這8種操作需要遵循一定的規則,比如需要把一個變量從主內存複製到工作內存,就需要順序地執行read和load操作,要把工作內存的變量同步到主內存中去,需要順序地執行store和write操作。只要求順序執行,但並不要求連續執行。

volatile

volatile關鍵字有兩種作用,一是可見性,當一個線程修改了volatile修飾的變量後,修改後的新值對於其他線程來說是立即可見的。二是避免指令重排序優化。

可見性

要求每次使用volatile修飾的變量,必須先從主內存讀取最新的值,每次修改volatile修飾的變量,必須立刻同步回主內存中。

Map configOptions;

char[] configText;

// 定義一個是否初始化的標誌

volatile boolean initialized = false;

// Thread A

configOptions = new HashMap();

configText = readConfigFile(fileName);

processConfigOptions(configText, configOptions);

// 初始化完成,置位標誌位

initialized = true;

// Thread B  檢測是否完成初始化

while (!initialized)

sleep();

在上面這個例子中,變量initialized加上了volatile修飾,那麼在線程A中完成初始化工作之後,對initialized變量的置位操作可以及時寫入主存,被線程B發現變量值得改變。

常見的有兩種說法,第一種說法是:“volatile是一種輕量級的線程間同步機制”;第二種說法是:“‘volatile是輕量級的同步方式’這種說法是錯誤的,它只是輕量級的線程操作可見方式。”

這兩種說法的角度是不一樣的,如果把線程間共享變量的“可見性”也作爲同步的一部分內容的話,第一種說法是正確的。

第二種說法是從“原子性”來考慮的,volatile無法保證對變量操作的原子性。看下面的這個例子。

public class VolatileTest {

    private static volatile int val;

    private static void addOne() {
        for (int i = 0; i < 1000; i++) {
            val++;
        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < 50; i++) {
            new Thread(() -> addOne()).start();
        }
        if (Thread.activeCount() > 1) {
            Thread.yield();
        }
        System.out.println(val);
    }
}

預期的結果是50000,而實際上每次運行出來的結果可能都不一樣。 

private static void addOne();

    Code:

       0: iconst_0
       1: istore_0
       2: iload_0
       3: sipush        1000
       6: if_icmpge     23
       9: getstatic     #2                  // Field val:I
      12: iconst_1
      13: iadd
      14: putstatic     #2                  // Field val:I
      17: iinc          0, 1
      20: goto          2
      23: return

使用javap -c -private  VolatileTest反編譯字節碼,可以看到val++操作實際上並不是一條指令完成的。當getstatic指令把val的值取到操作棧頂時,volatile保證了此時所取的值是正確的,但是在執行iconst_1、iadd的時候,其他線程可能已經把val的值加大了,而操作棧頂的值就變成了過期數據,所以putstatic執行後就會把較小的值寫回主存之中了。

出現問題的原因是val++這條指令不是原子操作,可以用加鎖(synchronized)來保證共享變量寫操作的原子性。同時,鎖也能保證變量的可見性,線程在得到鎖時從主內存讀入數據,在釋放鎖時,把數據寫回主存。

避免指令重排

下面代碼清單是一個典型的雙重檢查鎖定模式的例子

這是一個單例模式使用雙重檢查鎖的經典實現,如果已經創建了單例對象,那麼就不需要進入第二個加鎖的檢查了,減小了同步帶來的效率問題。第二個校驗的作用在於,如果兩個線程都通過了第一個校驗,第一個線程首先拿到鎖,實例化對象後寫入主內存,第二個線程進入同步塊,在第二個校驗處得知instance已經不爲null了,就直接返回。

volatile在這裏的作用是避免指令重排優化,初始化DCL實例和將對象地址寫到instance字段並不是原子操作,且兩個階段執行指令的順序是未定義的。假設某個線程執行new DCL()時,構造方法還未被調用,編譯器僅僅爲該對象分配了內存空間並設爲默認值,此時若另外一個線程調用getInstance方法,由於instance不爲null,但此時的instance還沒有被賦予真正有效的值,從而無法獲取到正確的instance對象。

volatile變量會在指令中加入內存屏障(Memory Barrier),來阻止指令重排序,保證指令的實際執行順序和代碼的書寫順序一致。此例子中就是使用了volatile的這種特性來保證單例模式的正常工作(需要jdk1.5後,依賴JSR規範)。

happens-before

Java內存模型定義了一個“先行發生”(happens-before)原則,主要規則如下:

程序次序規則(Program Order Rule):在一個線程內,按照程序代碼順序,書寫在前面的操作先行於書寫在後面的操作。

管程鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於後面對同一個鎖的lock操作。

Volatile變量規則(Volatile Variable Rule):對一個volatile變量的寫操作先行發生於後面對這個變量的讀操作。

線程啓動規則(Thread Start Rule):Thread對象的start()方法先行發生於此線程的每一個動作。

線程終止規則(Thread Termination Rule):線程中的所有操作都先行發生於對此線程的終止檢測。

線程中斷規則(Thread Interruption Rule):對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生。

對象終結規則(Finalizer Rule):一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize()方法的開始。

傳遞性(Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C。

對於程序次序規則(Program Order Rule)有必要進行解釋一下,它並不是說指令的執行完全按照代碼的書寫順序。比如下面的代碼:

// 同一個線程中
int a = 1;
int b = 2;
int c = a + 1;

依照該條規則,int a = 1先行發生於int b = 2;但是int b = 2完全可能先被處理器執行,而且大多數情況下會這樣。這並不影響先行發生原則的正確性。JSR原文給出瞭解釋:

It should be noted that the presence of a happens-before relationship between two actions does not necessarily imply that they have to take place in that order in an implementation. If the reordering produces results consistent with a legal execution, it is not illegal.

參考:

  1. 深入理解java虛擬機
  2. Java併發編程實踐
  3. JSR-133
  4. 碼出高效

 

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