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

概述

Java存儲模型(JMM),安全發佈、規約,同步策略等等的安全性得益於JMM,在你理解了爲何這些機制會如此工做後,能夠更容易有效地使用它們.java

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

若是缺乏同步,就會有不少因素會致使線程沒法當即,甚至永遠沒法看到另外一個線程的操做所產生的結果:數組

  • 編譯器生成指令的次序,能夠不一樣於源代碼書寫的順序,並且編譯器還會把變量存儲在寄存器,而不是內存中.
  • 處理器能夠亂序或者並行地執行指令.
  • 緩存會改變寫入提交到主內存的變量的次序.
  • 存儲在處理器本地緩存中的值,對於其餘處理器並不可見.

這些因素都會阻礙一個線程看到另外一個變量的最新值,並且會引發內存活動在不一樣的線程中表現出不一樣的發生次序---若是你沒有適當同步的話.緩存

在單線程環境中,上述狀況的發生,咱們是沒法感知到的,它除了可以提升程序執行速度外,不會產生其餘的影響.安全

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

從新排序後的指令使程序在計算性能上獲得了很大的提高.對性能的提高作出貢獻的,除了愈來愈高的時鐘頻率(它是評定CPU性能的重要指標。通常來講主頻數字值越大越好。),還有不斷提高的並行性.如今時鐘頻率正變得難以經濟地得到提升,能夠提高的只有硬件並行性.app

JMM規定了JVM的一種最小保證:何時寫入一個變量會對其餘線程可見.ide

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的底層細節的狀況下,也能確保安全性.

相關文章
相關標籤/搜索