1. 程式人生 > >java多執行緒12.記憶體模型

java多執行緒12.記憶體模型

假設一個執行緒為變數賦值:variable = 3;

  記憶體模型需要解決一個問題:“在什麼條件下,讀取variable的執行緒將看到這個值為3?”

  這看上去理所當然,但是如果缺少記憶體同步,那麼將會有許多因素使得執行緒無法立即甚至永遠,看到另一個執行緒的操作結果。

如:

  • 1.在編譯器中生成的指令順序,可以與原始碼中的順序不同,此外編譯器還會將變數儲存在暫存器而不是記憶體中;
  • 2.處理器可以採用亂序或並行等方式來執行指令;
  • 3.快取可能會改變將寫入變數提交到主記憶體的次序;
  • 4.而且儲存在處理器本地快取中的值,對於其他處理器是不可見的。

這些因素都會使得一個執行緒無法看到變數的最新值,並且會導致其他執行緒中的記憶體操作似乎在亂序執行。

Java語言規範要求JVM線上程中維護一種類似序列的語義:只要程式的最終結果與嚴格序列環境中執行的結果相同,那麼上述所有的操作都是允許的

這確實是一件好事,因為計算機近年來在效能上的提升很大程度要歸功於這些重新排序措施。

在單執行緒環境中,我們無法看到所有這些底層技術,它們除了提高程式的執行速度外,不會產生其他影響。

在多執行緒環境中,要維護程式的序列性將導致很大的效能開銷。對於併發應用程式中的執行緒來說,它們在大部分時間裡都執行各自的任務,因此線上程之間的協調操作只會降低應用程式的執行速度,而不會帶來任何好處。只有當多個執行緒要共享資料時,才必須協調它們之間的操作,並且JVM依賴程式通過同步操作來找出這些協調操作將在何時發生。

JVM規定了一組最小保證,這組保證規定了對變數的寫入操作將在何時對於其他執行緒可見。JVM在設計時就在可預測性和程式的易於開發性之間進行了權衡,從而在各種主流的處理器體系架構上都能實現高效能的JVM。

平臺的記憶體模型

在共享記憶體的多處理器體系架構中,每個處理器都擁有自己的快取,並且定期地與主記憶體進行協調。

在不同的處理器架構中提供了不同級別的快取一致性,其中一部分只提供最小的保證,即允許不同的處理器在任意時刻從同一個儲存位置上看到不同的值

要想確保每個處理器都能在任意時刻知道其他處理器正在進行的工作,將需要非常大的開銷。在大多數時間裡,這種資訊是不必要的,因此處理器會適當放寬儲存一致性保證,以換取效能的提升。

在架構定義的記憶體模型中將告訴應用程式可以從記憶體系統中獲得怎樣的保證,此外還定義了一些特殊的指令(稱為記憶體柵欄),當需要共享資料時,這些指令就能實現額外的儲存協調保證。為了使Java開發人員無須關心不同架構上記憶體模型之間的差異,Java還提供了自己的記憶體模型,並且JVM通過在適當的位置插上記憶體柵欄來遮蔽在JVM與底層平臺記憶體模型之間的差異。

假設:想象在程式中只存在唯一的操作執行順序,而不考慮這些操作在何種處理器上執行,並且在每次讀取變數時,都能獲得在執行序列中(任何處理器)最近一次寫入該變數的值。

這種樂觀的模型被稱為序列一致性,開發人員經常會錯誤的假設存在序列一致性,但在任何一款現代多處理器架構中都不會提供這種序列一致性,JVM也如此。

在支援共享記憶體的多處理器和編譯器中,當跨執行緒共享資料時,會出現一些奇怪的情況,除非通過使用記憶體柵欄來防止這些情況的發生。不過在Java程式中不需要指定記憶體柵欄的位置,而只需要通過正確地使用同步來找出何時將訪問共享狀態

重排序

/**
 * 在沒有正確同步的情況下,即使要推斷最簡單的併發程式的行為也很困難。
 * 示例中:很容易想象如何輸出(1,0),(0,1)或(1,1),T1可以在T2開始之前完成,T2也可以在T1開始之前完成,或者二者交替執行。
 * 但還可以輸出(0,0),由於每個執行緒中的各個操作之間不存在資料流依賴性,因此這些操作可以亂序執行(即使這些操作按照順序執行,但在將
 * 快取重新整理到主記憶體的不同時序中也可能出現這種情況,在T2的角度看,T1的賦值操作可能以相反的次序執行)。
 * 可以想象在T2看來的執行順序[ x=b, b=1, y=a, a=1 ]
 * 要列舉出這個簡單示例的所有可能執行結果非常困難,記憶體級的重排序會使程式的行為變得不可預測。
 * 而要確保在程式中正確地使用同步卻非常容易,同步將限制 編譯器、執行時和硬體對記憶體操作重排序的方式,從而在重排序時不會破壞JVM提供的可見性保證。
 */
public class Demo{
    
    static int x = 0, y = 0;
    static int a = 0, b = 0;
    
    public static void main(String[] args) throws InterruptedException {
        
        Thread T1  = new Thread(new Runnable(){
            public void run() {
                a = 1;
                x = b;
            }
        });
        
        Thread T2 = new Thread(new Runnable(){
            public void run() {
                b = 1;
                y = a;
            }
        });
        
        T1.start();
        T2.start();
        
        T1.join();
        T2.join();
        
        System.out.println(x +"--"+y);
    }
}

Java記憶體模型

java記憶體模型是通過各種操作來定義的,包括對變數的讀/寫操作,監視器的加鎖和釋放操作,以及執行緒的啟動和合並操作。

JVM為程式中所有的操作定義了一個偏序關係,稱為Happens-Before。要想保證執行操作B的執行緒看到操作A的結果(無論A和B是否在同一個執行緒中執行),那麼在A和B之間必須滿足Happens-Before關係。如果缺乏這個關係,那麼JVM可以對它們任意的重排序

當一個變數被多個執行緒讀取並且至少被一個執行緒寫入時,如果在讀操作和寫操作之間沒有依照Happens-Before來排序,那麼就會產生資料競爭問題。在正確同步的程式中不存在資料競爭,並會表現出序列一致性,就是說程式中的所有操作都會按照一種固定的和全域性的順序執行。

  • Happens-Before規則:
  • 1.程式順序規則:如果程式中操作A在操作B之前,那麼執行緒中操作A將在操作B之前執行。
  • 2.監視器鎖規則:在監視器鎖上的解鎖操作必須在同一個監視器鎖上的加鎖操作之前執行。
  • 3.volatile變數規則:對volatile變數的寫入操作必須在對該變數的讀操作之前執行。
  • 4.執行緒啟動規則:線上程上對Thread.Start的呼叫必須在該執行緒中執行任何操作之前執行。
  • 5.執行緒結束規則:執行緒中的任何操作都必須在其他執行緒檢測到該執行緒已經結束之前執行,或者Thread.join中成功返回,或者在呼叫Thread.isAlive時返回false。
  • 6.終端規則:當一個執行緒在另一個執行緒上呼叫interrupt時,必須在被終端執行緒檢測到interrupt呼叫之前執行通過丟擲(InterruptException,或者呼叫isInterrupted和interrupted。)
  • 7.終結器規則:物件的建構函式必須在啟動該物件的終結器之前執行完成。
  • 8.傳遞性:如果操作A在操作B之前執行,並且操作B在操作C之前執行,那麼操作A必須在操作C之前執行。

雖然這些操作只滿足偏序關係,但同步操作,如鎖的獲取與釋放,以及volatile變數的讀取與寫入操作,都滿足全序關係。

因此,在描述Happens-Before關係時,就可以使用“後續的鎖獲取操作”和“後續的volatile變數讀取操作”等表達術語。

當兩個執行緒使用同一個鎖進行同步時,在它們之間存在Happens-Before關係。

線上程A內部的所有操作都按照它們在源程式中的先後順序來排序,線上程B內部的操作也是如此。在A釋放了鎖M,並且B隨後獲取了鎖M,因此A中所有在釋放鎖之前的操作,就位於B中請求鎖之後的所有操作之前。而如果兩個執行緒是在不同的鎖上進行同步的,那麼就不能推斷它們之間的動作順序,因為兩個執行緒之間並不存在Happens-Before關係。

藉助同步

將Happens-Before的程式順序規則與其他某個順序規則(通常是監視器鎖規則或volatile變數規則)結合起來,從而對某個未被鎖保護的變數的訪問操作進行排序。

在FutureTask的保護方法AbstractQueuedSynchronizer中說明了如何使用這種“藉助”技巧。

AQS維護了一個表示同步器狀態的整數,FutureTask用這個整數來儲存任務的狀態。但FutureTask還維護了其他一些變數,如計算結果。

當一個執行緒呼叫set來儲存結果並且另一個執行緒呼叫get來獲取結果時,這兩個執行緒最好按照Happens-Before進行排序。這可以將執行結果的引用宣告為volatile型別來實現,但利用現有的同步機制可以更容易地實現相同的功能。

/**
 * FutureTask在設計時能夠確保,在呼叫tryAcquireShared之前總能成功地呼叫tryReleaseShared。
 * tryReleaseShared會寫入一個volatile型別的變數,而tryAcquireShared將讀取這個變數。
 * 在儲存和獲取result時將呼叫innerSet和innerGet方法。
 * 由於innerSet將在呼叫releaseShared(這又將呼叫tryReleaseShared)之前寫入result,
 * 並且innerGet將在呼叫acquireShared(這又將呼叫tryAcquireShared)之後讀取result,
 * 因此就可以確保innerSet的寫入操作在innerGet中的讀取操作之前執行。
 *
 * @param <V>
 */
public class FutureTask<V>{
    
    private final class Sync extends AbstractQueuedSynchronizer{
        
        private static final int RUNNING = 1;
        
        private static final int RAN = 2;
        
        private static final int CANCELLED = 4;
        
        private V result;
        
        private Exception exception;
        
        void innerset(V v){
            while(true){
                int s = getState();
                if(ranOrCancelled(s)){
                    return;
                }
                if(compareAndSetState(s,RAN)){
                    break;
                }
            }
            result = v;
            releaseShared(0);
            done();
        }
        
        V innerGet() throws InterruptedException, ExecutionException{
            acquireSharedInterruptibly(0);
            if(getState() == CANCELLED){
                throw new CancellationException();
            }
            if(exception != null){
                throw new ExecutionException(exception);
            }
            return result;
        }
    }
}

 

  • 類庫中提供的其他Happens-Before排序如:
  • 1.將一個元素放入一個執行緒安全容器的操作將在另一個執行緒從該容器中獲得這個元素的操作之前執行。
  • 2.在CountDownLatch上的倒數操作將線上程從閉鎖上的await方法中返回之前執行。
  • 3.在釋放Semaphore許可操作將在從該Semaphore上獲得一個許可之前執行。
  • 4.Future表示的任務的所有操作將在從Future.get中返回之前執行。
  • 5.向Exceutor提交一個Runnable或Callable的操作將在任務開始之前執行。
  • 6.一個執行緒到達CyclicBarrier或Exchanger的操作將在其他到達該柵欄或交換點的執行緒被釋放之前執行。如果CyclicBarrier使用一個柵欄操作,那麼到達柵欄的操作將在柵欄操作之前執行,而柵欄操作又會線上程從柵欄中釋放之前執行。

不安全的釋出

當缺少Happens-Before關係時,就可能出現重排序問題,這可以解釋為什麼在沒有充分同步的情況下發佈一個物件會導致另一個執行緒看到一個只被部分構造的物件。

在初始化一個新的物件時需要寫入多個變數,即新物件中的各個域。同樣,在釋出一個引用時也需要寫入一個變數,即新物件的引用。

如果無法確保釋出共享引用的操作在另一個執行緒載入該共享引用之前執行,那麼對新物件引用的寫入操作與物件中各個域的寫入操作重排序(從使用該物件的執行緒的角度來看)。

/**
 * 程式中存在的問題似乎只有競態條件問題(當所有Resource示例都相同時可以忽略)
 * 即使不考慮這個問題,這樣釋出仍然是不安全的,因為在另一個執行緒可能看到部分構造的Resource例項的引用。
 *  
 * 假設T1是第一個呼叫getInstance的執行緒,它將看到resource為null,並且初始化一個新的Resource,然後將resource設定為這個新例項。
 * 當T2隨後呼叫getInstance,它可能看到resource值為非空,因此使用這個已經構造好的Resource。
 * 但T1寫入resource的操作與T2讀取resource的操作之間並不存在Happens-Before方法。
 *  
 * 當新分配一個Resource時,Resource的建構函式將把新例項中的各個域由預設值修改為初始值。
 * 由於兩執行緒未使用同步,因此T2看到的T1的操作順序,可能與T1執行這些操作時的順序不同。
 * 即T2可能看到對resource的寫入操作將在Resource各個域的寫入操作之前發生。 從而T2就看到一個被部分構造處於無效狀態的Resource例項。
 */
public class Resource{
    
    private static Resource resource;
    
    public static Resource getInstance(){
        if(resource == null){
            resource = new Resource();
        }
        return resource;
    }
}

除了不可變物件以外,使用被另一個執行緒初始化的物件通常都是不安全的,除非物件的釋出操作是在使用該物件的執行緒開始使用之前執行。

安全釋出

在上面示例中需要將getInstance改為synchronized,使用同步即可解決問題。

JVM在初始器中採用了特殊的方式來處理靜態域(或者在靜態初始化程式碼塊中初始化的值),並提供了額外的執行緒安全性保證

靜態初始化器是由JVM在類的初始化階段執行,即在類被載入後並且執行緒使用之前

由於JVM將在初始化期間獲得一個鎖,並且每個執行緒只是獲取一次這個鎖以確保這個類已經載入,因此在靜態初始化期間,記憶體的寫入操作將自動對所有執行緒可見

因此無論是在被構造期間還是被引用時,靜態初始化的物件都不需要顯示的同步

然而這個規則僅適用於在構造時的狀態,如果物件時可變的,那麼在讀執行緒和寫執行緒之間仍然需要通過同步來確保隨後的修改操作是可見的,以及避免資料破壞。

將靜態初始化器這種特性和JVM的延遲載入機制結合起來,可以形成一種延遲初始化技術。

/**
 * 延遲初始化佔位模式:使用一個專門的類來初始化Resource。
 * JVM將推遲ResourceHolder的初始化操作,直到開始使用這個類時才初始化,並且由於通過一個靜態初始化來初始化Resource,因此不需要額外的同步。
 * 當任何一個執行緒第一次呼叫getResource時,都會使ResourceHolder被載入和初始化,此時靜態初始化器將執行
 */
public class ResourceFactory{
    
    private static class ResourceHolder{
        public static Resource resource = new Resource();
    }
    
    public static Resource getResource(){
        return ResourceHolder.resource;
    }
}

 

#筆記內容參考 《java併發程式設計實戰》