1. 程式人生 > >Synchronized的記憶體可見性

Synchronized的記憶體可見性

在Java中,我們都知道關鍵字synchronized可以用於實現執行緒間的互斥,但我們卻常常忘記了它還有另外一個作用,那就是確保變數在記憶體的可見性 - 即當讀寫兩個執行緒同時訪問同一個變數時,synchronized用於確保寫執行緒更新變數後,讀執行緒再訪問該 變數時可以讀取到該變數最新的值。

比如說下面的例子:

public class NoVisibility {
    private static boolean ready = false;
    private static int number = 0;

    private static class ReaderThread extends Thread {
        @Override
        public void run() {
            while (!ready) {
                Thread.yield(); //交出CPU讓其它執行緒工作
            }
            System.out.println(number);
        }
    }

    public static void main(String[] args) {
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
}

你認為讀執行緒會輸出什麼? 42? 在正常情況下是會輸出42. 但是由於重排序問題,讀執行緒還有可能會輸出0 或者什麼都不輸出。

我們知道,編譯器在將Java程式碼編譯成位元組碼的時候可能會對程式碼進行重排序,而CPU在執行機器指令的時候也可能會對其指令進行重排序,只要重排序不會破壞程式的語義 -

在單一執行緒中,只要重排序不會影響到程式的執行結果,那麼就不能保證其中的操作一定按照程式寫定的順序執行,即使重排序可能會對其它執行緒產生明顯的影響。

這也就是說,語句"ready=true"的執行有可能要優先於語句"number=42"的執行,這種情況下,讀執行緒就有可能會輸出number的預設值0.

而在Java記憶體模型下,重排序問題是會導致這樣的記憶體的可見性問題的。在Java記憶體模型下,每個執行緒都有它自己的工作記憶體(主要是CPU的cache或暫存器),它對變數的操作都在自己的工作記憶體中進行,而執行緒之間的通訊則是通過主存和執行緒的工作記憶體之間的同步來實現的。

比如說,對於上面的例子而言,寫執行緒已經成功的將number更新為42,ready更新為true了,但是很有可能寫執行緒只同步了number到主存中(可能是由於CPU的寫緩衝導致),導致後續的讀執行緒讀取的ready值一直為false,那麼上面的程式碼就不會輸出任何數值。

而如果我們使用了synchronized關鍵字來進行同步,則不會存在這樣的問題,

public class NoVisibility {
    private static boolean ready = false;
    private static int number = 0;
    private static Object lock = new Object();

    private static class ReaderThread extends Thread {
        @Override
        public void run() {
            synchronized (lock) {
                while (!ready) {
                    Thread.yield();
                }
                System.out.println(number);
            }
        }
    }

    public static void main(String[] args) {
        synchronized (lock) {
            new ReaderThread().start();
            number = 42;
            ready = true;
        }
    }
}

這個是因為Java記憶體模型對synchronized語義做了以下的保證,

即當ThreadA釋放鎖M時,它所寫過的變數(比如,x和y,存在它工作記憶體中的)都會同步到主存中,而當ThreadB在申請同一個鎖M時,ThreadB的工作記憶體會被設定為無效,然後ThreadB會重新從主存中載入它要訪問的變數到它的工作記憶體中(這時x=1,y=1,是ThreadA中修改過的最新的值)。通過這樣的方式來實現ThreadA到ThreadB的執行緒間的通訊。

這實際上是JSR133定義的其中一條happen-before規則。JSR133給Java記憶體模型定義以下一組happen-before規則,

  1. 單執行緒規則:同一個執行緒中的每個操作都happens-before於出現在其後的任何一個操作。
  2. 對一個監視器的解鎖操作happens-before於每一個後續對同一個監視器的加鎖操作。
  3. 對volatile欄位的寫入操作happens-before於每一個後續的對同一個volatile欄位的讀操作。
  4. Thread.start()的呼叫操作會happens-before於啟動執行緒裡面的操作。
  5. 一個執行緒中的所有操作都happens-before於其他執行緒成功返回在該執行緒上的join()呼叫後的所有操作。
  6. 一個物件建構函式的結束操作happens-before與該物件的finalizer的開始操作。
  7. 傳遞性規則:如果A操作happens-before於B操作,而B操作happens-before與C操作,那麼A動作happens-before於C操作。

實際上這組happens-before規則定義了操作之間的記憶體可見性,如果A操作happens-before B操作,那麼A操作的執行結果(比如對變數的寫入)必定在執行B操作時可見。

為了更加深入的瞭解這些happens-before規則,我們來看一個例子:

//執行緒A,B共同訪問的程式碼
Object lock = new Object();
int a=0;
int b=0;
int c=0;

//執行緒A,呼叫如下程式碼
synchronized(lock){
    a=1; //1
    b=2;  //2
} //3
c=3; //4


//執行緒B,呼叫如下程式碼
synchronized(lock){  //5
    System.out.println(a);  //6
    System.out.println(b);  //7
    System.out.println(c);  //8
}

我們假設執行緒A先執行,分別給a,b,c三個變數進行賦值(注:變數a,b的賦值是在同步語句塊中進行的),然後執行緒B再執行,分別讀取出這三個變數的值並打印出來。那麼執行緒B打印出來的變數a,b,c的值分別是多少?

根據單執行緒規則,在A執行緒的執行中,我們可以得出1操作happens before於2操作,2操作happens before於3操作,3操作happens before於4操作。同理,在B執行緒的執行中,5操作happens before於6操作,6操作happens before於7操作,7操作happens before於8操作。而根據監視器的解鎖和加鎖原則,3操作(解鎖操作)是happens before 5操作的(加鎖操作),再根據傳遞性 規則我們可以得出,操作1,2是happens before 操作6,7,8的。

則根據happens-before的記憶體語義,操作1,2的執行結果對於操作6,7,8是可見的,那麼執行緒B裡,列印的a,b肯定是1和2. 而對於變數c的操作4,和操作8. 我們並不能根據現有的happens before規則推出操作4 happens before於操作8. 所以線上程B中,訪問的到c變數有可能還是0,而不是3.