從Java記憶體模型角度理解安全初始化

登高且賦發表於2017-10-13

本文將簡要介紹java記憶體模型(JMM)的底層細節以及所提供的保障,並從JMM的角度再談如何在併發環境下正確初始化物件,這將有助於理解更高層面的併發同步機制背後的原理。

相關閱讀
1.多執行緒安全性:每個人都在談,但是不是每個人都談地清
2.物件共享:Java併發環境中的煩心事

1. 何為記憶體模型

如大家所知,Java程式碼在編譯和執行的過程中會對程式碼有很多意想不到且不受開發人員控制的操作:

  • 在生成指令順序可能和原始碼中順序不相同;
  • 編譯器可能會把變數儲存到暫存器中而非記憶體中;
  • 處理器可以採用亂序或者並行的方式執行指令;
  • 快取可能會改變將寫入變數提交到主記憶體的次序;
  • 儲存在處理器本地快取中的值,對於其他處理器是不可見的;
  • …..

以上所有的這些情況都可能會導致多執行緒同步的問題。

其實,在單執行緒的環境下,這些底層的技術都是為了提高執行效率而存在,不會影響執行結果:JVM只會在執行結果和嚴格序列執行結果相同的情況下進行如上的優化操作。我們需要知道近些年以來計算效能的提高很大程度上要感謝這些重新排序的操作。

為了進一步提高效率,多核處理器已經廣泛被使用,程式在多數時間內都是併發執行,只有在需要的時候才回去協調各個執行緒之間的操作。那什麼是需要的時候呢,JVM將這個問題拋給了程式,要求在程式碼中使用同步機制來保證多執行緒安全。

1.1 多處理器架構中的記憶體模型

在多核理器架構中,每個處理器都擁有自己的快取,並且會定期地與主記憶體進行協調。這樣的架構就需要解決快取一致性(Cache Coherence)的問題。很可惜,一些框架中只提供了最小保證,即允許不同處理器在任意時刻從同一儲存位置上看到不同的值。

正因此存在上面所述的硬體能力和執行緒安全需求的差異,才導致需要在程式碼中使用同步機制來保證多執行緒安全。

這樣“不靠譜”的設計還是為了追求效能,因為要保證每個處理器都能在任意時刻知道其他處理器在做什麼需要很大的開銷,而且大部分情況下處理器也沒有這樣的需求,放寬對於儲存一致性的保障,以換取效能的提升。

架構中定義了一些特殊的指令,也就是記憶體柵欄,當需要多執行緒間資料共享的時,這些指令將會提供額外的儲存協調。

值得慶幸的是JMM為我們遮蔽了各個框架在記憶體模型上的差異,讓開發人員不用再去關係這些底層問題。

1.2 重排序

JVM不光會改變命令執行的順序,甚至還會讓不同執行緒看到的程式碼執行的順序也是不同的,這就會讓在沒有同步操作的情況下預測程式碼執行結果邊變的困難。

下面的程式碼是《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() {
            public void run() {
                a = 1;
                x = b;
            }
        });
        Thread other = new Thread(new Runnable() {
            public void run() {
                b = 1;
                y = a;
            }
        });
        one.start();
        other.start();
        one.join();
        other.join();
        System.out.println("( " + x + "," + y + ")");
    }
}

以上程式碼的輸出結果可能是(1,0)、(0,1)、(1,1)甚至是(0,0),這是由於兩個執行緒的執行先後順序可能不同,執行緒內部的賦值操作的順序也有可能相互顛倒。

上面這樣簡單的程式碼,如果缺少合理的同步機制都很難預測其結果,複雜的程式將更為困難,這正是通過同步機制限制編譯器和執行時對於記憶體操作重排序限制的意義所在。

1.3 Java記憶體模型與Happens-Before規則

Java記憶體模型是通過各種操作來定義的,包括對於變數的對寫操作,監視器的加鎖和釋放鎖操作,以及執行緒的啟動和合並,而這些操作都要滿足一種偏序關係——Happen-Before規則:想要保證執行操作B的執行緒看到執行操作A的結果,而無論兩個操作是否在同一執行緒,則操作A和操作B之間必須滿足Happens-Before關係,否者JVM將可以對他們的執行順序任意安排。

Happens-Before規則:

  • 程式順序規則:一個執行緒中的每個操作,先於隨後該執行緒中的任意後續操作執行(針對可見性而言);
  • 監視器鎖規則:對一個鎖的解鎖操作,先於隨後對這個鎖的獲取操作執行;
  • volatile變數規則:對一個volatile變數的寫操作,先於對這個變數的讀操作執行;
  • 傳遞性:如果A happens-before B,B happens-before C,那麼A happens-before C;
  • start規則:如果執行緒A執行執行緒B的start方法,那麼執行緒A的ThreadB.start()先於執行緒B的任意操作執行;
  • join規則:如果執行緒A執行執行緒B的join方法,那麼執行緒B的任意操作先於執行緒A從TreadB.join()方法成功返回之前執行;
  • 中斷規則:當執行緒A呼叫另一個執行緒B的interrupt方法時,必須線上程A檢測到執行緒B被中斷(丟擲InterruptException,或者呼叫ThreadB.isInterrupted())之前執行。
  • 終結器規則:一個物件的建構函式先於該物件的finalizer方法執行前完成;

2. 安全釋出與記憶體模型

物件共享:Java併發環境中的煩心事中曾介紹過安全釋出和資料共享的問題,而造成不正確的釋出的根源就在於釋出物件的操作和訪問物件的操作之間缺少Happens-Before關係。

請看下面這個例子,這是一個不安全的懶載入,只有在第一次用到Resource物件時才會去初始化該物件。

public class UnsafeLazyInitialization {
    private static Resource resource;

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

    static class Resource {
    }
}

getInstance() 方法是一個靜態方法,可以被多個執行緒同時呼叫,就有可能出現資料競爭的問題,在Java記憶體模型的角度來說就是讀取resource物件判斷是都為空和對resource賦值的寫操作並不存在Happens-Before關係,彼此在多執行緒環境中不一定是可見的。此外,new Resource()來建立一個類物件,要先分配記憶體空間,物件各個域都是被賦予預設值,然後再呼叫建構函式對寫入各個域。由於這個過程和讀取Resource物件的操作並不滿足Happens-Before關係,所以可能一個執行緒中正在建立物件但是沒有執行完畢,而這時另一個執行緒看到的Resource物件的確不是為空,但卻是個失效的狀態。

真正執行緒安全的懶載入應該是這樣的,通過同步機制上鎖,讓讀操作和寫操作滿足Happens-Before規則。

public class SafeLazyInitialization {
    private static Resource resource;

    //一執行緒獲得內建鎖之後,在釋放鎖之前的操作都會先於另外一個執行緒得到鎖的操作執行
    public synchronized static Resource getInstance() {
        if (resource == null)
            resource = new Resource();
        return resource;
    }

    static class Resource {
    }
}

2.1 正確的延遲初始化

為了避免懶載入每次呼叫getInstance方法的同步開銷,可以使用提前初始化的方法,如下:

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

    public static Resource getResource() {
        return resource;
    }

    static class Resource {
    }
}

提前初始化方法利用靜態初始化提前載入並有同步機制保護的特性實現了安全釋出。更進一步,該方法和JVM的延遲載入機制結合,形成了一種完備的延遲初始化技術-延遲初始化佔位類模式,例項如下:

public class ResourceFactory {
    //靜態初始化不需要額外的同步機制
    private static class ResourceHolder {
        public static Resource resource = new Resource();
    }

    //延遲載入
    public static Resource getResource() {
        return ResourceHolder.resource;
    }

    static class Resource {
    }
}

上述程式碼中專門使用了一個類ResourceHolder來初始化Resource物件,ResourceHolder會被JVM推遲初始化直到被真正的呼叫,並且因為利用了靜態初始化而不需要額外的同步機制。

靜態初始化或靜態程式碼塊因為由JVM的機制保護,不需要額外的同步機制;

2.2 雙重檢查加鎖

下面讓我們從Java記憶體模型的角度談談臭名昭著的雙重檢查加鎖(DCL),示例程式碼如下:

public class DoubleCheckedLocking {
    private static Resource resource;

    public static Resource getInstance() {
        //沒有在同步的情況下讀取共享變數,破壞了Happens_Before規則
        if (resource == null) {
            synchronized (DoubleCheckedLocking.class) {
                if (resource == null)
                    resource = new Resource();
            }
        }
        return resource;
    }

    static class Resource {

    }
}

由於在早期的JVM中,同步操作很是效率低,所以延遲初始化常被用來避免不必要的同步開銷,但是對於DCL,其雖然很好的解決了“獨佔性”,但是沒有正確理解”可見性”。

物件共享:Java併發環境中的煩心事中曾經介紹過:對於共享變數,讀寫操作都需要在同一個鎖的保護之下,從而使得讀/寫操作都滿足Happens-Before規則,在多執行緒環境中可見。但是在DCL中,第一次對於resource的空判斷沒有在同步機制下進行,和寫操作之間沒有Happens-Before關係,即使寫操作是同步的,也不能保證寫操作的結果是多執行緒可見的,此時讀出的resource的值就可能是初始化到一半的失效狀態。

其實只要把resource設定為Volatile就能保證DCL的正常工作,而且效能的影響很小,但是現在JVM已經不斷成熟和完善, 沒有必要再使用DCL技術,延遲初始化佔位模式更為簡單和易於理解。

相關閱讀
1.多執行緒安全性:每個人都在談,但是不是每個人都談地清
2.物件共享:Java併發環境中的煩心事


相關文章