重排序引起的內存可見性問題

  • 什麼是重排序
  • 什麼是內存可見性
  • 將產生的問題
  • 如何解決問題

什麼是重排序

重排序是指編譯器和處理器爲了優化程序性能而對指令序列進行重新排序的一種優化措施

如果兩個操作訪問同一個變量,且這兩個操作有一個爲寫操作,此時這兩個操作之間就存在數據依賴性數據依賴性可概括爲以下三種類型:
這裏寫圖片描述
上面三種情況,只要重排序兩個操作的執行順序,程序的結果就會被改變。在Java內存模型(以下簡稱JMM)中,爲了效率是會對程序進行重排序。只有滿足某些條件的時候,JMM纔會禁止這些重排序,比如使用具有同步語義的語句等等。

什麼是內存可見性

如果線程A對共享變量X進行了修改,但是線程A沒有及時把更新後的值刷入到主內存中,而此時線程B從主內存讀取共享變量X的值,所以X的值是原始值,那麼我們就說對於線程B來講,共享變量X的更改對線程B是不可見的。

在以上敘述中要注意這麼一句話:對於線程B來講,共享變量X的 更改 對線程B是不可見的。那麼線程,線程工作內存和主內存之間可以用下圖來表示:

線程,工作內存與主內存之間的關係

將產生的問題

看一段《Java Concurrency in Practice》中的代碼

public class PossibleReordering {
    static int x = 0, y = 0;
    static int a = 0, b = 0;
    public static void main(String[] args)throws InterruptedException {
            Thread one = new Thread(new Runnable() {//線程A
                        public void run() {
                            a = 1;//step 1
                            x = b;//step 2
                        }
                            });
            Thread other = new Thread(new Runnable() {//線程B
                        public void run() {
                            b = 1;//step 3
                            y = a;//step 4
                        }
                            });
            one.start(); other.start();
            one.join(); other.join();
            System.out.println("( "+ x + "," + y + ")");
    }
}

在以上代碼運行中,如果兩個線程沒有正確的進行同步,我們很難說清楚最後的結果是什麼。有可能輸出:(1, 0), or (0, 1), or (1, 1)甚至(0,0)的情況,爲什麼呢?這是因爲JVM重排序的結果,重排序會使得step1到step4執行的順序無法預測,這取決於JVM的優化策略。由於JMM採用是共享內存模型,而非順序一致性模型,所以未同步的程序在JMM中不但整體的執行順序是無序的,而且所有線程看到的操作執行順序也可能不一致。在JMM中,,當前線程把寫過的數據緩存在本地內存中,在沒有刷新到主內存之前,這個寫操作僅對當前線程可見,從其它線程角度來看,會認爲這個寫操作根本沒有被當前線程執行,即是說,只有當前線程把本地內存寫過的數據刷新到主內存之後,這個寫操作才能對其他線程可見。所以其它線程根本不知道有線程對共享資源正進行修改,更不會去等待其修改完畢再去從主內存取。

如何解決問題

已經知道了產生這種問題的原因是因爲重排序,那麼解決問題自然就從禁止重排序上着手。

Happen-before規則:

  • x Program order rule. Each action in a thread happensͲbefore every action in that thread that comes later in the program order.
  • * 程序順序規則:線程裏的每一個操作都先行於代碼編寫順序中後來的線程裏的每一個操作(注意是線程之間的順序,在有些中譯本書籍中翻譯容易被誤導)*
  • x Monitor lock rule. An unlock on a monitor lock happensͲbefore every subsequent lock on that same monitor lock.
  • * 監視器鎖規則:一個監視器鎖的釋放先行與每個相同監視器鎖的加鎖操作(每次都是先解鎖在獲取鎖,而不是先獲取鎖,在把已經擁有的鎖釋放掉)*
  • x Volatile variable rule. A write to a volatile field happensͲbefore every subsequent read of that same field.
  • * volatile變量規則:對volatile域變量的寫操作先行於每一個後來的對該變量的讀操作(這裏要注意寫操作是指刷新到內存,讀操作指的是從主內存讀,線程修改操作和寫操作是兩碼事) *
  • x Thread start rule. A call to Thread.start on a thread happensͲbefore every action in the started thread.
  • * 線程啓動規則:線程的start方法先行於,線程對象裏的每一個操作,比如run()*
  • x Thread termination rule. Any action in a thread happensͲbefore any other thread detects that thread has terminated, either by successfully return from Thread.join or by Thread.isAlive returning false .
  • * 線程終止規則:線程裏的每一個操作先行於其它線程檢測到該線程已結束,或者該線程成功的從Thread.join方法返回,或者該線程Thread.alive返回false(其它線程檢測到A線程down掉了,可是A線程裏面還在執行操作,這是不允許的)*
  • x Interruption rule. A thread calling interrupt on another thread happensͲbefore the interrupted thread detects the interrupt (either by having InterruptedException thrown, or invoking isInterrupted or interrupted ).
  • * 中斷規則:一個線程調用另一個線程的中斷方法先行於被中斷線程檢測到中斷(比如,拋出了中斷異常,或者調用 isInterrupted or interrupted)*
  • x Finalizer rule. The end of a constructor for an object happensͲbefore the start of the finalizer for that object.
  • * 終接器規則:對象的構造函數必須在啓動該對象的總結器之前執行完成*
  • x Transitivity. If A happensͲbefore B, and B happensͲbefore C, then A happensͲbefore C.
  • * 傳遞性:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C;*

從JDK1.5之後,JMM使用happen-before的概念闡述操作之間的內存可見性。並保證只要線程A和B之間滿足happen-before關係,執行操作B的線程可以看到操作A的結果,本質在於JMM使用內存屏障禁止了操作重排序,從而實現一種偏序關係。
如何在編程中如何保證這種偏序Happen-before關係呢?
使用同步操作:同步操作滿足全序關係的,所以一定滿足偏序關係。同步操作一般有:鎖的獲取與釋放、對volatile變量的讀和寫
在volatile方案下能保證:step1 > step2 和 step3>step4
那麼就有這幾種情況發生:
1>2>3>4(0,1)
1>3>2>4(1,1)
1>3>4>2(1,1)
3>1>2>4(1,1)
3>1>4>2(1,1)
3>4>1>2(1,0)

public class PossibleReordering {
    static volatile int x = 0, y = 0;//使用volatile 解決方案
    static volatile int a = 0, b = 0;//使用volatile 解決方案
    public static void main(String[] args)throws InterruptedException {
            Thread one = new Thread(new Runnable() {//線程A
                        public void run() {
                            a = 1;//step 1
                            x = b;//step 2
                        }
                            });
            Thread other = new Thread(new Runnable() {//線程B
                        public void run() {
                            b = 1;//step 3
                            y = a;//step 4
                        }
                            });
            one.start(); other.start();
            one.join(); other.join();
            System.out.println("( "+ x + "," + y + ")");
    }
}

值得一提的是,案例中把所有的變量都設置爲volatile,其實若對volatile重排序規則瞭解的話,可以知道大可不必這樣,因爲插入內存屏障會有所開銷:po一張volatile排序規則表:
這裏寫圖片描述
在這張表裏可以看出其實只需要把x和y設爲volatile即可

    static volatile int x = 0, y = 0;//使用volatile 解決方案
    static int a = 0, b = 0;

使用synchronized方案:
step1 > step2 > step3 > step4
step1 > step2 > step4 > step3
step2 > step1 > step4 > step3
step2 > step1 > step3 > step4

step3 > step4 > step1>step2
step3 > step4 > step2>step1
step4 > step3 > step2>step1
step4 > step3 > step2>step1
結果只有(0,1)和(1,0)
從這裏可以看出,12之間並沒有數據依賴關係,34之間也是同樣。其實該方案遵循的happen-before的程序順序規則

public class PossibleReordering {
    static Integer x = 0, y = 0;//使用synchronized 解決方案
    static Integer a = 0, b = 0;//使用synchronized 解決方案
    public static void main(String[] args)throws InterruptedException {
            Thread one = new Thread(new Runnable() {//線程A
                        public void run() {
                            synchronized (a){
                                a = 1;//step 1
                                x = b;//step 2
                }
                        }
                            });
            Thread other = new Thread(new Runnable() {//線程B
                        public void run() {
                            synchronized (a){
                                b = 1;//step 3
                                y = a;//step 4
              }
                        }
                            });
            one.start(); other.start();
            one.join(); other.join();
            System.out.println("( "+ x + "," + y + ")");
    }
}

利用final域重排序規則:

對於final域,編譯器和處理器要遵守兩個重排序規則:

-在構造函數內對一個final域的寫入,與隨後把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。
-初次讀一個包含final域的對象的引用,與隨後初次讀這個final域,這兩個操作之間不能重排序。

final域重排序規則多用於對象構造,避免產生未初始化完全的對象。將會在《如何安全的發佈一個對象》一文中加以講解

發佈了35 篇原創文章 · 獲贊 12 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章