Java內存模型與線程

Java內存模型與線程



Start

  • 計算機的運算速度與它的存儲和通信子系統速度的差距太大,大量的時間都花費在磁盤I/O、網絡通信或者數據庫訪問上 – 多線程,多進程。
  • 每秒事務處理數(Transactions Per Second,TPS)是最重要的指標之一,它代表着一秒內服務端平均能響應的請求總數,而TPS值與併發度密切相關。

硬件的效率與一致性

  • 處理器要和內存交互(取運算數據、存儲運算結果…),這個I/O操作是很難消除的(無法僅靠寄存器來完成所有運算任務)。由於計算機的存儲設備與處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的高速緩存(Cache)來作爲內存與處理器之間的緩衝:將運算需要使用到的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步回內存之中,這樣處理器就無須等待緩慢的內存讀寫了。
  • 高速緩存的存儲交互很好地解決了處理器與內存的速度矛盾,但是它引入了一個新的問題:緩存一致性(Cache Coherence)。多處理器系統中,每個處理器都有自己的高速緩存,而它們又共享同一主內存(Main Memory),如圖12-1所示。當多個處理器的運算任務都涉及同一塊主內存區域時,將可能導致各自的緩存數據不一致,如果真的發生這種情況,那同步回到主內存時以誰的緩存數據爲準呢?爲了解決一致性的問題,需要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有MSI、 MESI(Illinois Protocol)、 MOSI、 Synapse、 Firefly及Dragon Protocol等。
  • 爲了使得處理器內部的運算單元能儘量被充分利用,處理器可能會對輸入代碼進行亂序執行(Out-Of-Order Execution)優化;與處理器的亂序執行優化類似,Java虛擬機的即時編譯器中也有類似的指令重排序(Instruction Reorder)優化。

ch12-cache-memory-processor.png-110.4kB


Java內存模型

  • Java虛擬機規範中試圖定義一種Java內存模型(Java Memory Model,JMM)來屏蔽掉各種硬件和操作系統的內存訪問差異,以實現讓Java程序在各種平臺下都能達到一致的內存訪問效果。 C/C++等則直接使用物理硬件和操作系統的內存模型,因此,會由於不同平臺上內存模型的差異而導致程序的移植性比較差。
  • Java內存模型必須定義得足夠嚴謹,才能讓Java的併發內存訪問操作不會產生歧義;但是,也必須定義得足夠寬鬆,使得虛擬機的實現有足夠的自由空間去利用硬件的各種特性(寄存器、高速緩存和指令集中某些特有的指令)來獲取更好的執行速度。

主內存與工作內存

  • Java內存模型的主要目標是定義程序中各個變量的訪問規則 – 虛擬機中將變量存儲到內存和從內存中取出變量這樣的底層細節。此處的變量(Variables)包括了實例字段、靜態字段和構成數組對象的元素,但不包括局部變量與方法參數,因爲後者是線程私有的,不會被共享,自然就不會存在競爭問題。
  • Java內存模型並沒有限制執行引擎使用處理器的特定寄存器或緩存來和主內存進行交互,也沒有限制即時編譯器進行調整代碼執行順序這類優化措施。
  • Java內存模型規定了所有的變量都存儲在主內存(Main Memory,類比物理內存)。每條線程還有自己的工作內存(Working Memory,類比處理器高速緩存),線程的工作內存中保存了被該線程使用到的變量的主內存副本拷貝。線程對變量的所有操作(讀取、賦值等)都必須在工作內存中進行,而不能直接讀寫主內存中的變量不同的線程之間也無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要通過主內存來完成,線程、主內存、工作內存三者的交互關係如圖12-2所示。
  • 粗略來看,主內存主要對應於Java堆中的對象實例數據部分,而工作內存則對應於虛擬機棧中的部分區域。 從更低層次上說,主內存就直接對應於物理硬件的內存,而爲了獲取更好的運行速度,虛擬機(甚至是硬件系統本身的優化措施)可能會讓工作內存優先存儲於寄存器和高速緩存中,因爲程序運行時主要訪問讀寫的是工作內存。

  • 注:

    • 如果局部變量是一個reference類型,它引用的對象在Java堆中可被各個線程共享,但是reference本身在Java棧的局部變量表中,它是線程私有的。
    • 如“假設線程中訪問一個10MB的對象,也會把這10MB的內存複製一份拷貝出來嗎?”,事實上並不會如此,這個對象的引用、對象中某個在線程訪問到的字段是有可能存在拷貝的,但不會有虛擬機實現成把整個對象拷貝一次。
    • Java虛擬機規範的規定,volatile變量依然有工作內存的拷貝,但是由於它特殊的操作順序性規定(後文會講到),所以看起來如同直接在主內存中讀寫訪問一般。
    • 除了實例數據,Java堆還保存了對象的其他信息,對於HotSpot虛擬機來講,有Mark Word(存儲對象哈希碼、 GC標誌、 GC年齡、 同步鎖等信息)、class Point(指向存儲類型元數據的指針)及一些用於字節對齊補白的填充數據(如果實例數據剛好滿足8字節對齊的話,則可以不存在補白)。

ch12-thread-main-memory-work-memory.png-115.6kB

內存間交互操作

  • 一個變量如何從主內存拷貝到工作內存、如何從工作內存同步回主內存之類的實現細節,Java內存模型中定義了以下8種操作來完成,虛擬機實現時必須保證下面提及的每一種操作都是原子的、 不可再分的。((JSR-133文檔中,已經放棄採用這8種操作去定義Java內存模型的訪問協議)
    • lock(鎖定):作用於主內存的變量,它把一個變量標識爲一條線程獨佔的狀態。
    • unlock(解鎖):作用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定。
    • read(讀取):作用於主內存的變量,它把一個變量的值從主內存傳輸到線程的工作內存中,以便隨後的load動作使用。
    • write(寫入):作用於主內存的變量,它把store操作從工作內存中得到的變量的值放入主內存的變量中。
    • store(存儲):作用於工作內存的變量,它把工作內存中一個變量的值傳送到主內存中,以便隨後的write操作使用。(store後write)
    • load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。(read以後load)
    • use(使用):作用於工作內存的變量,它把工作內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個需要使用到變量的值的字節碼指令時將會執行這個操作。
    • assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收到的值賦給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
  • 如果要把一個變量從主內存複製到工作內存,那就要順序地執行read和load操作,如果要把變量從工作內存同步回主內存,就要順序地執行store和write操作。注意,Java內存模型只要求上述兩個操作必須按順序執行,而沒有保證是連續執行。也就是說,read與load之間、store與write之間是可插入其他指令的,如對主內存中的變量a、 b進行訪問時,一種可能出現順序是read a、 read b、 load b、 load a。
  • Java內存模型還規定了在執行上述8種基本操作時必須滿足如下規則
    • 不允許read和load、store和write操作之一單獨出現,即不允許一個變量從主內存讀取了但工作內存不接受,或者從工作內存發起回寫了但主內存不接受的情況出現。
    • 不允許一個線程丟棄它的最近的assign操作,即變量在工作內存中改變了之後必須把該變化同步回主內存。
    • 不允許一個線程無原因地(沒有發生過任何assign操作)把數據從線程的工作內存同步回主內存中。
    • 一個新的變量只能在主內存中“誕生”,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量,換句話說,就是對一個變量實施use、store操作之前,必須先執行過了load和assign操作
    • 一個變量在同一個時刻只允許一條線程對其進行lock操作,但lock操作可以被同一條線程重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變量纔會被解鎖。
    • 如果對一個變量執行lock操作,那將會清空工作內存中此變量的值,在執行引擎使用這個變量前,需要重新執行load或assign操作初始化變量的值。
    • 如果一個變量事先沒有被lock操作鎖定,那就不允許對它執行unlock操作,也不允許去unlock一個被其他線程鎖定住的變量。
    • 對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store、 write操
      作)。
  • 這8種內存訪問操作以及上述規則限定,再加上對volatile的一些特殊規定,就可以完全確定了Java程序中哪些內存訪問操作在併發下是安全的。 – 等價於”先行發生原則”

對於volatile型變量的特殊規則

  • 關鍵字volatile可以說是Java虛擬機提供的最輕量級的同步機制;Java內存模型對volatile專門定義了一些特殊的訪問規則,一個變量定義爲volatile之後,它將具備兩種特性:可見性,禁止指令重排序優化。

可見性

  • 保證此變量對所有線程的可見性,這裏的“可見性”是指當一條線程修改了這個變量的值,新值對於其他線程來說是可以立即得知的。而普通變量不能做到這一點,普通變量的值在線程間傳遞均需要通過主內存來完成 – 線程A修改一個普通變量的值,然後向主內存進行回寫,另外一條線程B在線程A回寫完成了之後再從主內存進行讀取操作,新變量值纔會對線程B可見。
  • 針對 volatile變量的可見性的誤解 – “volatile變量對所有線程是立即可見的,對volatile變量所有的寫操作都能立刻反應到其他線程之中,換句話說,volatile變量在各個線程中是一致的,所以基於volatile變量的運算在併發下是安全的”。這句話的論據部分並沒有錯,但是並不能得出“基於volatile變量的運算在併發下是安全的”這個結論。volatile變量在各個線程的工作內存中不存在一致性問題(在各個線程的工作內存中,volatile變量也可以存在不一致的情況,但由於每次使用之前都要先刷新,執行引擎看不到不一致的情況,因此可以認爲不存在一致性問題),但是Java裏面的運算並非原子操作,導致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);
    }
}

# output
darcy@darcy-pc:~/IdeaProjects/jvm_in_action/src$ javac ch12/VolatileTest.java 
darcy@darcy-pc:~/IdeaProjects/jvm_in_action/src$ java ch12.VolatileTest 
145901
 # 反編譯後的字節碼;
 # 一條race++變成了四條字節碼指令;
  public static void increase();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #2                  // Field race:I
         3: iconst_1
         4: iadd
         5: putstatic     #2                  // Field race:I
         8: return
      LineNumberTable:
        line 12: 0
        line 13: 8

# 想反匯編出來
darcy@darcy-pc:~/IdeaProjects/jvm_in_action/src$ java -XX:+PrintAssembly ch12.VolatileTest
Error: VM option 'PrintAssembly' is diagnostic and must be enabled via -XX:+UnlockDiagnosticVMOptions.
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
darcy@darcy-pc:~/IdeaProjects/jvm_in_action/src$ java -XX:+UnlockDiagnosticVMOptions  -XX:+PrintAssembly ch12.VolatileTest
Java HotSpot(TM) 64-Bit Server VM warning: PrintAssembly is enabled; turning on DebugNonSafepoints to gain additional output
Could not load hsdis-amd64.so; library not loadable; PrintAssembly is disabled
132676
  • 從字節碼層面上很容易就分析出併發失敗的原因了:當getstatic指令把race的值取到操作棧頂時,volatile關鍵字保證了race的值在此時是正確的,但是在執行iconst_1、iadd這些指令的時候,其他線程可能已經把race的值加大了,而在操作棧頂的值就變成了過期的數據,所以putstatic指令執行後就可能把較小的race值同步回主內存之中。
  • 注意即使編譯出來只有一條字節碼指令,也並不意味執行這條指令就是一個原子操作。一條字節碼指令在解釋執行時,解釋器將要運行許多行代碼才能實現它的語義,如果是編譯執行,一條字節碼指令也可能轉化成若干條本地機器碼指令,此處使用-XX:+PrintAssembly參數輸出反彙編來分析會更加嚴謹一些。
  • 由於volatile變量只能保證可見性,在不符合以下兩條規則的運算場景中,我們仍然要通過加鎖(使用synchronized或java.util.concurrent中的原子類)來保證原子性。
  • 運算結果並不依賴變量的當前值,或者能夠確保只有單一的線程修改變量的值。
  • 變量不需要與其他的狀態變量共同參與不變約束。
# 適合使用volatile變量來控制併發
# 當shutdown()方法被調用時,能保證所有線程中執行的doWork()方法都立即停下來。
volatile boolean shutdownRequested;
public void shutdown(){
    shutdownRequested = true;
}
public void doWork() {
    while (!shutdownRequested){
        //do stuff
    }
}

禁止指令重排序優化

  • 普通的變量僅僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變量賦值操作的順序與程序代碼中的執行順序一致。因爲在一個線程的方法執行過程中無法感知到這點,這也就是Java內存模型中描述的所謂的“線程內表現爲串行的語義”(Within-Thread As-If-Serial Semantics)。
# 如果定義initialized變量時沒有使用volatile修飾,就可能會由於指令重排序的優化,導致位於線程A中最後一句的代碼“initialized=true”被提前執行(提前執行是指這句話對應的彙編代碼被提前執行),這樣在線程B中使用配置信息的代碼就可能出現錯誤,而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中執行
//等待initialized爲true,代表線程A已經把配置信息初始化完成
while(!initialized){
sleep();
}//使用線程A中初始化好的配置信息
doSomethingWithConfig();
# volatile修飾符的作用;
public class Singleton {
    private volatile static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        Singleton.getInstance();
    }
}

# 添加和volatile和不添加volatile的關鍵變化在於有volatile修飾的變量,賦值後(前面mov%eax,0x150(%esi)這句便是賦值操作)多執行了一個“lock addl $0x0,(%esp)”操作,這個操作相當於一個內存屏障(Memory Barrier或Memory Fence,指重排序時不能把後面的指令重排序到內存屏障之前的位置),只有一個CPU訪問內存時,並不需要內存屏障;但如果有兩個或更多CPU訪問同一塊內存,且其中有一個在觀測另一個,就需要內存屏障來保證一致性了。 這句指令中的“addl $0x0,(%esp)”(把ESP寄存器的值加0)顯然是一個空操作(採用這個空操作而不是空操作指令nop是因爲IA32手冊規定lock前綴不允許配合nop 指令使用),關鍵在於lock前綴,查詢IA32手冊,它的作用是使得本CPU的Cache寫入了內存,該寫入動作也會引起別的CPU或者別的內核無效化(Invalidate)其Cache,這種操作相當於對Cache中的變量做了一次前面介紹Java內存模式中所說的“store和write”操作。所以通過這樣一個空操作,可讓前面volatile變量的修改對其他CPU立即可見。
  • 指令重排序是指CPU採用了允許將多條指令不按程序規定的順序分開發送給各相應電路單元處理。但並不是說指令任意重排,CPU需要能正確處理指令依賴情況以保障程序能得出正確的執行結果。譬如指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值減去3,這時指令1和指令2是有依賴的,它們之間的順序不能重排——(A+10)*2與A*2+10顯然不相等,但指令3可以重排到指令1、2之前或者中間,只要保證CPU執行後面依賴到A、B值的操作時能獲取到正確的A和B值即可。所以在本CPU中,重排序看起來依然是有序的。 因此,lock addl$0x0,(%esp)指令把修改同步到內存時,意味着所有之前的操作都已經執行完成,這樣便形成了“指令重排序無法越過內存屏障”的效果。
  • 某些情況下,volatile的同步機制的性能確實要優於鎖(使用synchronized關鍵字或java.util.concurrent包裏面的鎖),但是由於虛擬機對鎖實行的許多消除和優化,使得我們很難量化地認爲volatile就會比synchronized快多少。 如果讓volatile自己與自己比較,那可以確定一個原則:volatile變量讀操作的性能消耗與普通變量幾乎沒有什麼差別,但是寫操作則可能會慢一些,因爲它需要在本地代碼中插入許多內存屏障指令來保證處理器不發生亂序執行。不過即便如此,大多數場景下volatile的總開銷仍然要比鎖低,我們在volatile與鎖之中選擇的唯一依據僅僅是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修飾的變量不會被指令重排序優化,保證代碼的執行順序與程序的順序相同)。

對於long和double型變量的特殊規則

  • Java內存模型要求lock、unlock、read、load、assign、use、store、write這8個操作都具有原子性,但是對於64位的數據類型(long和double),在模型中特別定義了一條相對寬鬆的規定:允許虛擬機將沒有被volatile修飾的64位數據的讀寫操作劃分爲兩次32位的操作來進行,即允許虛擬機實現選擇可以不保證64位數據類型的load、store、read和write這4個操作的原子性,這點就是所謂的long和double的非原子性協定(Nonatomic Treatment ofdouble andlong Variables)。
  • 如果有多個線程共享一個並未聲明爲volatile的long或double類型的變量,並且同時對它們進行讀取和修改操作,那麼某些線程可能會讀取到一個既非原值,也不是其他線程修改值的代表了“半個變量”的數值。但是這種情況基本不可能。

原子性、 可見性與有序性

  • Java內存模型是圍繞着在併發過程中如何處理原子性、 可見性和有序性這3個特徵來建立的。
  • synchronized關鍵字在需要這3種特性的時候都可以作爲其中一種的解決方案?大部分的併發控制操作都能使用synchronized來完成。

原子性(Atomicity):

  • 由Java內存模型來直接保證的原子性變量操作包括read、load、assign、use、store和write,我們大致可以認爲基本數據類型的訪問讀寫是具備原子性的。(float, double不視爲特例)
  • 如果應用場景需要一個更大範圍的原子性保證,Java內存模型還提供了lock和unlock操作來滿足這種需求,儘管虛擬機未把lock和unlock操作直接開放給用戶使用,但是卻提供了更高層次的字節碼指令monitorenter和monitorexit來隱式地使用這兩個操作,這兩個字節碼指令反映到Java代碼中就是同步塊——synchronized關鍵字,因此在synchronized塊之間的操作也具備原子性。

可見性(Visibility)

  • 可見性是指當一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。Java內存模型是通過在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量值這種依賴主內存作爲傳遞媒介的方式來實現可見性的,無論是普通變量還是volatile變量都是如此,普通變量與volatile變量的區別是,volatile的特殊規則保證了新值能立即同步到主內存,以及每次使用前立即從主內存刷新。因此,可以說volatile保證了多線程操作時變量的可見性,而普通變量則不能保證這一點。
  • 除了volatile之外,Java還有兩個關鍵字能實現可見性,即synchronized和final。同步塊的可見性是由“對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store、write操作)”這條規則獲得的,而final關鍵字的可見性是指:被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的結論。
private int value = 0;

    public void setValue(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
}
# 線程A先調用set方法,然後線程B調用get方法;那麼線程B獲得的值?
# 先行發生原則中的各項規則,由於兩個方法分別由線程A和線程B調用,不在一個線程中,所以程序次序規則在這裏不適用;由於沒有同步塊,自然就不會發生lock和unlock操作,所以管程鎖定規則不適用;由於value變量沒有被volatile關鍵字修飾,所以volatile變量規則不適用;後面的線程啓動、終止、中斷規則和對象終結規則也和這裏完全沒有關係。因爲沒有一個適用的先行發生規則,所以最後一條傳遞性也無從談起,因此我們可以判定儘管線程A在操作時間上先於線程B,但是無法確定線程B中"getValue()"方法的返回結果,換句話說,這裏面的操作不是線程安全的。
# fixed it: 1. 把getter/setter方法都定義爲synchronized方法,這樣就可以套用管程鎖定規則;2.把value定義爲volatile變量,由於setter方法對value的修改不依賴value的原值,滿足volatile關鍵字使用場景,這樣就可以套用volatile變量規則來實現先行發生關係。
  • 一個操作“時間上的先發生”不代表這個操作會是“先行發生”,一個操作“先行發生”也不能推導出這個操作必定是“時間上的先發生” – “指令重排”。兩者之間沒有必然的聯繫。衡量併發安全問題的時候不要受到時間順序的干擾,一切必須以先行發生原則爲準。

Java與線程

線程的實現

  • 線程是比進程更輕量級的調度執行單位,線程的引入,可以把一個進程的資源分配和執行調度分開,各個線程既可以共享進程資源(內存地址、文件I/O等),又可以獨立調度(線程是CPU調度的基本單位)。
  • Java中每個已經執行start()且還未結束的java.lang.Thread類的實例就代表了一個線程。Thread類的所有關鍵方法都是聲明爲Native的(沒法使用平臺無關的方式實現)。
  • 實現線程主要有3種方式:使用內核線程實現、使用用戶線程實現和使用用戶線程加輕量級進程混合實現。

使用內核線程實現

  • 內核線程(Kernel-Level Thread,KLT)就是直接由操作系統內核(Kernel,下稱內核)支持的線程,這種線程由內核來完成線程切換,內核通過操縱調度器(Scheduler)對線程進行調度,並負責將線程的任務映射到各個處理器上。
  • 程序一般使用內核線程的一種高級接口——輕量級進程(Light Weight Process,LWP),輕量級進程就是我們通常意義上所講的線程,每個輕量級進程都由一個內核線程支持。
  • 輕量級進程具有它的侷限性:首先,由於是基於內核線程實現的,所以各種線程操作,如創建、析構及同步,都需要進行系統調用。 而系統調用的代價相對較高,需要在用戶態(User Mode)和內核態(KernelMode)中來回切換。其次,每個輕量級進程都需要有一個內核線程的支持,因此輕量級進程要消耗一定的內核資源(如內核線程的棧空間),因此一個系統支持輕量級進程的數量有限。好處就是由於內核線程的支持,每個輕量級進程都成爲一個獨立的調度單元。

ch12-light-weight-progress.png-210kB

使用用戶線程實現

  • 狹義上的用戶線程指的是完全建立在用戶空間的線程庫上,系統內核不能感知線程存在的實現。用戶線程的建立、 同步、 銷燬和調度完全在用戶態中完成,不需要內核的幫助。如果程序實現得當,這種線程不需要切換到內核態,因此操作可以是非常快速且低消耗的,也可以支持規模更大的線程數量,部分高性能數據庫中的多線程就是由用戶線程實現的。這種進程與用戶線程之間1:N的關係稱爲一對多的線程模型。
  • 用戶線程的優勢在於不需要系統內核支援,劣勢也在於沒有系統內核的支援,所有的線程操作都需要用戶程序自己處理。 線程的創建、切換和調度都是需要考慮的問題,而且由於操作系統只把處理器資源分配到進程,那諸如“阻塞如何處理”、“多處理器系統中如何將線程映射到其他處理器上”這類問題解決起來將會異常困難,甚至不可能完成。Java、Ruby等語言都曾經使用過用戶線程,最終又都放棄使用它。

ch12-process-user-thread.png-91.7kB

使用用戶線程加輕量級進程混合實現

  • 將內核線程與用戶線程一起使用的實現方式。在這種混合實現下,既存在用戶線程,也存在輕量級進程。 用戶線程還是完全建立在用戶空間中,因此用戶線程的創建、切換、析構等操作依然廉價,並且可以支持大規模的用戶線程併發。而操作系統提供支持的輕量級進程則作爲用戶線程和內核線程之間的橋樑,這樣可以使用內核提供的線程調度功能及處理器映射,並且用戶線程的系統調用要通過輕量級線程來完成,大大降低了整個進程被完全阻塞的風險。在這種混合模式中,用戶線程與輕量級進程的數量比是不定的,即爲N:M的關係。

ch12-user-thread-lwt-m-n.png-191.2kB

Java線程的實現

  • Java線程在JDK 1.2之前,是基於稱爲“綠色線程”(Green Threads)的用戶線程實現的,而在JDK1.2中,線程模型替換爲基於操作系統原生線程模型來實現。目前的JDK版本中,操作系統支持怎樣的線程模型取決於Java虛擬機的線程是怎樣映射。線程模型只對線程的併發規模和操作成本產生影響,對Java程序的編碼和運行過程來說,這些差異都是透明的。

Java線程調度

  • 線程調度是指系統爲線程分配處理器使用權的過程,主要調度方式有兩種,分別是協同式線程調度(Cooperative Threads-Scheduling)和搶佔式線程調度(Preemptive ThreadsScheduling)。
  • 協同式調度: 線程的執行時間由線程本身來控制,線程把自己的工作執行完了之後,要主動通知系統切換到另外一個線程上。優點是實現簡單,而且由於線程要把自己的事情幹完後纔會進行線程切換,切換操作對線程自己是可知的,所以沒有什麼線程同步的問題。缺點:線程執行時間不可控制,甚至如果一個線程編寫有問題,一直不告知系統進行線程切換,那麼程序就會一直阻塞在那裏。
  • 搶佔式調度:線程將由系統來分配執行時間,線程的切換不由線程本身來決定(在Java中,Thread.yield()可以讓出執行時間,但是要獲取執行時間的話,線程本身是沒有什麼辦法的)。在這種實現線程調度的方式下,線程的執行時間是系統可控的,也不會有一個線程導致整個進程阻塞的問題,Java使用的線程調度方式就是搶佔式調度。
  • Java語言一共設置了10個級別的線程優先級(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY),在兩個線程同時處於Ready狀態時,優先級越高的線程越容易被系統選擇執行。Java的線程是通過映射到系統的原生線程上來實現的,所以線程調度最終還是取決於操作系統,即系統線程優先級跟Java線程的優先級一般對不上。

ch12-thread-priority.png-240.9kB

狀態轉換

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

ch12-thread-state-transit.png-103.8kB


ref

深入理解Java虛擬機(第二版)
使用-XX:+PrintAssembly打印asm代碼遇到的問題

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章