1. 程式人生 > >記憶體模型(一)

記憶體模型(一)

多工處理在現代計算機作業系統中幾乎已經是一項必備的功能了。在許多情況下,讓計算機同時去做幾件事情,不僅是因為計算機的運算能力臺強大,還有一個很重要的原因是計算機的運算速度與它的儲存和通訊子系統速度的差距太大,大量的時間多花在磁碟I/O、網路通訊或者資料庫訪問上。於是,讓計算機處理多項任務則是最容易也是最有效利用計算機運算速度的方式。

除了充分能利用計算機處理器的能力外,一個服務端同時對多個客戶端提供服務則是另一個更具體的併發應用場景。衡量一個服務效能的高低好壞,每秒事務處理數(Transactions per second,TPS)是最重要的指標之一。  它代表著一秒內服務端能響應的請求總數,而TPS值與程式的併發能力又有非常密切的關係。

  • 硬體的效率與一致性

在瞭解JVM併發相關的知識之前,先了解一下物理計算機中的併發問題,因為物理機遇到的併發問題與虛擬機器中的情況有不少相似之處,物理機對併發的處理方案對於虛擬機器的實現也有相當大的意義。

一個任務的執行,不僅需要處理器“計算”就可以完成,處理器至少要與記憶體互動,如讀取運算資料、儲存運算結果等,這個I/O操作是很難消除的,因為不可能僅靠暫存器來完成所有運算任務。由於計算機的儲存裝置和處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的高度快取(cache)來作為記憶體與處理器之間的緩衝。快取的原理是將運算需要使用到的資料複製到快取中,讓運算能快速進行,當運算結束後再從快取同步回記憶體之中,這樣處理器就無須等待緩慢的記憶體讀寫了。

基於快取記憶體的儲存互動很好地解決了處理器與記憶體的速度矛盾,但是也為計算機系統帶來更高的複雜度,因為它引入了一個新的問題:快取一致性

在多處理器系統中,每個處理器都有自己的快取記憶體,而它們又共享同一主記憶體。當多個處理器的運算任務都涉及同一塊主記憶體區域時,將可能導致各自的快取資料不一致,如果真的發生這種情況,那同步回到主記憶體時以誰的快取資料為準呢?為了解決一致性的問題,需要各個處理器訪問快取時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有MSI、MESI、MOSI、Synapse、Firefly及Dragon Protocol等。

記憶體模型,可以理解為在特定的操作協議下,對特定的記憶體或告訴快取進行讀寫訪問的過程抽象。不同架構的物理機器可以擁有不一樣的記憶體模型,而JVM也有自己的記憶體模型。而且JVM中的記憶體訪問操作與硬體的快取訪問操作具有很高的可比性。

除了增加快取記憶體之外,為了使得處理器內部的運算單元能儘量被充分利用,處理器可能會對輸入程式碼進行亂序執行優化,處理器會在計算之後將亂序執行的結果重組,保證該結果與順序執行的結果是一致的,但並不保證程式中各個語句計算的先後順序與輸入程式碼中的順序一致,因此,如果存在一個計算任務依賴另一個計算任務的中間結果,那麼其順序性並不能靠程式碼的先後順序來保證。與處理器的亂序執行優化類似,JVM的即時編譯器中也有類似的指令重排序優化。

  • Java記憶體模型

JVM虛擬機器規範中定義了一種Java記憶體模型來遮蔽掉各種硬體和作業系統的記憶體訪問差異,以實現讓Java程式在各種平臺下都能達到一致的記憶體訪問效果。而其他的語言,如C、C++則直接使用物理硬體和作業系統的記憶體模型,因此,會由於不同平臺上記憶體模型的差異,有可能導致程式在一套平臺上併發完全正常,而在另外一套平臺上併發訪問卻經常出錯,因此在某些場景就必須針對不同的平臺來編寫程式。

Java記憶體模型實際上還是利用暫存器、告訴快取指令集中某些特有的指令來獲取更好的執行速度。JDK1.5之後,Java記憶體模型已經成熟和完善起來了。

(1)主記憶體與工作記憶體

Java記憶體模型的主要目標是定義程式中各個變數的訪問規則(這裡的變數不像java中定義的變數,這個變數包括了例項欄位、靜態欄位和構成陣列物件的元素,但不包括區域性變數與方法引數,因為這些是執行緒私有的,不會被共享,也就不會有競爭),即在JVM中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節。

為了獲得較好的執行效能,java記憶體模型並沒有限制執行引擎使用處理器的特定暫存器或快取來和主記憶體進行互動,也沒有限制即時編譯器進行調整程式碼執行順序這類優化措施。

java記憶體模型規定了所有變數都儲存在主記憶體中(這裡的主記憶體類似與物理機的主記憶體,只不過這個主記憶體只是JVM記憶體的一部分)。每個執行緒還有自己的工作記憶體(類似於物理機的快取記憶體),執行緒的工作記憶體儲存了被該執行緒使用到的變數的主記憶體副本拷貝,執行緒對變數的所有操作如讀取、賦值等操作必須在工作記憶體中執行,而不能直接讀寫主記憶體中的變數。不同執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞均需要通過主記憶體來完成。

注:主記憶體、工作記憶體與java記憶體區域中的堆、棧、方法區等的區別:

       這兩種基本上是沒有關係的,如果兩者一定要勉強對應起來,那麼主記憶體主要對應於java堆中的物件例項資料部分,而工作記憶體則對應於JVM棧中的部分割槽域。從更低層次上說,主記憶體就對應於物理硬體的記憶體,而為了獲得更好的執行速度,JVM可能會讓工作記憶體優先儲存於暫存器和快取記憶體中,因為程式執行時主要訪問讀寫的是工作記憶體。

(2)記憶體間互動操作

關於主記憶體和工作記憶體之間具體的互動協議,即一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回主記憶體之類的實現細節,java記憶體模型中定義了以下8種操作來完成,JVM實現時必須保證下面的沒種操作都是原子的、不可再分的。

lock(鎖定):作用於主記憶體變數,它把一個變數標識為一條執行緒獨佔的狀態。

unlock(解鎖):作用於主記憶體變數,它把一個處於鎖定狀態的變臉釋放出來,釋放後的變數才可以被其他執行緒鎖定。

read(讀取):作用於主記憶體變數,它把一個變數的值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load操作執行。

load(載入):作用於工作記憶體的變數,它把read操作從主記憶體得到的變數值放入工作記憶體的變數副本中。

use(使用):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳遞給執行引擎,每當JVM遇到一個需要使用到變數的值的位元組碼指令時將會執行這個操作。

assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。

store(儲存):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳送到主記憶體中,以便隨後的write操作使用。

write(寫入):作用於主記憶體的變數,它把store操作從工作記憶體中得到的變數的值放入主記憶體的變數中。

如果要把一個變數從主記憶體複製到工作記憶體,那就要順序地執行read和load操作,如果要把變數從工作記憶體同步回主記憶體,就要順序地執行store和write操作。而且,java記憶體模型只要求上述兩個操作必須按順序執行,而沒有保證是連續執行。

java記憶體模型還規定了在執行上述八種操作時必須遵循如下規則:

不允許read和load、store和write操作之一單獨出現,即不允許一個變數從主記憶體讀取了但工作記憶體不接受,或者從工作記憶體發起了回寫但主記憶體不接受的情況出現。

不允許一個執行緒丟棄它的最近的assign操作,即變數在工作記憶體中改變了之後必須把該變化同步回主記憶體。

不允許一個執行緒無原因地(沒有發生過任何assign操作)把資料從執行緒的工作記憶體同步回主記憶體中。

一個新的變數只能在主記憶體中“誕生”,不允許在工作記憶體中直接使用一個未被初始化(load或assign)的變數,換句話說,就是對一個變數實施use、store操作之前,必須先執行過了assign和load操作。

一個變數在同一時刻只允許一條執行緒對其執行lock操作,但lock操作可以被同一條執行緒重複執行多次,多次執行lock之後,只有執行相同次數的unlock操作,變數才會被解鎖。

如果對一個變數執行lock操作,那將會清空工作記憶體中該變數的值,在執行引擎使用這個變數前,需要重新執行load或assign操作初始化變數的值。

如果一個變數事先沒有被lock操作鎖定,那就不允許對它執行unlock操作,也不允許去unlock一個被其他執行緒鎖定住的變數。

對一個變數執行unlock操作之前,必須先把此變數同步回主存中。

(3)對於volatile型變數的特殊規則

關鍵字volatile是JVM提供的最輕量級的同步機制,java記憶體模型對volatile專門定義了一些特殊的訪問規則。

當一個變數定義為volatile之後,它將具備兩種特性,第一是保證此變數對所有執行緒的可見性,這裡的“可見性”是指當一條執行緒修改了這個變數的值,新值對於其他執行緒來說是可以立即得知的,而普通變數不能做到這一點,普通變數的值線上程間傳遞均需要通過主記憶體來完成,即執行緒A修改了一個普通變數的值,執行緒B只有在執行緒A將修改同步到主記憶體之後,再進行讀取操作,才能得到變數的新值。

“volatile變數在各個執行緒的工作記憶體中不存在一致性問題”。其實在各個執行緒的工作記憶體中,volatile變數也可以存在不一致的情況,但由於每次使用之前都要先重新整理,執行引擎看不到不一致的情況,因此可以認為不存在一致性問題。但是java裡的操作並非原子操作,導致volatile變數的運算在併發下一樣是不安全的。

也就是說,如果不考慮執行緒併發(可以有多個執行緒,但它們不會併發執行),volatile變數不存在一致性問題。如果多執行緒併發,那麼volatile變數也一樣是不安全的。如下示例:用20個執行緒對一個初值為0的volatile變數進行自增,每個執行緒自增10000(執行次數一定要大一點兒,不然模擬不出效果)次,那麼最後結果應該是200000,但是多次執行的結果都會小於200000。

package C;
public class VolatileTest {

    public static volatile int race = 0;
    
    public static void increase(){
        race++;
    }
    
    private static final int THREADS_COUNT = 20;
    
    public static void main(String[] args) {
        
        Thread[] threads = new Thread[THREADS_COUNT];
        
        for(int i=0;i<THREADS_COUNT;i++){
            threads[i] = new Thread(new Runnable() {//每個執行緒將race自增10次
                @Override
                public void run() {
                    // TODO Auto-generated method stub
                    for(int i=0;i<10000;i++){
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        
        while(Thread.activeCount()>1)
            Thread.yield();
        
        System.out.println(race);
        
    }
}

為什麼會出現上述情況呢?這是因為每個執行緒在操作race變數時,都要執行以下操作:從主記憶體讀取(read)、載入到工作記憶體(load)、使用(use,這裡是將race變數自增)、賦值(assign,這裡是將自增以後的race重新賦值給工作記憶體中的race)、儲存(store,把工作記憶體中的race變數的值傳送到主記憶體)、寫入(write,將工作記憶體中傳來的race變數的值賦給主記憶體中的race變數)。每個執行緒都要執行這一個流程的六步操作,但是某個執行緒在執行到一個流程的第三個操作的時候,另一個執行緒也開始執行,那麼後執行完流程的執行緒,將會將它的執行結果覆蓋掉前一個執行緒的執行結果,於是主記憶體中的race變數的值最後可定會小於200000。

由於volatile變數只能保證可見性,在不符合以下兩條規則的運算場景中,我們仍要通過加鎖來保證原子性。

a.運算結果並不依賴變數的當前值(執行緒只用當前變數,而不對當前變數進行修改),或者能夠確保只有單一的執行緒修改變數的值。

b.變數不需要與其他的狀態變數共同參與不變約束。

如下例項就可以用volatile來控制併發:

volatile boolean shutdownFlag;
    
    public void shutdown(){
        shutdownFlag = true;
    }
    
    public void doWork(){
        while(!shutdownFlag){
            //do stuff
        }
    }

使用volatile變數的第一語義就是保證此變數對所有執行緒可見性。那第二就是禁止指令重排序優化。普通的變數僅僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變數賦值操作的順序與程式程式碼中的執行順序一致。

為什麼volatile禁止指令重排序呢?因為指令重排序會影響程式的併發執行。看如下虛擬碼:

    Map configOptions;
    char[] configText;
    
    //此變數必須定義為volatile
    volatile boolean initialized = false;
    
    //執行緒A讀取配置資訊,讀取完後將initialized設定為true以通知其他執行緒配置可用
    configOptions = new HashMap();
    configText = readConfigFile(fileName);
    processConfigOptions(configText,configOptions);
    initialized = true;
    
    //執行緒B等待執行緒A讀取完配置資訊,使用配置資訊
    while(!initialized){
        sleep();
    }
    doSomethingWithConfig();

如果定義initialized變數時沒有用volatile變數修飾,那麼就可能會由於指令重排序的優化,導致位於執行緒A中的最後一句initialized = true提前執行(指令重排序優化是機器級的優化操作,提前執行是指這句話對應的彙編程式碼被提前執行),這樣線上程B中使用配置資訊的程式碼就可能出現錯誤。

用volatile修飾變數,相當於給變數增加了一個記憶體屏障(相當於執行了lock操作)。增加了記憶體屏障之後,指令重排序時不能把後面的指令重排序到記憶體屏障之前的位置。只有一個CPU訪問記憶體時,並不需要記憶體屏障;但如果有兩個或更多的CPU訪問同一塊記憶體,且其中有一個在觀測另一個,就需要記憶體屏障來保證一致性。記憶體屏障的作用是使得本CPU的Cache寫入了記憶體,該寫入動作也會引起別的CPU或者別的核心無效化其Cache,這種操作相當於對Cache中的變數做了一次java記憶體模式中所說的“store和write”操作。

為什麼說volatile禁止指令重排序呢?從硬體架構上講,指令重排序是指CPU採用了允許將多條指令不按程式規定的順序分開發送給各相應電路單元處理。但並不是說指令任意重排,CPU需要能正確處理指令依賴情況以保障程式能得出正確的執行結果。比如指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值減去3,這時指令1和指令2是有依賴的(volatile就是確定依賴的),他們之間的順序不能重排,但指令3可以排到指令1、2之前或中間,只要保證CPU執行後面依賴到A、B值的操作時能獲取到正確的A和B即可。所以在一個CPU內部,重排序看起來依然是有序的,因此指令把修改同步到記憶體時,意味著之前的操作都已經執行完成,這樣便形成了“指令重排序無法約過記憶體屏障”的效果。

volatile的同步機制的效能要優於鎖,但是由於虛擬機器對鎖實行的許多消除和優化,會讓開發人員覺得volatile並沒有比鎖快多少。實際上,volatile變數讀操作的效能和普通變數差不多,但是寫操作就可能慢一點兒,因為它需要在原生代碼中插入許多記憶體屏障指令來保證處理器不發生亂序執行。

java記憶體模型對volatile變數定義的特殊規則:

a.執行緒在使用volatile修飾的變數之前,也就是執行use操作之前,必須先從主記憶體中讀取變數的值,即必須先執行read和load操作。

b.執行緒在對volatile修飾的變數執行assign操作之後,必須對該變數執行store和write操作。保證每次修改變數都必須立刻同步回主記憶體,用於保證其他執行緒可以看到變數的修改。

c.volatile修飾的變數,如果A動作是執行緒T對變數執行use操作,B動作是use操作對應的load操作;如果C動作是執行緒T對變數執行use動作,D動作是use操作對應的load操作。如果A動作先於C動作,則B動作先於D動作。

(4)對於long和double型變數的特殊規則

java記憶體模型要求lock、unlock、read、load、use、assign、store、write這八個操作都具有原子性,但是對於64位的資料型別(long和double),在模型中特別定義了一條相對寬鬆的規定:允許虛擬機器將沒有被volatile修飾的64位資料的讀寫操作劃分為兩次32為的操作來進行,即允許虛擬機器實現選擇可以不保證64位資料型別的load、store、read和write這4個操作的原子性。

如果有多個執行緒共享一個並未宣告為volatile的long或double型別的變數,並且同時對它們進行讀取和修改操作,那麼某些執行緒可能會讀取到一個既非原值,也不是其他執行緒修改值的代表了“半個變數”的數值。

不過這種讀取到“半個變數”的情況非常罕見,因為java記憶體模型雖然允許虛擬機器不把long或double變數的讀寫實現成原子操作,但允許虛擬機器選擇把這些操作實現為具有原子性的操作,而且幾乎所有的虛擬機器都把64位資料的讀寫操作作為原子操作來對待。