從0學習java併發編程實戰-讀書筆記-java內存模型(14)

什麼是java內存模型

假設一個線程爲變量aVariable賦值:java

aVariable = 3;

內存模型須要解決的問題:在什麼條件下,讀取aVariable的線程將看到這個值爲3?數組

  • 若是缺乏同步,那麼將會有許多因素使得線程沒法當即甚至永遠,看到另外一個線程的操做結果。
  • 在編譯器中生成的指令順序,能夠與源代碼中的順序不一樣,
  • 編譯器還會把變量保存在寄存器而不是內存中。
  • 處理器能夠採用亂序或並行等方式來執行指令。
  • 緩存可能會改變將寫入變量提交到主內存的次序。
  • 保存在處理器本地緩存的值,對於其餘處理器是不可見的。

以上這些因素均可能會使一個線程沒法看到變量的最新值,而且會致使其餘線程中的內存操做彷佛在亂序執行(若是沒有使用正確的同步)。緩存

在單線程環境下,咱們沒法看到全部這些底層技術,它們除了提升程序的執行速度外,不會產生其餘影響。java語言規範要求JVM在線程中維護一種相似串行的語義:只要程序最終結果與在嚴格串行中執行的結果相同,那麼上訴條件都是容許的。
在多線程環境中,維護程序的串行性將帶來很大的性能開銷。對於併發應用程序中的線程來講,它們在大多數時間裏都在執行各自的任務,所以在線程之間的協調操做只會下降應用程序的運行速度,不會帶來任何好處。只有當多個線程須要共享數據時,才必須協調它們之間的操做,而且JVM依賴程序經過同步來找出這些協調將在何時發生。安全

平臺的內存模型

在共享內存的多處理器體系架構中,每一個處理器都擁有本身的緩存,而且按期地與主內存進行協調。在不一樣的處理器架構中提供了不一樣級別的緩存一致性(Cache Coherence),其中一部分只提供最小保證,即容許不一樣的處理器在任意時刻從同一位置上看到不一樣的值
要想確保每一個處理器都能在任意時刻知道其餘正在進行的工做,將須要很是大的開銷。在大多數時間裏,這種信息是沒必要要的,所以處理器適當的放寬一致性保證,以換取性能的提高。
在架構定義的內存模型中,將告訴應用程序能夠從內存系統中得到怎樣的保證,此外還定義了一些特殊的指令(稱爲內存柵欄柵欄),當須要共享數據時,這些指令就能實現額外的存儲協調保證。爲了使java開發人員無需關心不一樣架構上內存模型的差別,java提供了本身的內存模型,而且JVM經過在適當位置插入內存柵欄來屏蔽在JMM與底層平臺內存模型之間的差別。多線程

重排序

在沒有充分同步的程序中,若是調度器採用不恰當的方式來交替執行不一樣線程的操做,那麼將致使不正確的結果。JMM還使得不一樣線程看到的操做執行順序是不一樣的,從而致使在缺少同步的狀況下,要推斷操做的執行順序變得更加複雜。各自使操做延遲或看似混亂執行的不一樣緣由,均可以歸爲重排序。架構

java內存模型簡介

java內存模型是經過各類操做來定義的,包括對變量的讀/寫操做,監視器的加鎖和釋放,以及線程的啓動和合並。JMM爲程序中全部的操做定義了一個偏序關係,稱爲Happens-Before。要想保證操做B的線程看到操做A的結果(不管AB是否在一個線程上執行),那麼AB之間必須知足偏序關係,不然,JVM能夠對它們進行任意的重排序。
偏序關係(Happens-Before)的規則包括:併發

  • 程序順序規則:若是程序中操做A在操做B以前,那麼在線程中A操做將在B操做以前執行。
  • 監視器鎖規則:在監視器鎖上的解鎖操做必須在同一個監視器鎖的加鎖操做以前執行(顯示鎖和內置鎖在加鎖和解鎖等操做上有着相同的內存語義)。
  • volatile變量規則:對volatile變量的寫入操做必須在對該變量的讀操做以前執行(原子變量volatile變量在讀寫操做上有着相同的內存語義)。
  • 線程啓動規則:在線程上對Thread.start的調用必須在該線程中執行任何操做以前執行。
  • 線程結束規則:線程中的任何操做必須在其餘檢測到該線程已經結束以前執行,或者從Thread.join中返回,或者在調用Thread.isAlive時返回false。
  • 中斷規則:當一個線程在另外一個線程上調用interrupt時,必須在被中斷線程檢測到interrupt調用以前執行(經過拋InterruptedException,或者調用isInterrupted和interrupted)。
  • 終結器規則:對象的構造函數必須在啓動該對象的終結器以前執行完成。
  • 傳遞性:若是操做A 在操做B以前執行,操做B在操做C以前執行,那麼操做A必須在操做C以前執行。

藉助同步

因爲Happens-Before的排序功能很強大,所以有時候能夠藉助(Piggyback)現有同步機制的可見性屬性。這須要將Happens-Before的程序順序規則與其餘某個順序規則(一般是指監視器鎖規則或者volatile變量規則)結合起來,從而對某個未被鎖保護的變量訪問操做進行排序。
這項技術因爲對語句順序很是敏感,所以很容易出錯,它是一項高級技術,而且只有當須要最大限度的提高某些類(如ReentrantLock)的性能時候,才應該使用這個技術。
在FutureTask的保護方法AQS中說明了如何使用這種藉助技術。AQS維護了一個表示同步器狀態的整數,FutureTask用這個整數來保存任務的狀態:正在運行、已完成、已取消。但FutureTask還維護了其餘一些變量,例如計算的結果。當一個線程調用set來保存結果而且另外一個線程調用get來獲取該結果時,這兩個線程最好按照Happens-Before進行排序。這能夠經過將執行結果的引用聲明爲volatile類型來實現,但利用現有的同步機制能夠更容易的實現相同功能。app

說明如何藉助同步的FutureTask內部類

private final class Sync extends AbstractQueuedSynchronizer {
    private static final int RUNNING = 1, RAN = 2, CANCELED = 4;
    private V result;
    private Exception excetpion;

    void innerSet(V v){
        while(true){
            int s = getState();
            if(ranOrCanceled(s)){
                return;
            }
            if(compareAndSetState(s, RAN)){
                break;
            }
            result = v;
            releaseShared(0);
            done();
        }

        V innerGet() throws InterruptedException ,ExcutionExcption{
            acquireSharedInterruptibly(0);
            if(getState() == CANCELED){
                throw new CancellationException();
            }
            if(exception != null){
                throw new ExecutionException(exception);
            }
            return result;
        }
    }
}

FutureTask在設計時能確保,在調用tryAcquireShared以前總能成功調用tryReleaseShared。tryReleasedShared會寫入一個volatile類型的變量,而tryAcquireShared將讀取這個變量。innerSet和innerGet,在保存和獲取result時將調用這些方法。
innerSet在調用releaseShared(這個方法又將調用tryReleaseShared)以前寫入result,而且innerGet將在調用acquireShared以後讀取result,所以將程序順序與volatile變量規則結合在一塊兒,就能夠確保innerSet中的寫入操做在innerGet的讀取操做以前執行。函數

之因此這項技術稱爲藉助,是由於它使用了一種現有的Happens-Before順序來確保對象X的可見性,而不是專門爲了發佈X而建立的一種Happens—before順序。
在FutureTask中使用的藉助技術很容易出錯,所以要謹慎使用。在某些狀況下,這種藉助技術是很是合理的。
在類庫中提供的其餘Happens-before技術包括:性能

  • 將一個元素放入一個線程安全容器的操做將在另外一個線程從該容器中得到這個元素的操做以前執行。
  • 在CountDownLatch上的倒數操做將在線程從閉鎖上的await方法中返回以前執行。
  • 釋放Smaphore許可的操做將在從該Semaphore上得到一個許可以前執行。
  • Future表示的任務的全部是操做將在Future.get中返回以前執行。
  • 向Executor提交一個Runnable或Callable的操做將在任務開始執行以前執行。
  • 一個線程到達CyclicBarrier或Exchanger的操做將在其餘到達該柵欄或交換點的線程被釋放以前執行,而柵欄操做又會在線程從柵欄中釋放以前執行。

發佈

不安全的發佈

當缺乏Happens-Before關係時,就可能出現重排序問題,這就解釋了爲何在沒有充分同步的狀況下發佈一個對象會致使另外一個線程看到一個只被部分構造的對象。在初始化一個新的對象時須要寫入多個變量,即新對象中的各個域。一樣,在發佈一個引用時也須要寫入一個變量,即新對象的引用。若是沒法確保發佈共享引用的操做在另外一個線程加載該共享引用以前執行,那麼對新對象引用的寫入操做將與對象中各個域的寫入操做重排序。這時候另外一個線程可能看到對象的某些或者所有狀態中包含的是無效值。

public class UnsafeLazyInitialization{
    private static Resource resource;

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

假設線程A是第一個調用getInstance的線程,它將看到resource爲null,而且初始化一個新的Resource,而後將resource設置爲執行這個新實例。當線程B隨後調用getInstance,它可能看到resource非空,所以使用這個已經構造好的Resource。最初這看不出任何問題。
可是線程A寫入resource的數據與線程B讀取resource操做之間不存在Happens-Before關係。在發佈對象時存在數據競爭問題,所以B並不必定能看到Resource的正確狀態。
當新分配一個對象Resource時,Resource的構造函數將把新實例中的各個域由默認值(由Object構造函數寫入的)修改成它們的初始值。因爲兩個線程中都沒有使用同步,所以線程B看到線程A中的操做順序,可能與線程A執行這些操做的順序並不相同。所以即便線程A初始化Resource以後再將resource的引用指向它,線程B仍然可能看到寫入操做以前的resource狀態,該實例可能處於無效狀態。

除了不可變對象之外,使用被另外一個線程初始化的對象一般是不安全的,除非對象的發佈操做是在使用該對象的線程開始使用以前執行的。

安全的發佈

事實上,Happens-Before比安全發佈提供了更強可見性和順序保證。若是將X從A安全地發佈到B,那麼這種發佈能夠保證X狀態的可見性,但沒法保證A訪問的其餘變量狀態的可見性。然而,若是A將X置入隊列的操做在線程B從隊列中獲取X的操做以前執行,那麼B不只能看到A留下的X狀態(假設線程A在其餘線程都沒有對X進行修改),還能看到A在移交X以前所作的全部操做。

安全初始化模式

public class SafeLazyInitialization{
    private static Resource resource;

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

在初始器中採用了特殊方式來處理靜態域,並提供了額外的線程安全性保證。靜態初始化器是由JVM在類的初始化階段執行,即在類被加載後而且被線程使用以前。因爲JVM將在初始化期間得到一個鎖,而且每一個線程都至少獲取一次這個鎖以確保這個類已經加載。所以在靜態初始化期間,內存寫入操做將自動對全部線程可見。不管是被構造期間仍是被引用時,靜態初始化的對象都不須要顯式的同步。然而這個規則僅適用於構造時的狀態,若是對象是可變的,那麼在讀線程和寫線程之間仍然須要經過同步來確保隨後的操做是可見的,以避免數據被破壞。

雙重加鎖檢測(DCL)

在早期的JVM中,同步存在着巨大的競爭開銷。所以人們相處了許多「聰明的(至少看上去聰明)」的技巧來下降同步的影響,有些技巧很好,但也有些技巧是很差的,甚至是糟糕的,DCL就是糟糕的一類。

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

在編寫正確的延遲初始化方法中須要使用同步,但在當時,同步不只執行速度很慢,而且更重要的是,開發人員尚未徹底理解同步的含義:雖然人們能很好的理解了「獨佔性」的含義,但卻沒有很好理解可見性的含義。
DCL聲稱能實現一箭雙鵰——在常見的代碼路徑上的延遲初始化中不存在同步開銷。它的工做原理是,首先檢查是否在沒有同步的狀況下須要初始化,若是resource引用不爲空,那麼就直接使用它。不然,就進行同步並再次檢查Resource是否被初始化,從而保證只有一個線程對共享的resource進行初始化。在常見的代碼路徑中——獲取一個已經構造好的Resource引用並不須要同步。
DCL真正的問題是:當在沒有同步的狀況下讀取一個共享方法時,可能發生的最糟糕狀況,就是看到一個失效值(在這種狀況下通常是空值)。可是若是使用DCL,線程可能看到引用的當前值,可是對象的狀態倒是失效的,這意味着線程能夠看到對象處於無效或者錯誤的狀態。

初始化過程當中的安全性

若是能確保初始化過程的安全性,那麼就可使得被正確構造的不可變對象在沒有同步的狀況下也能安全地在多個線程之間共享,而無論它們怎麼發佈的,甚至經過某種數據競爭來發布。
若是不能確保初始化的安全性,那麼當在發佈或線程中沒有使用同步時,一些本應爲不可變對象(例如String)的值可能會發生改變,安全架構依賴於String的不可變性,若是缺乏了初始化安全性,那麼可能會致使一個安全漏洞,從而使惡意代碼繞過安全檢查。

初始化安全性將確保,對於被正確構造的對象,全部線程都能看到由構造函數爲對象給各個final域設置的正確值,而無論採用何種方法來發布對象,並且,對於能夠經過被正確構造對象中某個final域到達的任意變量(例如某個final數組的元素、或者由一個final域引用的hashmap裏的內容等)將一樣對於其餘線程是可見的。
對於含有final域的對象,初始化安全性能夠防止對對象的初始引用被重排序到構造過程以前。 當構造函數完成時,構造函數對final域的全部寫入操做,以及對經過這些域能夠到達的任何變量的寫入操做,都將被「凍結」,而且任何得到該對象引用的線程都至少能確保看到被凍結的值。對於經過final域可到達的初始變量的寫入操做,將不會與構造過程後的操做一塊兒被重排序。

不可變對象的初始化安全性

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

    public SafeState(){
        states = new HashMap<String, String>();
        states.put("A","a");
        states.put("B","b");
        states.put("C","c");
        states.put("D","d");
    }
    public String getAbbreviation(String s){
        return states.get("s");
    }
}

對SafeStates的細微修改均可能破壞它的線程安全性,例如若是states不是final類,或者存在構造函數之外的方法能修改states,那麼初始化安全性將沒法確保在缺乏同步的狀況下安全地訪問到SafeStates。若是在SafeStates中還有其餘的非final域,那麼其餘線程仍然可能看到這些域上的不正確的值。這也致使了對象在構造過程當中逸出,從而使初始化安全性的保證無效。

初始化安全性只能保證經過final域可達的值從構造過程完成時開始的可見性。對於經過非final域可達的值,或者在構造過程完成後可能改變的值,必須採用同步來確保可見性。

小結

java內存模型說明了某個線程的內存操做在哪些狀況下對於其餘線程是可見的。其中包括確保這些操做是按照一種Happens-Before的偏序關係進行排序,而這種關係是基於內存操做和同步操做等的級別來定義的。若是缺少充足的同步,那麼當線程訪問共享數據時,會發生一些很是奇怪的問題。

相關文章
相關標籤/搜索