從Java內存模型角度理解安全初始化

本文將簡要介紹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并發環境中的煩心事

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,321評論 6 543
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,559評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,442評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,835評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,581評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,922評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,931評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,096評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,639評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,374評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,591評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,104評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,789評論 3 349
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,196評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,524評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,322評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,554評論 2 379

推薦閱讀更多精彩內容