Java併發程式設計實戰系列16之Java記憶體模型(JMM)

芥末無疆發表於2018-02-22

前面幾章介紹的安全釋出、同步策略的規範還有一致性,這些安全性都來自於JMM。

16.1 什麼是記憶體模型,為什麼需要它?

假設

a=3

記憶體模型要解決的問題是:“在什麼條件下,讀取a的執行緒可以看到這個值為3?”

如果缺少同步會有很多因素導致無法立即、甚至永遠看不到一個執行緒的操作結果,包括

  • 編譯器中指令順序
  • 變數儲存在暫存器而不是記憶體中
  • 處理器可以亂序或者並行執行指令
  • 快取可能會改變將寫入變數提交到主記憶體的次序
  • 處理器中也有本地快取,對其他處理器不可見

單執行緒中,會為了提高速度使用這些技術,但是Java語言規範要求JVM線上程中維護一種類似序列的語義:只要程式的最終結果與在嚴格環境中的執行結果相同,那麼上述操作都是允許的。

隨著處理器越來越強大,編譯器也在不斷的改進,通過指令重排序實現優化執行,使用成熟的全域性暫存器分配演算法,但是單處理器存在瓶頸,轉而變為多核,提高並行性。

在多執行緒環境中,維護程式的序列性將導致很大的效能開銷,併發程式中的執行緒,大多數時間各自為政,執行緒之間協調操作只會降低應用程式的執行速度,不會帶來任何好處,只有當多個執行緒要共享資料時,才必須協調他們之間的操作,並且JVM依賴程式通過同步操作找出這些協調操作將何時發生。

JMM規定了JVM必須遵循一組最小的保證,保證規定了對變數的寫入操作在何時將對其他執行緒可見。JMM需要在各個處理器體系架構中實現一份。

16.1.1 平臺的記憶體模型

在共享記憶體的多處理器體系架構中,每個處理器擁有自己的快取,並且定期的與主記憶體進行協調。在不同的處理器架構中提供了不同級別的快取一致性(cache coherence)。其中一部分只提供最小的保證,即允許不同的處理器在任意時刻從同一個儲存位置上看到不同的值。作業系統、編譯器以及runtime需要彌補這種硬體能力與執行緒安全需求之間的差異。

要確保每個處理器在任意時刻都知道其他處理器在進行的工作,這將開銷巨大。多數情況下,這完全沒必要,可隨意放寬儲存一致性,換取效能的提升。存在一些特殊的指令(成為記憶體柵欄),當需要共享資料時,這些指令就能實現額外的儲存協調保證。為了使Java開發人員無須關心不同架構上記憶體模型之間的差異,產生了JMM,JVM通過在適當的位置上插入記憶體柵欄來遮蔽JMM與底層平臺記憶體模型之間的差異。

按照程式的順序執行,這種樂觀的序列一致性在任何一款現代多處理器架構中都不會提供這種序列一致性。當跨執行緒共享資料時,會出現一些奇怪的情況,除非通過使用記憶體柵欄來防止這種情況的發生。

16.1.2 重排序

下面的程式碼,4中輸出都是有可能的。

public class ReorderingDemo {

    static int x = 0, y = 0, a = 0, b = 0;

    public static void main(String[] args) throws Exception {
        Bag bag = new HashBag();
        for (int i = 0; i < 10000; i++) {
            x = y = a = b = 0;
            Thread one = new Thread() {
                public void run() {
                    a = 1;
                    x = b;
                }
            };
            Thread two = new Thread() {
                public void run() {
                    b = 1;
                    y = a;
                }
            };
            one.start();
            two.start();
            one.join();
            two.join();
            bag.add(x + "_" + y);
        }
        System.out.println(bag.getCount("0_1"));
        System.out.println(bag.getCount("1_0"));
        System.out.println(bag.getCount("1_1"));
        System.out.println(bag.getCount("0_0"));
        // 結果是如下的或者其他情況,證明可能發生指令重排序
        //        9999
        //        1
        //        0
        //        0

        //        9998
        //        2
        //        0
        //        0
    }

16.1.3 Java記憶體模型簡介

JMM通過各種操作來定義,包括對變數的讀寫操作,監視器monitor的加鎖和釋放操作,以及執行緒的啟動和合並操作,JMM為程式中所有的操作定義了一個偏序關係,成為Happens-before,要想保證執行操作B的執行緒看到A的結果,那麼A和B之間必須滿足Happens-before關係。如果沒有這個關係,JVM可以任意的重排序。

JVM來定義了JMM(Java記憶體模型)來遮蔽底層平臺不同帶來的各種同步問題,使得程式設計師面向JAVA平臺預期的結果都是一致的,對於“共享的記憶體物件的訪問保證因果性正是JMM存在的理由”(這句話說的太好了!!!)。

因為沒法列舉各種情況,所以提供工具輔助程式設計師自定義,另外一些就是JMM提供的通用原則,叫做happens-before原則,就是如果動作B要看到動作A的執行結果(無論A/B是否在同一個執行緒裡面執行),那麼A/B就需要滿足happens-before關係。下面是所有的規則,滿足這些規則是一種特殊的處理措施,否則就按照上面背景提到的對於可見性、順序性是沒有保障的,會出現“意外”的情況。

如果多執行緒寫入遍歷,沒有happens-before來排序,那麼會產生race condition。在正確使用同步的的程式中,不存在資料競爭,會表現出序列一致性。

  • (1)同一個執行緒中的每個Action都happens-before於出現在其後的任何一個Action。//控制流,而非語句
  • (2)對一個監視器的解鎖happens-before於每一個後續對同一個監視器的加鎖。//lock、unlock
  • (3)對volatile欄位的寫入操作happens-before於每一個後續的同一個欄位的讀操作。
  • (4)Thread.start()的呼叫會happens-before於啟動執行緒裡面的動作。
  • (5)Thread中的所有動作都happens-before於其他執行緒檢查到此執行緒結束或者Thread.join()中返回或者Thread.isAlive()==false。
  • (6)一個執行緒A呼叫另一個另一個執行緒B的interrupt()都happens-before於執行緒A發現B被A中斷(B丟擲異常或者A檢測到B的isInterrupted()或者interrupted())。
  • (7)一個物件建構函式的結束happens-before與該物件的finalizer的開始
  • (8)如果A動作happens-before於B動作,而B動作happens-before與C動作,那麼A動作happens-before於C動作。

16.1.4 藉助同步

piggyback(藉助)現有的同步機制可見性。例如在AQS中藉助一個volatile的state變數保證happens-before進行排序。

舉例:Inner class of FutureTask illustrating synchronization piggybacking. (See JDK source)

還可以記住CountDownLatch,Semaphore,Future,CyclicBarrier等完成自己的希望。

16.2 釋出

第三章介紹瞭如何安全的或者不正確的釋出一個物件,其中介紹的各種技術都依賴JMM的保證,而造成釋出不正確的原因就是

  • 釋出一個共享物件
  • 另外一個執行緒訪問該物件

之間缺少一種happens-before關係。

16.2.1 不安全的釋出

缺少happens-before就會發生重排序,會造成釋出一個引用的時候,和內部各個field初始化重排序,比如

init field a
init field b
釋出ref
init field c

這時候從使用這角度就會看到一個被部分構造的物件。

錯誤的延遲初始化將導致不正確的釋出,如下程式碼。這段程式碼不光有race condition、建立低效等問題還儲存在另外一個執行緒會看到部分構造的Resource例項引用。

@NotThreadSafe
public class UnsafeLazyInitialization {
    private static Resource resource;

    public static Resource getInstance() {
        if (resource == null)
            resource = new Resource(); // unsafe publication
        return resource;
    }

    static class Resource {
    }
}

那麼,除非使用final,或者釋出操作執行緒在使用執行緒開始之前執行,這些都滿足了happens-before原則。

16.2.2 安全的釋出

使用第三章的各種技術可以安全釋出物件,去報釋出物件的操作在使用物件的執行緒開始使用物件的引用之前執行。如果A將X放入BlockingQueue,B從佇列中獲取X,那麼B看到的X與A放入的X相同,實際上由於使用了鎖保護,實際B能看到A移交X之前所有的操作。

16.2.3 安全的初始化模式

有時候需要延遲初始化,最簡單的方法:

@ThreadSafe
public class SafeLazyInitialization {
    private static Resource resource;

    public synchronized static Resource getInstance() {
        if (resource == null)
            resource = new Resource();
        return resource;
    }

    static class Resource {
    }
}

如果getInstance呼叫不頻繁,這絕對是最佳的。

在初始化中使用static會提供額外的執行緒安全保證。靜態初始化是由JVM在類的初始化階段執行,並且在類被載入後,線上程使用前的。靜態初始化期間,記憶體寫入操作將自動對所有執行緒可見。因此靜態初始化物件不需要顯示的同步。下面的程式碼叫做eager initialization。

@ThreadSafe
public class EagerInitialization {
    private static Resource resource = new Resource();

    public static Resource getResource() {
        return resource;
    }

    static class Resource {
    }
}

下面是lazy initialization。JVM推遲ResourceHolder的初始化操作,直到開始使用這個類時才初始化,並且通過一個static來做,不需要額外的同步。

@ThreadSafe
public class ResourceFactory {
    private static class ResourceHolder {
        public static Resource resource = new Resource();
    }

    public static Resource getResource() {
        return ResourceFactory.ResourceHolder.resource;
    }

    static class Resource {
    }
}

16.2.4 雙重檢查加鎖CDL

DCL實際是一種糟糕的方式,是一種anti-pattern,它只在JAVA1.4時代好用,因為早期同步的效能開銷較大,但是現在這都不是事了,已經不建議使用。

@NotThreadSafe
public class DoubleCheckedLocking {
    private static Resource resource;

    public static Resource getInstance() {
        if (resource == null) {
            synchronized (DoubleCheckedLocking.class) {
                if (resource == null)
                    resource = new Resource();
            }
        }
        return resource;
    }

    static class Resource {

    }
}

初始化instance變數的虛擬碼如下所示:

memory = allocate();   //1:分配物件的記憶體空間
ctorInstance(memory);  //2:初始化物件
instance = memory;     //3:設定instance指向剛分配的記憶體地址

之所以會發生上面我說的這種狀況,是因為在一些編譯器上存在指令排序,初始化過程可能被重排成這樣:

memory = allocate();   //1:分配物件的記憶體空間
instance = memory;     //3:設定instance指向剛分配的記憶體地址
                       //注意,此時物件還沒有被初始化!
ctorInstance(memory);  //2:初始化物件

而volatile存在的意義就在於禁止這種重排!解決辦法是宣告為volatile型別。這樣就可以用DCL了。

@NotThreadSafe
public class DoubleCheckedLocking {
    private static volatile Resource resource;

    public static Resource getInstance() {
        if (resource == null) {
            synchronized (DoubleCheckedLocking.class) {
                if (resource == null)
                    resource = new Resource();
            }
        }
        return resource;
    }

    static class Resource {

    }
}

16.3 初始化過程中的安全性

final不會被重排序。

下面的states因為是final的所以可以被安全的釋出。即使沒有volatile,沒有鎖。但是,如果除了建構函式外其他方法也能修改states。如果類中還有其他非final域,那麼其他執行緒仍然可能看到這些域上不正確的值。也導致了構造過程中的escape。

寫final的重排規則:

  • JMM禁止編譯器把final域的寫重排序到建構函式之外。
  • 編譯器會在final域的寫之後,建構函式return之前,插入一個StoreStore屏障。這個屏障禁止處理器把final域的寫重排序到建構函式之外。也就是說:寫final域的重排序規則可以確保:在物件引用為任意執行緒可見之前,物件的final域已經被正確初始化過了。

讀final的重排規則:

  • 在一個執行緒中,初次讀物件引用與初次讀該物件包含的final域,JMM禁止處理器重排序這兩個操作(注意,這個規則僅僅針對處理器)。編譯器會在讀final域操作的前面插入一個LoadLoad屏障。也就是說:讀final域的重排序規則可以確保:在讀一個物件的final域之前,一定會先讀包含這個final域的物件的引用。

如果final域是引用型別,那麼增加如下約束:

  • 在建構函式內對一個final引用的物件的成員域的寫入,與隨後在建構函式外把這個被構造物件的引用賦值給一個引用變數,這兩個操作之間不能重排序。(個人覺得基本意思也就是確保在建構函式外把這個被構造物件的引用賦值給一個引用變數之前,final域已經完全初始化並且賦值給了當前構造物件的成員域,至於初始化和賦值這兩個操作則不確保先後順序。)
@ThreadSafe
public class SafeStates {
    private final Map<String, String> states;

    public SafeStates() {
        states = new HashMap<String, String>();
        states.put("alaska", "AK");
        states.put("alabama", "AL");
        /*...*/
        states.put("wyoming", "WY");
    }

    public String getAbbreviation(String s) {
        return states.get(s);
    }
}


相關文章