Java虛擬機器08——Java記憶體模型與執行緒

llldddbbb發表於2019-04-18

硬體的效率與一致性

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

基於快取記憶體的儲存互動很好地解決了處理器與記憶體的速度矛盾,但它引入了一個新的問題:快取一致性(Cache Coherence)。在多處理器系統中,每個處理器都有自己的快取記憶體,而它們又共享同一主記憶體(Main Memory),當多個處理器的運算任務都涉及同一塊主記憶體區域時,將可能導致各自的快取資料不一致的問題。為了解決這個問題,需要遵循一些協議,如MSI、MESI、MOSI、Synapse、Firefly及Dragon Protocol等。

image.png

除了增加快取記憶體外,為了使得處理器內部的運算單元能儘量被充分利用,處理器可能會對輸入程式碼進行亂序執行(Out-Of-Order Execution)優化,處理器會在計算之後將亂序執行的結果重組,保證該結果與順序執行的結果是一致的。與此類似的是Java虛擬機器中的指令重排序(Instruction Reorder)

Java記憶體模型

Java虛擬機器規範中試圖定義一種Java記憶體模型(Java Memory Model,JMM)來遮蔽掉各種硬體和作業系統的記憶體訪問差異,以實現讓Java程式在各種平臺下都能達到一致的記憶體訪問效果。

主記憶體與工作記憶體

Java記憶體模型的主要目標是定義程式在虛擬機器中將各個變數儲存到記憶體和從記憶體中取出變數的底層訪問規則。此處的變數(Variables)與Java程式設計中所說的變數有所區別,它包括了例項欄位、靜態欄位和構成陣列物件的元素,但不包括區域性變數與方法引數,因為後者是執行緒私有的,不會存在競爭問題。為了獲得較好的執行效能,Java記憶體模型並沒有限制執行引擎使用處理器的特定暫存器或快取來和主記憶體進行互動,也沒有限制即時編譯器進行調整程式碼執行順序這類優化措施。

Java記憶體模型規定了所有的變數都儲存在主記憶體(Main Memory)中(此處的主記憶體與介紹物理硬體時的主記憶體名字一樣,兩者也可以互相類比,但此處僅是虛擬機器記憶體的一部分)。每條執行緒還有自己的工作記憶體(Working Memory,可與前面講的處理器快取記憶體類比),執行緒的工作記憶體中儲存了被該執行緒使用到的變數的主記憶體副本拷貝,執行緒對變數的所有操作(讀取、賦值等)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。不同的執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞均需要通過主記憶體來完成。

image.png

這裡所講的主記憶體、工作記憶體與Java記憶體區域中的Java堆、棧、方法區等並不是同一個層次的記憶體劃分,這兩者基本上是沒有關係的,如果兩者一定要勉強對應起來,那從變數、主記憶體、工作記憶體的定義來看,主記憶體主要對應於Java堆中的物件例項資料部分,而工作記憶體則對應於虛擬機器棧中的部分割槽域。 從更低層次上說,主記憶體就直接對應於物理硬體的記憶體,而為了獲取更好的執行速度,虛擬機器(甚至是硬體系統本身的優化措施)可能會讓工作記憶體優先儲存於暫存器和快取記憶體中,因為程式執行時主要訪問讀寫的是工作記憶體。

記憶體間互動操作

Java記憶體模型中定義了以下8種操作來完成主記憶體與工作記憶體之間具體的互動協議,虛擬機器實現時必須保證下面提及的每一種操作都是原子的、不可再分的(對於double和long型別的變數來說,load、store、read和write操作在某些平臺上允許有例外)

  • lock(鎖定):作用於主記憶體的變數,它把一個變數標識為一條執行緒獨佔的狀態
  • unlock(解鎖):作用於主記憶體的變數,它把一個處於鎖定狀態的變數釋放出來,釋放後的執行緒才可以被其他執行緒鎖定
  • read(讀取):作用於主記憶體的變數,它把一個變數的值從主記憶體傳輸到執行緒工作的記憶體中,以便隨後的load操作使用
  • load(載入):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中
  • use(使用):作用於工作記憶體的變數,它把工作記憶體中的一個變數的值傳遞給執行引擎,每當虛擬機器遇到一個需要使用到變數的值的位元組碼指令時將會執行這個操作
  • assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作
  • store(儲存):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳送到主記憶體中,以便隨後的write操作使用
  • write(寫入):作用於主記憶體的變數,它把store操作從工作記憶體中得到的變數的值放入主記憶體的變數中

如果要把一個變數從主記憶體複製到工作記憶體,那就要順序地執行read和load操作,如果要把變數從工作記憶體同步回主記憶體,就要順序地執行store和write操作。注意,Java記憶體模型只要求上述兩個操作必須按順序執行,而沒有保證是連續執行。也就是說,read與load之間、store與write之間是可插入其他指令的。除此之外,Java記憶體模型還規定了在執行上述8種基本操作時必須滿足如下規則:

  • 不允許read和load、store和write操作之一單獨出現,即不允許一個變數從主記憶體讀取了但工作記憶體不接受,或者從工作記憶體發起回寫了但主記憶體不接受的情況出現。
  • 不允許一個執行緒丟棄它的最近的assign操作,即變數在工作記憶體中改變了之後必須把該變化同步回主記憶體。
  • 不允許一個執行緒無原因地(沒有發生過任何assign操作)把資料從執行緒的工作記憶體同步回主記憶體中。
  • 一個新的變數只能在主記憶體中“誕生”,不允許在工作記憶體中直接使用一個未被初始化(load或assign)的變數,換句話說,就是對一個變數實施use、store操作之前,必須先執行過了assign和load操作。
  • 一個變數在同一個時刻只允許一條執行緒對其進行lock操作,但lock操作可以被同一條執行緒重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變數才會被解鎖。
  • 如果對一個變數執行lock操作,那將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前,需要重新執行load或assign操作初始化變數的值。
  • 如果一個變數事先沒有被lock操作鎖定,那就不允許對它執行unlock操作,也不允許去unlock一個被其他執行緒鎖定住的變數。
  • 對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中(執行store、write操作)

volatile

關鍵字volatile可以說時Java虛擬機器提供的最輕量級的同步機制,當一個變數定義為volatile之後,它將具備兩種特性:

1.保證此變數對所有執行緒的可見性,這裡的“可見性”是指當一條執行緒修改了這個值,新值對於其他執行緒來說是可以立即得知的

volatile變數在各個執行緒的工作記憶體中不存在一致性問題(在各個執行緒的工作記憶體中,volatile變數也可以存在不一致的情況,但是由於每次使用之前都會先重新整理,執行引擎看不到不一致的情況,因此可以認為不存在一致性問題),但是基於volatile變數的運算在併發下一樣是不安全的。

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(() -> {
                for (int j = 0; j < 10000; j++) {
                    increase();
                }
            });
            threads[i].start();
        }
        //  等待所有累加執行緒都結束
        while (Thread.activeCount()>1) {
            Thread.yield();
        }
        System.out.println(race);
    }
}
複製程式碼

執行結果如下:

image.png

這段程式碼啟動了20個執行緒,每個執行緒進行10000次自增操作,如果正確併發的話應該是輸出200000,但是發現每次執行結果都是小於200000的值,這是為什麼呢?

問題就出現在自增運算race++不是原子操作,會產生4條指令getstatic、iconst_1、iadd、putstatic,當getstatic指令把race的值取到操作棧頂時,volatile關鍵字只保證了此時的值是正確的,在執行++操作的時候,其他執行緒可能把race的值加大了,而在操作棧頂的值就變成了過期的資料,因此就可能把較小的值同步回主記憶體中。

由於volatile變數只能保證可見性,在不符合以下兩條規則的運算場景中,我們仍然要通過加鎖(使用synchronized或java.util.concurrent中的原子類)來保證原子性:

  • 運算結果並不依賴變數的當前值,或者能夠確保只有單一的執行緒修改變數的值。
  • 變數不需要與其他的狀態變數共同參與不變約束。

如以下場景,呼叫shutdown能立即停止工作。

    volatile boolean shutdownRequested;
    public void shutdown() {
        shutdownRequested = false
    }
    public void dowork() {
        while(!shutdownRequested){
            // do something
        }
    }
複製程式碼

2.使用volatile變數的第二個語義是禁止指令重排序優化,普通的變數僅僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變數賦值操作的順序與程式程式碼中的執行順序一致。因為在一個執行緒的方法執行過程中無法感知到這點,這也就是Java記憶體模型中描述的所謂的“執行緒內表現為序列的語義”(Within-Thread As-If-Serial Semantics)

Java記憶體模型中對volatile變數定義的特殊規則。假定T表示一個執行緒,V和W分別表示兩個volatile型變數,那麼在進行read、load、use、assign、store和write操作時需要滿足如下規則:

  • 只有當執行緒T對變數V執行的前一個動作是load的時候,執行緒T才能對變數V執行use動作;並且,只有當執行緒T對變數V執行的後一個動作是use的時候,執行緒T才能對變數V執行load動作。執行緒T對變數V的use動作可以認為是和執行緒T對變數V的load、read動作相關聯,必須連續一起出現(這條規則要求在工作記憶體中,每次使用V前都必須先從主記憶體重新整理最新的值,用於保證能看見其他執行緒對變數V所做的修改後的值)。
  • 只有當執行緒T對變數V執行的前一個動作是assign的時候,執行緒T才能對變數V執行store動作;並且,只有當執行緒T對變數V執行的後一個動作是store的時候,執行緒T才能對變數V執行assign動作。執行緒T對變數V的assign動作可以認為是和執行緒T對變數V的store、write動作相關聯,必須連續一起出現(這條規則要求在工作記憶體中,每次修改V後都必須立刻同步回主記憶體中,用於保證其他執行緒可以看到自己對變數V所做的修改)。
  • 假定動作A是執行緒T對變數V實施的use或assign動作,假定動作F是和動作A相關聯的load或store動作,假定動作P是和動作F相應的對變數V的read或write動作;類似的,假定動作B是執行緒T對變數W實施的use或assign動作,假定動作G是和動作B相關聯的load或store動作,假定動作Q是和動作G相應的對變數W的read或write動作。如果A先於B,那麼P先於Q(這條規則要求volatile修飾的變數不會被指令重排序優化,保證程式碼的執行順序與程式的順序相同)。

原子性、可見性與有序性

原子性(Atomicity)

由Java記憶體模型來直接保證的原子性變數操作包括read、load、assign、use、store和write,我們大致可以認為基本資料型別的訪問讀寫是具備原子性的。

如果應用場景需要一個更大範圍的原子性保證,Java記憶體模型還提供了lock和unlock操作來滿足這種需求,儘管虛擬機器未把lock與unlock操作直接開放給使用者使用,但是卻提供了更高層次的位元組碼指令monitorenter和monitorexit來隱匿地使用這兩個操作,這兩個位元組碼指令反映到Java程式碼中就是同步塊—synchronized關鍵字,因此在synchronized塊之間的操作也具備原子性。

可見性(Visibility)

可見性是指當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改。Java記憶體模型是通過在變數修改後將新值同步回主記憶體,在變數讀取前從主記憶體重新整理變數值這種依賴主記憶體作為傳遞媒介的方式來實現可見性的,無論是普通變數還是volatile變數都是如此,普通變數與volatile變數的區別是,volatile的特殊規則保證了新值能立即同步到主記憶體,以及每次使用前立即從主記憶體重新整理。因此,可以說volatile保證了多執行緒操作時變數的可見性,而普通變數則不能保證這一點。

除了volatile之外,Java還有兩個關鍵字能實現可見性,即synchronized和final。同步塊的可見性是由“對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中(執行store、write操作)”這條規則獲得的,而被final修飾的欄位在構造器中一旦初始化完成,並且構造器沒有把this的引用傳遞出去(this引用逃逸是一件很危險的事情,其他執行緒有可能通過這個引用訪問到“初始化了一半”的物件),那在其他執行緒中就能看見final欄位的值。

有序性(Ordering)

Java程式中天然的有序性可以總結為一句話:如果在本執行緒內觀察,所有的操作都是有序的;如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的。前半句是指“執行緒內表現為序列的語義”(Within-Thread As-If-Serial Semantics),後半句是指“指令重排序”現象和“工作記憶體與主記憶體同步延遲”現象。

Java語言提供了volatile和synchronized兩個關鍵字來保證執行緒之間操作的有序性,volatile關鍵字本身就包含了禁止指令重排序的語義,而synchronized則是由“一個變數在同一個時刻只允許一條執行緒對其進行lock操作”這條規則獲得的,這條規則決定了持有同一個鎖的兩個同步塊只能序列地進入。

先行發生原則

先行發生(happens-before)的原則是判斷資料是否存在競爭、執行緒是否安全的主要依據,依靠這個原則,我們可以通過幾條規則解決併發環境下兩個操作之間是否可能存在衝突的所有問題。

先行發生是Java記憶體模型中定義的兩項操作之間的偏序關係,如果說操作A先行發生於操作B,其實就是說在發生操作B之前,操作A產生的影響能被操作B觀察到,“影響”包括修改了記憶體中共享變數的值、傳送了訊息、呼叫了方法等。

Java記憶體模型下一些“天然的”先行發生關係,這些先行發生關係無須任何同步器協助就已經存在,可以在編碼中直接使用。如果兩個操作之間的關係不在此列,並且無法從下列規則推匯出來的話,它們就沒有順序性保障,虛擬機器可以對它們隨意地進行重排序:

  • 程式次序規則(Program Order Rule):在一個執行緒內,按照程式程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作。準確地說,應該是控制流順序而不是程式程式碼順序,因為要考慮分支、迴圈等結構。
  • 管程鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於後面對同一個鎖的lock操作。這裡必須強調的是同一個鎖,而“後面”是指時間上的先後順序。
  • volatile變數規則(Volatile Variable Rule):對一個volatile變數的寫操作先行發生於後面對這個變數的讀操作,這裡的“後面”同樣是指時間上的先後順序。
  • 執行緒啟動規則(Thread Start Rule):Thread物件的start()方法先行發生於此執行緒的每一個動作。
  • 執行緒終止規則(Thread Termination Rule):執行緒中的所有操作都先行發生於對此執行緒的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值等手段檢測到執行緒已經終止執行。
  • 執行緒中斷規則(Thread Interruption Rule):對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測到是否有中斷髮生。
  • 物件終結規則(Finalizer Rule):一個物件的初始化完成(建構函式執行結束)先行發生於它的finalize()方法的開始。
  • 傳遞性(Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。

Java與執行緒

執行緒是比程式更輕量級的排程執行單位,執行緒的引入,可以把一個程式的資源分配和執行排程分開,各個執行緒既可以共享程式資源(記憶體地址、檔案I/O等),又可以獨立排程(執行緒是CPU排程的基本單位)

執行緒的實現

系統平臺實現執行緒主要有3種方式:使用核心執行緒實現、使用使用者執行緒實現和使用使用者執行緒加輕量級程式混合實現。

使用核心執行緒實現

核心執行緒(Kernel-Level Thread,KLT)就是直接由作業系統核心支援的執行緒,這種執行緒由核心來完成執行緒切換,核心通過操縱排程器(Scheduler)對執行緒進行排程,並負責將執行緒的任務對映到各個處理器上。每個核心執行緒可以視為核心的一個分身,這樣作業系統就有能力同時處理多件事情,支援多執行緒的核心就叫做多執行緒核心(Multi-Threads Kernel)。

程式一般不會直接去使用核心執行緒,而是去使用核心執行緒的一種高階介面——輕量級程式(Light Weight Process,LWP),輕量級程式就是我們通常意義上所講的執行緒,由於每個輕量級程式都由一個核心執行緒支援,因此只有先支援核心執行緒,才能有輕量級程式。這種輕量級程式與核心執行緒之間1比1的關係稱為一對一的執行緒模型,如下圖所示:

image.png

由於核心執行緒的支援,每個輕量級程式都成為一個獨立的排程單元,即使有一個輕量級程式在系統呼叫中阻塞了,也不會影響整個程式繼續工作,但是輕量級程式具有它的侷限性:首先,由於是基於核心執行緒實現的,所以各種執行緒操作,如建立、析構及同步,都需要進行系統呼叫。而系統呼叫的代價相對較高,需要在使用者態(User Mode)和核心態(Kernel Mode)中來回切換。其次,每個輕量級程式都需要有一個核心執行緒的支援,因此輕量級程式要消耗一定的核心資源(如核心執行緒的棧空間),因此一個系統支援輕量級程式的數量是有限的。

使用使用者執行緒實現

從廣義上講,一個執行緒只要不是核心執行緒,就可以認為是使用者執行緒(User Thread,UT)。從狹義上的使用者執行緒指的是完全建立在使用者空間的執行緒庫上,系統核心不能感知執行緒存在的實現。 使用者執行緒的建立、同步、銷燬和排程完全在使用者態中完成,不需要核心的幫助。如果程式實現得當,這種執行緒不需要切換到核心態,因此操作可以是非常快速且低消耗的,也可以支援規模更大的執行緒數量,部分高效能資料庫中的多執行緒就是由使用者執行緒實現的。這種程式與使用者執行緒之間1比N的關係稱為一對多的執行緒模型,如下圖所示:

image.png

使用使用者執行緒的優勢在於不需要系統核心支援,劣勢也在於沒有系統核心的支援,所有的執行緒操作都需要使用者程式自己處理。因而使用使用者執行緒實現的程式一般都比較複雜,現在使用使用者執行緒的程式越來越少了,Java、Ruby等語言都曾經使用過使用者執行緒,最終又都放棄使用它。

使用使用者執行緒加輕量級程式混合實現

這種混合實現下,既存在使用者程式也存在輕量級程式。使用者執行緒的建立、切換、析構等操作依然廉價,並且可以支援大規模的使用者執行緒併發。而使用者執行緒的系統呼叫通過輕量級執行緒來完成,大大降低了整個程式被完全阻塞的風險。這種混合模式中,使用者執行緒與輕量級程式的數量為N:M的關係

image.png

Java執行緒的實現

虛擬機器規範中並未限定Java執行緒需要使用哪種執行緒模型來實現,執行緒模型只對執行緒的併發規模和操作成本產生影響,對Java程式的編碼和執行過程來說,這些差異都是透明的。對於Sun JDK來說,它的Windows版與Linux版都是使用一對一的執行緒模型實現的,一條Java執行緒就對映到一條輕量級程式之中,因為Windows和Linux系統提供的執行緒模型就是一對一的。

Java執行緒排程

執行緒排程是指系統為執行緒分配處理器使用權的過程,主要排程方式有兩種,分別是協同式執行緒排程(Cooperative Threads-Scheduling)和搶佔式執行緒排程(Preemptive Threads-Scheduling)。

如果使用協同式排程的多執行緒系統,執行緒的執行時間由執行緒本身來控制,執行緒把自己的工作執行完了之後,要主動通知系統切換到另外一個執行緒上。協同式多執行緒的最大好處是實現簡單,而且由於執行緒要把自己的事情幹完後才會進行執行緒切換,切換操作對執行緒自己是可知的,所以沒有什麼執行緒同步的問題。它的壞處也很明顯:執行緒執行時間不可控制,甚至如果一個執行緒編寫有問題,一直不告知系統進行執行緒切換,那麼程式就會一直阻塞在那裡。

如果使用搶佔式排程的多執行緒系統,那麼每個執行緒將由系統來分配執行時間,執行緒的切換不由執行緒本身來決定(在Java中,Thread.yield()可以讓出執行時間,但是要獲取執行時間的話,執行緒本身是沒有什麼辦法的)。在這種實現執行緒排程的方式下,執行緒的執行時間是系統可控的,也不會有一個執行緒導致整個程式阻塞的問題,Java使用的執行緒排程方式就是搶佔式排程。

雖然Java執行緒排程是系統自動完成的,但是可以設定優先順序,Java語言一共設定了10個級別的執行緒優先順序(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY)。Java的執行緒是通過對映到系統的原生執行緒上來實現的,所以執行緒排程最終還是取決於作業系統,雖然現在很多作業系統都提供執行緒優先順序的概念,但是並不見得能與Java執行緒的優先順序一一對應。

狀態轉換

Java語言定義了5種執行緒狀態,在任意一個時間點,一個執行緒只能有且只有其中的一種狀態,這5種狀態分別如下。

  • 新建(New):建立後尚未啟動的執行緒處於這種狀態。
  • 執行(Runable):Runable包括了作業系統執行緒狀態中的Running和Ready,也就是處於此狀態的執行緒有可能正在執行,也有可能正在等待著CPU為它分配執行時間。
  • 無限期等待(Waiting):處於這種狀態的執行緒不會被分配CPU執行時間,它們要等待被其他執行緒顯式地喚醒。以下方法會讓執行緒陷入無限期的等待狀態: 沒有設定超時引數的Object.wait()方法。 沒有設定超時引數的Thread.join()方法。 LockSupport.park()方法。
  • 限期等待(Timed Waiting):處於這種狀態的執行緒也不會被分配CPU執行時間,不過無須等待被其他執行緒顯式地喚醒,在一定時間之後它們會由系統自動喚醒。以下方法會讓執行緒進入限期等待狀態: Thread.sleep()方法。 設定了超時引數的Object.wait()方法。 設定了超時引數的Thread.join()方法。 LockSupport.parkNanos()方法。 LockSupport.parkUntil()方法。
  • 阻塞(Blocked):執行緒被阻塞了,“阻塞狀態”與“等待狀態”的區別是:“阻塞狀態”在等待著獲取到一個排他鎖,這個事件將在另外一個執行緒放棄這個鎖的時候發生;而“等待狀態”則是在等待一段時間,或者喚醒動作的發生。在程式等待進入同步區域的時候,執行緒將進入這種狀態。
  • 結束(Terminated):已終止執行緒的執行緒狀態,執行緒已經結束執行。
    image.png

相關文章