1. 程式人生 > >併發程式設計學習筆記之Java儲存模型(十三)

併發程式設計學習筆記之Java儲存模型(十三)

概述

Java儲存模型(JMM),安全釋出、規約,同步策略等等的安全性得益於JMM,在你理解了為什麼這些機制會如此工作後,可以更容易有效地使用它們.

1. 什麼是儲存模型,要它何用.

如果缺少同步,就會有很多因素會導致執行緒無法立即,甚至永遠無法看到另一個執行緒的操作所產生的結果:

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

這些因素都會阻礙一個執行緒看到另一個變數的最新值,而且會引起記憶體活動在不同的執行緒中表現出不同的發生次序---如果你沒有適當同步的話.

在單執行緒環境中,上述情況的發生,我們是無法感知到的,它除了能夠提高程式執行速度外,不會產生其他的影響.

Java語言規範規定了JVM要維護內部執行緒類似順序話語義(within-thread as-if-serial semantics):只要程式的最終結果等同於它在嚴格的順序化環境中執行的結果,那麼上述所有的行為都是允許的.

重新排序後的指令使程式在計算效能上得到了很大的提升.對效能的提升做出貢獻的,除了越來越高的時鐘頻率(它是評定CPU效能的重要指標。一般來說主頻數字值越大越好。),還有不斷提升的並行性.現在時鐘頻率正變得難以經濟地獲得提高,可以提升的只有硬體並行性.

JMM規定了JVM的一種最小保證:什麼時候寫入一個變數會對其他執行緒可見

.

1.1 平臺的儲存模型

在可共享記憶體的多核處理器體系架構中,每個處理器都有它自己的快取,並且週期性的與主記憶體協調一致.

處理器架構提供了不同級別的快取一致性(cache coherence);有的只提供最小的保證,幾乎在任何時間內,都允許不同的處理器在相同的儲存位置上看到不同的值.無論是作業系統、編譯器、執行時(有時甚至包括應用程式),都要將就這些硬體與執行緒安全需求之間的不同.

想要保證每個處理器都能在任意時間獲知其他處理器正在進行的工作,其代價非常高昂,而且大多數時間裡這些資訊沒什麼用,所以處理器會犧牲儲存一致性的保證,來換取效能的提升.

一種架構的儲存模型告訴了應用程式可以從它的儲存系統中獲得何種擔保

,同時詳細定義了一些特殊的指令被稱為儲存關卡(memory barriers)柵欄(fences),用以在需要共享資料時,得到額外的儲存協調保證.

為了幫助Java開發者遮蔽這些跨架構的儲存模型之間的不同,Java提供了自己的儲存模型,JVM會通過在適當的位置上插入儲存關卡,來解決JMM與底層平臺儲存模型之間的差異化.

有一個理想地模型,叫順序化一致性模型說的是:操作執行的順序是唯一的,那就是它們出現在程式中的順序,這與執行他們的處理器無關;另外,變數每一次讀操作,都能得到執行序列上這個變數最新的寫入值,無論這個值是哪個處理器寫入的.

這是一個理想,沒有哪個現代的多處理器會提供這一點,JMM也不行.這個模型又叫馮·諾依曼模型,這個經典的順序化計算模型,僅僅是現代多處理器行為的模糊近似而已.

最後的結論就是: 在Java中,跨執行緒共享資料時,只需要正確的使用同步就可以保證執行緒安全,不需要在程式中指明儲存關卡的放置位置..

1.2 重排序

各種能夠引起操作延遲或者錯序執行的不同原因,都可以歸結為一類重排序(reordering).

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() {
                    @Override
                    public void run() {
                        a = 1 ;
                        x = b ;
                    }
                });

                Thread other = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        b = 1;
                        y = a;
                    }
                });
                one.start();
                other.start();

                one.join();
                other.join();
                System.out.println("x:"+x);
                System.out.println("y:"+y);
    }

}

PossibleReordering可能因為重排序列印輸出 0,0 1,1 1,0.

這是一個簡單的程式,但是因為重排序的存在它列出的結果仍然讓人驚訝.

記憶體級的重排序會讓程式的行為變得不可預期.沒有同步,推斷執行次序的難度令人望而卻步;只要確保你的程式已正確同步,事情就會變得簡單些.

同步抑制了編譯器、執行時和硬體對儲存操作的各式各樣的重排序,否則這些重排序會破壞JMM提供的可見性保證.

1.3 Java儲存模型的簡介

Java儲存模型的定義是通過動作(actions)的形式進行描述的,所謂動作,包括變數的讀和寫、監視器加鎖和釋放鎖、執行緒的啟動和拼接(join).

JMM為所有程式內部的動作定義了一個偏序關係(happens-before),要想保證執行動作B的執行緒看到動作A的結果(無論A和B是否發生在同一個執行緒),A和B之間就必須滿足happens-before關係.如果兩個操作之間並未按照happens-before關係排序,JVM可以對它們隨意地重排序.

偏序關係≼: 是集合上的一種反對稱的,自反的和傳遞的關係,不過並不是任意兩個元素x,y都必須滿足 x≼y或者y≼x.我們每天都在應用偏序關係來表達我們的喜好;我們可以喜歡壽司勝過三明治,可以喜歡莫扎特勝過馬勒,但是我們不必在三明治和莫扎特之間做出一個明確的喜好選擇.

當一個變數被多個執行緒讀取,且至少被一個執行緒寫入時,如果讀寫操作並未按照happens-before排序,就會產生資料競爭(data race).一個正確同步的程式(correctly synchronized program)是沒有資料競爭的程式;正確同步的程式會表現順序的一致性,這就是說所有程式內部的動作會以固定的、全域性的順序發生.

資料競爭: 如果在訪問共享的非final型別的域時沒有采用同步來進行協同,那麼就會出現資料競爭.(資料競爭主要會引發過期資料的問題)

happens-before的法則包括:

  • 程式次序法則:執行緒中的每個動作A都happens-before於該執行緒中的每一個動作B,其中,在程式中,所有的動作B都出現在動作A之後.
  • 監視器鎖法則:對一個監視器鎖的解鎖happens-before於每一個後續對同一監視器鎖定加鎖.(同顯示鎖)
  • volatile變數法則:對volatile域的寫入操作happens-before於每一個後續對同一域的讀操作.
  • 執行緒啟動法則:在一個執行緒裡,對Thread.start的呼叫會happens-before於每一個啟動執行緒中的動作.
  • 執行緒終結法則:執行緒中的任何動作都happens-before於其他執行緒檢測到這個執行緒已終結、或者從Thread.join呼叫中成功返回,或者Thread.isAlive返回false.
  • 中斷法則:一個執行緒呼叫另一個執行緒的interrupt happens-before於被中斷的執行緒發現中斷(通過丟擲InterruptedException,或者呼叫isInterrupted和interrupted)
  • 終結法則:一個物件的建構函式的結束 happens-before於B,且B happens-before於C,則A happens-before 於C.

雖然動作僅僅需要滿足偏序關係,但是同步動作--鎖的獲取與釋放,以及volatile變數的讀取與寫入--卻是滿足全序關係(當偏序集中的任意兩個元素都可比時,稱該偏序集滿足全序關係).

1.4 由類庫擔保的其他happens-before排序

包括:

  • 將一個條目置入執行緒安全容器happens-before於另一個執行緒從容器中獲取條目.
  • 執行CountDownLatch中的倒計時happens-before於執行緒從閉鎖(latch)的await中返回.
  • 釋放一個許可給Semaphore happens-before 於從同一個Semaphore裡獲得一個許可.
  • Future表現的任務所發生的動作 happens-before 於另一個執行緒成功地從Future.get
  • 向Executor提交一個Runnable或Callable happens-before 與開始執行任務.
  • 最後,一個執行緒到達CyclicBarrier或者Exchanger happens-before於相同關卡(barrier)或Exchanger點中的其他執行緒被釋放.如果CyclicBarrier使用一個關卡(barrier)動作,到達關卡happens-before於關卡動作,依照次序,關卡動作happens-before於執行緒從關卡中釋放.

2. 釋出

安全釋出技術之所以是安全的,正是得益於JMM提供的保證.

而不正確釋出帶來風險的真正原因,是在"釋出共享物件"與從"另一個執行緒訪問它"之間,缺少happens-before.

2.1 不安全的釋出

在缺少happens-before的情況下,存在重排序的可能性.所以沒有充分同步的情況下發佈一個物件會導致看到物件的過期值(在賦值的情況下可能看到物件是null或者物件的引用是null).

區域性建立物件:

public class UnsafeLazyInitialization {
    private static  Resource resource;

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

}

這是非執行緒安全的,一個執行緒呼叫getInstance, 當== null成真時,為resource賦值,但是不能保證對另一個執行緒可見,會有過期值的問題.

類中只有一種方式獲得resource物件的例項就是通過getInstance方法,但是因為呼叫這個方法的執行緒之間沒有同步,所以即使程式碼的書寫順序是在 == null的時候先賦值再返回引用,但是另一個執行緒得到resource例項的時候可能因為重排序導致得到的是一個resouce是new 出來的例項,但是物件的域為null的情況.

除了不可變物件,使用被另一個執行緒初始化的物件,是不安全的,除非物件的釋出時happens-before於物件的消費執行緒使用它.

2.2 安全釋出

安全釋出之所以是安全的是因為釋出的物件對於其他執行緒是可見的.因為它們保證釋出物件是happens-before於消費執行緒載入已釋出物件的引用.

happens-before比安全釋出承諾更強的可見性與排序性.但是安全釋出的操作更加貼近程式設計.

2.3 安全初始化技巧

有些物件的初始化很昂貴,這時候惰性初始化的好處就顯現出來了.

可以修改一下之前的程式碼,使它變成執行緒安全的.

public class UnsafeLazyInitialization {
    private static  Resource resource;

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

}

在類中,靜態的初始化物件:

private static Resource resource = new Resource();

提供了額外的執行緒安全性保證,JVM要在初始化期間獲得一個鎖,這個鎖每個執行緒至少會用到一次來確保一個類是否已被載入:這個鎖也保證了靜態初始化期間,記憶體寫入的結果自動地對所有執行緒是可見的.所以靜態初始化的物件,無論是構造期間還是被引用的時候,都不需要顯示地進行同步.(只適用於構造當時(as-constructed)的狀態,如果物件是可變的,還是需要加鎖)

public class EagerInitialization{
    private static Resource resource = new Resource();
    
    publiic static Resource getResource(){
        return resource;
    }
}

惰性初始化holder類技巧:

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

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

2.4 雙重檢查鎖

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
    }
}

雙重檢查鎖最大的問題在於:執行緒可看到引用的當前值,但是物件的狀態確實過期的.這意味著物件可以被觀察到,但卻處於無效或錯誤的狀態.

雙重檢查鎖已經被廢棄了---催生它的動力(緩慢的無競爭同步和緩慢的JVM啟動)已經不復存在.這樣的優化已經不明顯了. 使用惰性初始化更好.

3. 初始化安全性

保證了初始化安全,就可以讓正確建立的不可變物件在沒有同步的情況下被安全地跨執行緒共享,而不用管它是如何釋出的.

如果沒有初始化安全性,就會發生這樣的事情:像String這樣的不可變物件,沒有在釋出或消費執行緒中用到同步,可能表現出它們的值被改變.

初始化安全可以保證,對於正確建立的物件,無論它是如何釋出的,所有執行緒都將看到建構函式設定的final域的值.更進一步,一個正確建立的物件中,任何可以通過其final域觸及到的變數(比如一個final陣列中的元素,或者一個final域引用的HashMap裡面的內容),也可以保證對其他執行緒是可見的(只有通過正在被構建的物件的final域,才能觸及到).

不可變物件的初始化安全性:

    public class SafeStates {
    private final Map<String,String> states;

    public SafeStates() {
        states = new HashMap<>();
        states.put("a","a");
        states.put("b","b");
        states.put("c","c");
    }

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

對於含有final域的物件,初始化安全性可以抑制重排序,否則這些重排序會發生在物件的構造期間以及內部載入物件引用的時刻.所有建構函式要寫入值的final域,以及任何通過這些域得到的任何變數,都會在建構函式完成後被"凍結",可以保證任何獲得該引用的執行緒,至少可以看到和凍結一樣的新值.

所以在即使沒有同步,而且依賴於非執行緒安全的HashSet可以被安全地釋出.但是隻止於安全的釋出,如果有任何執行緒可以修改states的值還是需要同步來保證執行緒安全性.

初始化安全性保證只有以通過final域觸及的值,在建構函式完成時才是可見的,對於通過非final域觸及的值,或者建立完成後可能改變的值,必須使用同步來確保可見性.

總結

Java儲存模型明確地規定了在什麼時機下,操作儲存器的執行緒的動作可以被另外的動作看到.規範還規定了要保證操作是按照一種偏序關係進行排序.這種關係稱為happens-before,它是規定在獨立儲存器和同步操作的級別之上的.

如果缺少充足的同步,執行緒在訪問共享資料時就會發生非常無法預期的事情.但是使用安全釋出可以在不考慮happens-before的底層細節的情況下,也能確保安全性.