1. 程式人生 > >jdk原始碼解析(十一)——Java記憶體模型與執行緒

jdk原始碼解析(十一)——Java記憶體模型與執行緒

前面我們瞭解了Java的編譯和執行,這裡在講解一下高效併發(Java記憶體模型與執行緒)在瞭解記憶體模型與執行緒之前,我們先要了解一些東西。

1 硬體效率與一致性 

計算併發執行的執行和充分利用計算機處理器的效能兩者看來是互為因果的,而在大多數的時候,計算機的處理速度不止是在處理器進行的,大多數是在記憶體和處理器進行的,但是這種I/O操作是很難消除的。為了提升計算速度,在記憶體和處理器之間加上一個快取記憶體來作為記憶體和處理器之間的緩衝。基於快取記憶體的方法很好的解決了處理器和快取的矛盾,但是也引入了一個新的問題:快取一致性。每個處理器都有自己的快取記憶體,而他們又共享一個主記憶體。當處理多個處理器的運算任務都涉及同一個主記憶體區域時,將可能導致各自的快取資料不一致,如果真發生這種情況,將同步到主記憶體的資料到底以誰的快取資料為準。為了解決一致性問題,需要各個處理器訪問快取時都遵循一些協議,在讀寫時根據協議進行操作,這類協議有MSI 、MESI(Illionis Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。在本章中將會多次提到“記憶體模型”一詞,可以理解為在特定的操作協議下,對特定的記憶體或快取記憶體進行讀寫訪問的過程抽象。



  除了增加快取記憶體之外,為了使得處理器內部的運算單元儘可能被充分利用,處理器可能會對輸入程式碼進行亂序執行優化,處理器會在計算之後將亂序執行的結果重組,保證該結果與順序執行的結果是一致的,但並不保證程式中各個語句計算的先後順序與程式碼中的順序一致。因此,如果某個人物依賴另外一個人物的中間結果,但執行順序出現了問題,所以結果會出現了問題。Java虛擬機器的即時編譯器中也有類似的指令重排序優化。

2. Java記憶體模型

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

2.1 主記憶體與工作記憶體

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

每條執行緒還有自己的工作記憶體,執行緒的工作記憶體中儲存了被該執行緒使用到的變數的主記憶體副本拷貝。執行緒對變數的所有操作(讀取、賦值),都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。不同執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞均需要通過主記憶體來完成,執行緒、主記憶體、工作記憶體三者之間的互動關係如下圖:


注:這裡所講的主記憶體、工作記憶體與Java記憶體區域中的Java堆、棧、方法區等並不是同一個層次的記憶體劃分。
主記憶體主要對應java堆中的物件例項資料部分,而工作記憶體則對應於虛擬機器棧中的部分資料。從更低層次上說,主記憶體就直接對應於物理硬體的記憶體。
為了獲取更好的執行速度,虛擬機器可能會讓工作記憶體優先儲存於暫存器和快取記憶體中,因為程式執行時主要訪問讀寫的是工作記憶體。

2.2 記憶體間的互動操作

關於主記憶體與工作記憶體之間具體的互動協議,即一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回主記憶體等的細節,Java記憶體模型定義了8種操作來完成,虛擬機器實現時必須保證下面提及的每一種操作都是原子操作(不可在分的)。

lock(鎖定):作用於主記憶體的變數,它把一個變數標誌為一條執行緒獨佔的狀態。
unlock(解鎖):作用於主記憶體中的變數,它把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。
read(讀取):作用於主記憶體的變數,它把一個變數的值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用。
load(載入):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。
use(使用):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳遞給執行引擎。
assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接受到的值賦給工作記憶體的變數。
store(儲存):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳送到主記憶體中,以便隨後的write操作使用。
write(寫入):作用於主記憶體中的變數,它把store操作從主記憶體中得到的變數值放入主記憶體的變數中。


除了保證上述8種操作的原子性,Java記憶體模型還規定了在執行上述8種基本操作時必須遵循的規範,從而完全確定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操作之前,必須先把此變數同步回主記憶體中(只是sotore、write操作)。

2.3  對於volatile(關鍵字)型變數的特殊規則

關鍵字volatile是Java虛擬機器提供的最輕量級的同步機制,瞭解volatile變數的語義對後面瞭解多執行緒操作的其它特性很有意義。首先,我們來了解一下這個關鍵字的作用。
當一個變數定義為volatile之後,它將具備兩種屬性,第一種是保證此變數對所有執行緒的可見性,這裡的“可見性”是指當一條執行緒修改了這個變數的值,新值對於其它執行緒來說是可以立即得知的。不過,因為Java裡面的運算並不是原子操作,所以volatile變數的運算在併發條件下並不是絕對安全的。以下為示例程式碼:

package com.overridere.twelve;
/**
 * 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(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }

        // 等待所有累加執行緒都結束
        while (Thread.activeCount() > 1)
            Thread.yield();

        System.out.println(race);
    }
}
這段程式碼發起了20個執行緒,每個執行緒都對變數race進行10000次自增操作,如果race是執行緒安全的,最後輸出結果應該是200000,但真實結果都是一個小於200000的值,這是問為什麼呢?
 public static void increase();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #13                 // Field race:I
         3: iconst_1
         4: iadd
         5: putstatic     #13                 // Field race:I
         8: return
      LineNumberTable:
        line 10: 0
        line 11: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
問題就在“race++”中,用javap命令得到上面的位元組碼命令,發現只有一行程式碼的increase()方法在Class檔案中是由4條位元組碼指令構成的,從這上面分析就很容易分析出執行緒不安全的原因了:當getstatic指令把race的值取到運算元棧頂的時候,volatile關鍵字保證了race的值在此時是正確的,但是在執行iconst_1、iadd這些指令的時候,其它執行緒可能已經把race加大了,而操作棧頂的值就變成了過期的資料。意思是說,在這個例子當中,volatile關鍵字只保證getstatic指令從常量池中將值取出放到運算元棧頂這個動作是執行緒安全的(從主記憶體到工作記憶體),作用範圍就是這個,之後就會失去作用,後面的增加操作就不是執行緒安全的了。
  • 運算結果並不依賴變數的當前值,或者能夠保證只有單一的執行緒修改變數的值。
  • 變數不需要與其它的變數共同參與不變約束。

第一句話的意思是:不管我以前是什麼樣的,都不影響現在的我。
第二句話的意思是:不管別人是什麼樣的,只要跟我沒關係,就不會影響到我。
如下面的示例程式碼:

volatile boolean isVolatile;
public void shutdown(){
    isVolatile = true;
}
public void doWork(){
    while(!isVolatile){
        //do stuff
    }
}
上面程式碼中的isVolatile變數,無論之前是什麼,都不妨礙當前執行緒賦值,也沒有與其它變數綁起來共同參與不變約束。

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

指令重排序意思就是不按程式規定的順序執行指令,但並不是說指令任意重排。比如說,指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值減去3,這時指令1和指令2是有依賴的,它們之間的順序不能重排——(A+10)*2與A*2+10顯然不相等,但是指令3可以重排到指令1、2之前或者中間。

而valotile關鍵字是如何實現禁止指令重排序呢?
有volatile關鍵字修飾的變數,賦值後會多執行一個操作,這個操作會把修改同步到記憶體,意味著所有之前的操作都執行完畢了,這個操作相當於記憶體屏障(Memory Barrier),這樣一來指令重排序的時候就不能把後面的指令重排序到記憶體屏障之前的位置。只有一個CPU訪問記憶體時,不需要記憶體屏障。

2.4 原子性、可見性與有序性

原子性(Atomicity):由Java記憶體模型來直接保證的原子性變數操作包括read、load、assign、use、和write。
可見性(Visibility):可見性是指當一個執行緒修改了共享變數的值,其它執行緒能夠立即得知這個修改。Java記憶體模型是通過在變數修改後將新值同步回主記憶體,在變數讀取前從主記憶體重新整理變數值這種依賴主記憶體作為傳遞媒介的方式來實現可見性的。volatile變數與普通變數的區別是,volatile的特殊規則保證新值能立即同步到主記憶體,每次使用前立即從主記憶體重新整理。
有序性(Ordering):如果在本執行緒內觀察,所有的操作都是有序的;如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的。前半句是指“執行緒內表現為序列的語義”,後半句是指“指令重排序”現象和“工作記憶體和主記憶體同步延遲”現象。

3 Java與執行緒

併發不一定依賴多執行緒(php很常見多程序併發),但是在Java中談到併發,大多數都與執行緒脫不開關係,那講到java執行緒,我們就從Java執行緒在虛擬機器中的實現開始講起。

3.1 執行緒的實現

我們知道,執行緒是比程序更輕量級的排程執行單位,執行緒的引入,可以把一個程序的資源分配和執行排程分開,各個執行緒既可以共享程序資源(記憶體地址、檔案I/O等),又可以獨立排程(執行緒是CPU排程的基本單位)。
主流的作業系統都提供了執行緒實現,Java語言則提供了在不同硬體和作業系統平臺下對執行緒操作的統一處理,每個已經執行了start()且還未結束的java.lang.Thread類的例項就代表了一個執行緒。Thread類中所有關鍵方法都是宣告為Native的,在Java API中,本地方法往往意味著這個方法沒有使用或無法使用平臺無關的手段來實現(當然也有可能是為了執行效率而使用Native方法)。正因如此,本節標題為“執行緒的實現”而不是“Java執行緒的實現”
執行緒的實現主要有3種方式:使用核心執行緒實現、使用使用者執行緒實現和使用使用者執行緒加輕量級程序混合實現。

1、使用核心執行緒實現

核心執行緒(Kernel-Level Thread,KLT)就是直接由作業系統核心支援的執行緒,這種執行緒由核心來完成執行緒切換,核心通過作業系統排程器對執行緒進行排程,並負責將執行緒的任務對映到各個處理器上。每個核心執行緒可以視為核心的一個分身,這樣作業系統就有能力同時處理多件事情,支援多執行緒的核心就叫多執行緒核心。
程式一般不會直接去使用核心執行緒,而是使用核心執行緒的一種高階介面——輕量級程序(Light Weight Process,LWP),由於每個輕量級程序都由一個核心支援,因此只有先支援核心執行緒,才能有輕量級程序。這種輕量級程序和核心執行緒之間1:1的關係稱為一對一執行緒模型。


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

2、使用使用者執行緒實現

從廣義上來講,一個執行緒只要不是核心執行緒,就可以認為是使用者執行緒,因此,從這個定義上來講,輕量級程序也屬於使用者執行緒,但輕量級程序的實現始終是建立在核心之上的,許多操作都進行系統呼叫,效率會受到限制。
而狹義上的使用者執行緒指的是完全建立在使用者空間的執行緒庫上,系統核心不能感知執行緒存在的實現。使用者執行緒的建立、同步、銷燬和排程完全在使用者態中完成,不需要核心的幫助。這種執行緒不需要切換到核心態,因此操作可以是非常快速且非常低消耗的,也可以支援規模更大的執行緒數量,部分高效能資料庫中的多執行緒就是由使用者執行緒實現的。這種程序與使用者執行緒之間1:N的關係稱為一對多的執行緒模型。


使用使用者執行緒的優勢在於不需要系統核心的支援。劣勢也在於沒有系統核心的支援,所有的執行緒操作都需要使用者程式自己處理。
執行緒的建立、切換和排程都是需要考慮的問題,而且由於作業系統只把處理器資源分配到程序,那諸如“阻塞如何處理”、“多處理器系統中如何將執行緒對映到其他處理器上”這類問題解決起來將會異常困難,甚至不可能完成。因此,使用使用者執行緒實現的程式一般都異常複雜,除了特定環境外,現在使用使用者執行緒的程式越來越少。

3、使用使用者執行緒加輕量級程序混合實現

在這種混合模式下,即存在使用者執行緒,也存在輕量級程序。使用者執行緒還是完全建立在使用者空間中,因此使用者執行緒的建立、切換、析構等操作依然廉價,並且可以支援大規模的使用者執行緒併發。而作業系統提供支援的輕量級程序則作為使用者執行緒和核心執行緒之間的橋樑,這樣可以使用核心執行緒提供的執行緒排程功能及處理器對映,並且使用者執行緒的系統呼叫要通過輕量級程序來完成,大大降低了整個程序被完全阻塞的風險。這種混合模式,使用者執行緒與輕量級程序的數量比是不定的,即N:M的關係,也稱為多對多的執行緒模型。

4 Java執行緒的實現

Java執行緒在JDK 1.2之前是基於使用者執行緒實現的,而JDK 1.2中,執行緒模型替換為基於作業系統原生執行緒來實現。
因此,在目前的JDK版本中,作業系統支援怎樣的執行緒模型,很大程度上決定了Java虛擬機器的執行緒是怎樣對映的,這點在不同的平臺上沒有達成一致,虛擬機器規範中也沒有限定Java執行緒需要使用哪種執行緒模型來實現。
執行緒模型只是對執行緒的併發規模和操作成本產生影響,對Java程式的編碼和執行來說,這些差異都是透明的。
對於Sun JDK來說,它的Windows版與Linux版都是使用一對一的執行緒模型實現的,一條Java執行緒就對映到一條輕量級程序之中,因為Windows和Linux系統提供的執行緒模型就是一對一的。

3.2 Java執行緒排程

Java的執行緒排程方式是搶佔式排程,雖然Java執行緒的排程是系統自動完成的,但是我們還是可以“建議”系統給某些執行緒多分配一點執行時間,另外的一些執行緒則可以少分配一點——這項操作可以通過設定優先順序來完成。
不過,執行緒的優先順序並不是太靠譜,因為Java執行緒是通過對映到原生執行緒上來實現的,所以執行緒排程最終還是取決於作業系統,雖然現在很多作業系統都提供了優先順序的概念,但是並不見得與Java執行緒的優先順序一一對應。例如Windows中就只有7種執行緒優先順序,而Java語言一共設定了10個級別的執行緒優先順序。


3.3 Java執行緒的狀態轉化

新建(New):建立後尚未啟動的執行緒處於這種狀態。
執行(Runable):Runable包括了作業系統執行緒狀態中的Running和Ready,也就是說處於此種狀態的執行緒可能正在執行,也可能正在等待CPU為它分配執行時間。
無限期等待(Waiting):處於這種狀態下的執行緒不會被分配CPU執行時間,他們要等待被其他執行緒顯示喚醒。
限期等待(Timed Waiting):處於這種狀態下的執行緒也不會被分配CPU執行時間,不過無須等待被其他執行緒顯示喚醒,在一定時間之後它們由系統自動喚醒。
阻塞(Blocked):執行緒被阻塞了,“阻塞狀態”與“等待狀態”的區別是:“阻塞狀態”在等待著獲取一個排他鎖,這個事件將在另外一個執行緒放棄這個鎖的時候發生。
結束(Terminate):已經終止的執行緒的執行緒狀態,執行緒已經結束執行。


不足之處以後填補。