1. 程式人生 > >java多線程12.內存模型

java多線程12.內存模型

存在 sync exce vol 並行 java多線程 jvm exchanger 表示

假設一個線程為變量賦值: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並發編程實戰》

java多線程12.內存模型