深入理解 Java 虛擬機--Java 內存模型與線程

摘自《深入理解 Java 虛擬機:JVM 高級特性與最佳實踐》(第二版)

        併發處理的廣泛應用是使得 Amdahl 定律代替摩爾定律(注:Amdahl 定律通過系統中並行化與串行化的比重來描述多處理器系統能獲得的運算加速能力,摩爾定律則用於描述處理器晶體管數量與運行效率之間的發展關係。這兩個定律的更替代表了近年來硬件發展從追求處理器頻率追求多核心並行處理的發展過程。)成爲計算機性能發展源動力的根本原因,也是人類 “壓榨” 計算機運算能力的最有力武器。

概述

        多任務處理在現代計算機操作系統中幾乎已是一項必備的功能了。在許多情況下,讓計算機同時去做幾件事情,不僅是因爲計算機的運算能力強大了,還有一個很重要的原因是計算機的運算速度與它的存儲通信子系統速度的差距太大,大量的時間都花費在磁盤 I/O網絡通信或者數據庫訪問上。如果不希望處理器在大部分時間裏都處於等待其他資源的狀態,就必須使用一些手段把處理器的運算能力 “壓榨” 出來,否則就會造成很大的浪費,而讓計算機同時處理幾項任務則是最容易想到、也被證明是非常有效的 “壓榨” 手段。

        除了充分利用計算機處理器的能力外,一個服務端同時對多個客戶端提供服務則是另一個更具體的併發應用場景。衡量一個服務性能的高低好壞,每秒事務處理數(Transactions Per Second,TPS是最重要的指標之一,它代表着一秒內服務端平均能響應的請求總數,而 TPS 值與程序的併發能力又有非常密切的關係。對於計算量相同的任務,程序線程併發協調得越有條不紊,效率自然就會越高;反之,線程之間頻繁阻塞甚至死鎖,將會大大降低程序的併發能力。

        服務端是 Java 語言最擅長的領域之一,這個領域的應用佔了 Java 應用中最大的一塊份額,不過如何寫好併發應用程序卻又是服務端程序開發的難點之一,處理好併發方面的問題通常需要更多的編碼經驗來支持。幸好 Java 語言和虛擬機提供了許多工具,把併發編程的門檻降低了不少。並且各種中間件服務器、各類框架都努力地替程序員處理儘可能多的線程併發細節,使得程序員在編碼時能更關注業務邏輯,而不是花費大部分時間去關注此服務會同時被多少人調用、如何協調硬件資源。無論語言、中間件和框架如何先進,開發人員都不能期望它們能獨立完成所有併發處理的事情,瞭解併發的內幕也是成爲一個高級程序員不可缺少的課程

硬件的效率與一致性

        在正式講解 Java 虛擬機併發相關的知識之前,我們先花費一點時間去了解一下物理計算機中的併發問題,物理機遇到的併發問題與虛擬機中的情況有不少相似之處,物理機對併發的處理方案對於虛擬機的實現也有相當大的參考意義。

        “讓計算機併發執行若干個運算任務” 與 “更充分地利用計算機處理器的效能” 之間的因果關係,看起來順理成章,實際上它們之間的關係並沒有想象中的那麼簡單,其中一個重要的複雜性來源是絕大多數的運算任務都不可能只靠處理器 “計算” 就能完成,處理器至少要與內存交互,如讀取運算數據、存儲運算結果等,這個 I/O 操作是很難消除的(無法僅靠寄存器來完成所有運算任務)。由於計算機的存儲設備與處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的高速緩存(Cache)來作爲內存與處理器之間的緩衝:將運算需要使用到的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步回內存之中,這樣處理器就無須等待緩慢的內存讀寫了。

        基於高速緩存的存儲交互很好地理解了處理器與內存的速度矛盾,但是也爲計算機系統帶來了更高的複雜度,因爲它引入了一個新的問題:緩存一致性(Cache Coherence)。在多處理器系統中,每個處理器都有自己的告訴緩存,而它們又共享同一主內存(Main Memory),如圖 12-1 所示。當多個處理器的運算任務都涉及同一塊主內存區域時,將可能導致各自的緩存數據不一致,如果真的發生這種情況,那同步回到主內存時以誰的緩存數據爲準呢?爲了解決一致性的問題,需要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有 MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly 及 Dragon Protocol 等。在本章中將會多次提到的 “內存模型 一詞,可以理解爲在特定的操作協議下,對特定的內存或高速緩存進行讀寫訪問的過程抽象。不同架構的物理機器可以擁有不一樣的內存模型,而 Java 虛擬機也有自己的內存模型,並且這裏介紹的內存訪問操作與硬件的緩存訪問操作具有很高的可比性。

        除了增加高速緩存之外,爲了使得處理器內部的運算單元能儘量被充分利用,處理器可能會對輸入代碼進行亂序執行(Out-Of-Order Execution)優化,處理器會在計算之後將亂序執行的結果充足,保證該結果與順序執行的結果是一致的,但並不保證程序中各個語句計算的先後順序與輸入代碼中的順序一致,因此,如果存在一個計算任務依賴另外一個計算任務的中間結果,那麼其順序並不能靠代碼的先後順序來保證。與處理器的亂序執行優化類型,Java 虛擬機的即時編譯器中有有類似的指令重排序(Instruction Reorder)優化。

Java 內存模型

        Java 虛擬機規範中試圖定義一種 Java 內存模型Java Memory Model,JMM)來屏蔽掉各種硬件操作系統內存訪問差異,以實現讓 Java 程序在各種平臺下都能達到一致的內存訪問效果。在此之前,主流程序語言(如 C/C++ 等)直接使用物理硬件和操作系統的內存模型,因此,會由於不同平臺上內存模型的差異,有可能導致程序在一套平臺上併發完全正常,而在另外一套平臺上併發訪問卻經常出錯,因此在某些場景就必須針對不同的平臺來編寫程序。

        定義 Java 內存模型並非一件容易的事情,這個模型必須定義得足夠嚴謹,才能讓 Java 的併發內存訪問操作不會產生歧義;但是,也必須定義得足夠寬鬆,使得虛擬機的實現有足夠的自由空間去利用硬件的各種特性(寄存器、高速緩存和指令集中某些特有的指令)來獲取更好的執行速度。經過長時間的驗證和修補,在 JDK 1.5(實現了 JSR-133)發佈後,Java 內存模型已經成熟和完善起來了。

主內存與工作內存

        Java 內存模型的主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出變量這樣的底層細節。此處的變量(Variables)與 Java 編程中所說的變量有所區別,它包括了實例字段、靜態字段和構成數組對象的元素,但不包括局部變量與方法參數,因爲後者是線程私有的,不會被共享,自然就不會存在競爭問題。爲了獲得較好的執行效能,Java 內存模型並沒有限制執行引擎使用處理器的特定寄存器或緩存和主內存進行交互,也沒有限制即時編譯器進行調整代碼執行順序這類優化措施。

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

        這裏所講的主內存、工作內存與前面所講的 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 之間是可插入其他指令的,如對主內存中的變量 a、b 進行訪問時,一種可能出現順序是 read a、read b、load b、load a。除此之外,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 操作)。

        這 8 種內存訪問操作以及上述規則限定,再加上稍後介紹的對 volatile 的一些特殊規定,就已經完全確定了 Java 程序中哪些內存訪問操作在併發下是安全的。由於這種定義相當嚴謹但又十分煩瑣,實踐起來很麻煩,所以在後面筆者將介紹這種定義的一個等效判斷原則——先行發生原則,用來確定一個訪問在併發環境下是否安全。

對於 volatile 型變量的特殊規則

        關鍵字 volatile 可以說是 Java 虛擬機提供的最輕量級的同步機制,但是它並不容易完全被正確、完整地理解,以致於許多程序員都習慣不去使用它,遇到需要處理多線程數據競爭問題的時候一律使用 synchronized 來進行同步。瞭解 volatile 變量的語義對後面瞭解多線程操作的其他特性很有意義,在本節中我們將多花費一些時間去弄清楚 volatile 的語義到底是什麼。

         Java 內存模型對 volatile 專門定義了一些特殊的訪問規則,在介紹這些比較拗口的規則定義之前,筆者先用不那麼正式但通俗易懂的語言來介紹一下這個關鍵字的作用。

        當一個變量定義爲 volatile 之後,它將具備兩種特性,第一是保證此變量對所有線程的可見性,這裏的 “可見性” 是指當一條線程修改了這個變量的值,新值對於其他線程來說是可以立即得知的。而普通變量不能做到這一點,普通變量的值在線程間傳遞均需要通過主內存來完成,例如,線程 A 修改一個普通變量的值,然後向主內存進行回寫,另外一條線程 B 在線程 A 回寫完成了之後再從主內存進行讀取操作,新變量值纔會對線程 B 可見。

        關於 volatile 變量的可見性,經常會被開發人員誤解,認爲以下描述成立:“volatile 變量對所有線程是立即可見的,對 volatile 變量所有的寫操作都能立刻反應到其他線程之中,換句話說,volatile 變量在各個線程中是一致的,所以基於 volatile 變量的運算在併發下是安全的”。這句話的論據部分並沒有錯,但是其論據並不能得出 “基於 volatile 變量的運算在併發下是安全的” 這個結論。volatile 變量在各個線程的工作內存中不存在一致性問題(在各個線程的工作內存中,volatile 變量也可以存在不一致的情況,但由於每次使用之前都要先刷新,執行引擎看不到不一致的情況,因此可以認爲不存在不一致性問題),但是 Java 裏面的運算並非原子操作,導致 volatile 變量的運算在併發下一樣是不安全的,我們可以通過一段簡單的演示來說明原因,請看代碼清單 12-1 中演示的例子。

代碼清單 12-1  volatile 的運算

  1. /** 
  2.  * volatile 變量自增運算測試 
  3.  *  
  4.  * @author mk 
  5.  */  
  6. public class VolatileTest {  
  7.   
  8.     public static volatile int race = 0;  
  9.       
  10.     public static void increase() {  
  11.         race++;  
  12.     }  
  13.       
  14.     private static final int THREADS_COUNT = 20;  
  15.       
  16.     public static void main(String[] args) {  
  17.         Thread[] threads = new Thread[THREADS_COUNT];  
  18.         for (int i = 0; i < THREADS_COUNT; i ++) {  
  19.             threads[i] = new Thread(new Runnable() {  
  20.                 @Override  
  21.                 public void run() {  
  22.                     for (int i = 0; i < 10000; i++) {  
  23.                         increase();  
  24.                     }  
  25.                 }  
  26.             });  
  27.             threads[i].start();  
  28.         }  
  29.           
  30.         // 等待所有累加線程都結束  
  31.         while (Thread.activeCount() > 1)   
  32.             Thread.yield();  
  33.           
  34.         System.out.println(race);  
  35.     }  
  36. }  

        這段代碼發起了 20 個線程,每個線程對 race 變量進行 10000 次自增操作,如果這段代碼能夠正確併發的話,最後輸出的結果應該是 200000。讀者運行完這段代碼之後,並不會獲得期望的結果,而且發現每次運行程序,輸出的結果都不一樣,都是一個小於 200000 的數字,這是爲什麼呢?

        問題就出現在自增運算 “race++” 之中,我們用 javap 反編譯這段代碼後會得到代碼清單 12-2,發現只有一行代碼的 increase() 方法在 Class 文件中是由 4 條字節碼指令構成的(return 指令不是由 race++ 產生的,這條指令可以不計算),從字節碼層面上很容易就分析出併發失敗的原因了:當 getstatic 指令把 race 的值取到操作棧頂時,volatile 關鍵字保證了 race 的值在此時是正確的,但是在執行 iconst_1、iadd 這些指令的時候,其他線程可能已經把 race 的值加大了,而在操作棧頂的值就變成了過期的數據,所以 putstatic 指令執行後就可能把較小的 race 值同步會主內存之中。

代碼清單 12-2  VolatileTest 的字節碼

  1. public static void increase();  
  2.   flags: ACC_PUBLIC, ACC_STATIC  
  3.   Code:  
  4.     stack=2, locals=0, args_size=0  
  5.        0: getstatic     #13                 // Field race:I  
  6.        3: iconst_1  
  7.        4: iadd  
  8.        5: putstatic     #13                 // Field race:I  
  9.        8return  
  10.     LineNumberTable:  
  11.       line 130  
  12.       line 148  

        客觀地說,筆者在此使用字節碼來分析併發問題,仍然是不嚴謹的,因爲即使編譯出來只有一條字節碼指令,也並不意味着執行這條指令就是一個原子操作。一條字節碼指令在解釋執行時,解釋器將要運行許多行代碼才能實現它的語義,如果是編譯執行,一條字節碼指令也可能轉化成若干條本地機器碼指令,此處使用 -XX:+PrintAssembly參數輸出反彙編來分析會更嚴峻一些,但考慮讀者閱讀的方便,並且字節碼已經能說明問題,所以此處使用字節碼來分析。

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

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

        而在像如下的代碼清單 12-3 所示的這類場景就很適合使用 volatile 變量來控制併發,當 shutdown() 方法被調用時,能保證所有線程中執行的 doWork() 方法都立即停下來。

代碼清單 12-3  volatile 的使用場景

  1. volatile boolean shutdownRequested;  
  2.   
  3. public void shutdown() {  
  4.     shutdownRequested = true;  
  5. }  
  6.   
  7. public void doWork() {  
  8.     while (!shutdownRequested) {  
  9.         //do stuff  
  10.     }  
  11. }  

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

        上面的描述仍然不太容易理解,我們還是繼續通過一個例子來看看爲何指令重排序會干擾程序的併發執行,演示程序如代碼清單 12-4 所示。

代碼清單 12-4  指令重排序

  1. Map configOptions;  
  2. char[] configText;  
  3. // 此變量必須定義爲 volatile  
  4. <strong>volatile </strong>boolean initialized = false;  
  5.   
  6. // 假設以下代碼在線程 A 中執行  
  7. // 模擬讀取配置信息,當讀取完成後將 initialized 設置爲 true 以通知其他線程配置可用  
  8. configOptions = new HashMap();  
  9. configText = readConfigFile(fileName);  
  10. processConfigOptions(configText, configOptions);  
  11. initialized = true;  
  12.   
  13. // 假設以下代碼在線程 B 中執行  
  14. // 等待 initialized 爲 true,代表線程 A 已經把配置信息初始化完成  
  15. while (!initialized) {  
  16.     sleep();  
  17. }  
  18. // 使用線程 A 中初始化好的配置信息  
  19. doSomethingWithConfig();  

        代碼清單 12-4 中的程序是一段僞代碼,其中描述的場景十分場景,只是我們在處理配置文件時一般不會出現併發而已。如果定義 initialized 變量時沒有使用 volatile 修飾,就可能由於指令重排序的優化,導致位於線程 A 中最後一句的代碼 “initialized=true” 被提前執行(這裏雖然使用 Java 作爲僞代碼,但所指的重排序優化是機器級的優化操作,提前執行是指這句話對應的彙編代碼被提前執行),這樣在線程 B 中使用配置信息的代碼就可能出現錯誤,而 volatile 關鍵字則可以避免此類情況的發生。(注:volatile 屏蔽指令重排序的語義在 JDK 1.5 中才被完全修復,此前的 JDK 中即使將變量聲明爲 volatile 也仍然不能完全避免重排序所導致的問題(主要是 volatile 變量前後的代碼仍然存在重排序問題),這點也是在 JDK 1.5 之前的 Java 中無法按期地使用 DCL(雙鎖檢測)來實現單例模式的原因。

        指令重排序是併發編程中最容易讓開發人員產生疑惑的地方,除了上面僞代碼的例子之外,筆者再舉一個可以實際操作運行的例子來分析 volatile 關鍵字是如何禁止指令重排序優化的。代碼清單 12-5 是一段標準的 DCL 單例代碼,可以觀察加入 volatile 和未加入 volatile 關鍵字時所生成彙編代碼的差別(如何獲得 JIT 的彙編代碼,請參考http://blog.csdn.net/kisimple/article/details/51526034)。

代碼清單 12-5  DCL 單例模式

  1. public class Singleton {  
  2.   
  3.     private volatile static Singleton instance;  
  4.       
  5.     public static Singleton getInstance() {  
  6.         if (instance == null) {  
  7.             synchronized (Singleton.class) {  
  8.                 if (instance == null) {  
  9.                     instance = new Singleton();  
  10.                 }  
  11.             }  
  12.         }  
  13.         return instance;  
  14.     }  
  15.       
  16.     public static void main(String[] args) {  
  17.         Singleton.getInstance();  
  18.     }  
  19. }  

        編譯所用的 VM 參數如下:(打印結果和原文不符,這裏採用原文的打印結果!)

  1. -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,*Singleton.getInstance  

        編譯後,這段代碼對 instance 變量賦值部分如代碼清單 12-6 所示。

  1. 0x01a3de0f: mov    $0x3375cdb0,%esi   ;...beb0cd75 33  
  2.                                         ;   {oop('Singleton')}  
  3. 0x01a3de14: mov    %eax,0x150(%esi)   ;...89865001 0000  
  4. 0x01a3de1a: shr    $0x9,%esi          ;...c1ee09  
  5. 0x01a3de1d: movb   $0x0,0x1104800(%esi)  ;...c6860048 100100  
  6. 0x01a3de24: lock addl $0x0,(%esp)     ;...f0830424 00  
  7.                                         ;*putstatic instance  
  8.                                         ; - Singleton::getInstance@24   

        通過對比就會發現,關鍵變化在於有 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 的語義問題,再來看看在衆多保障併發安全的工具中選用 volatile 的意義——它能讓我們的代碼比使用其他的同步工具更快嗎?在某些情況下,volatile 的同步機制的性能確實要優於鎖(使用 synchronized 關鍵字或 java.util.concurrent 包裏面的鎖),但是由於虛擬機對鎖實行的許多消除和優化,使得我們很難量化地認爲 volatile 就會比 synchronized 快多少。如果讓 volatile 自己與自己比較,那可以確定一個原則:volatile 變量讀操作的性能消耗與普通變量幾乎沒有什麼差別,但是寫操作則可能會慢一些,因爲它需要在本地代碼中插入許多內存屏障指令來保證處理器不發生亂序執行。不過即便如此,大多數場景下 volatile 的總開銷仍然要比鎖低我們在 volatile 與鎖之中選擇的唯一依據僅僅是volatile 的語義能否滿足使用場景的需求

        在本節的最後,我們回頭看一下 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 對變量的前一個動作是 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、assign、use、store、write 這 8 個操作都具有原子性,但是對於 64 位的數據類型(long 和 double),在模型中特別定義了一條相對寬鬆的規定:允許虛擬機將沒有被 volatile 修飾的 64 位數據的讀寫操作劃分爲兩次 32 位的操作來進行,即允許虛擬機實現選擇可以不保證 64 位數據類型的 load、store、read 和 write 這 4 個操作的原子性,這點就是所謂的 long 和 double 的非原子性協定(Nonatomic Treatment of double and long Variables)。

        如果有多個線程共享一個併爲聲明爲 volatile 的 long 或 double 類型的變量,並且同時對它們進行讀取和修改操作,那麼某些線程可能會讀取到一個既非原值,也不是其他線程修改的值的代表了 “半個變量” 的數值。

        不過這種讀取到 “半個變量” 的情況非常罕見(在目前商用 Java 虛擬機中不會出現),因爲 Java 內存模型雖然允許虛擬機不把 long 和 double 變量的讀寫實現成原子操作,但允許虛擬機選擇把這些操作實現爲具有原子性的操作,而且還 “強烈建議” 虛擬機這樣實現。在實際開發中,目前各種平臺下的商用虛擬機幾乎都選擇把 64 位的數據的讀寫操作作爲原子操作來對待,因此我們在編寫代碼時一般不需要把用到的 long 和 double 變量專門聲明爲 volatile。

原子性、可見性與有序性

        介紹完 Java 內存模型的相關操作和規則,我們再整體回顧一下這個模型的特徵。Java 內存模型是圍繞着在併發過程中如何處理原子性、可見性和有序性這 3 個特徵來建立的,我們逐個來看一下哪些操作實現了這 3 個特性。

        原子性(Atomicity):由 Java 內存模型來直接保證的原子性變量操作包括 read、load、assign、use、store 和 write,我們大致可以認爲基本數據類型的訪問讀寫是具備原子性的(例外就是 long 和 double 的非原子性協定,讀者只要知道這件事就可以了,無須太過在意這些幾乎不會發生的例外情況)。

        如果應用場景需要一個更大範圍的原子性保證(經常會遇到),Java 內存模型還提供了 lock 和 unlock 操作來滿足這種需求,經管虛擬機未把 lock 和 unlock 操作直接開放給用戶使用,但是卻提供了更高層次的字節碼指令 monitorenter 和 monitorexit 來隱式地使用這兩個操作,這兩個字節碼指令反映到 Java 代碼中就是同步塊——synchronized 關鍵字,因此在 synchronized 塊之間的操作也具備原子性。

        可見性(Visibility):可見性是指當一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。上文在講解 volatile 變量的時候我們已詳細討論過這一點。Java 內存模型是通過在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量值這種依賴主內存作爲傳遞媒介的方式來實現可見性的,無論是普通變量還是 volatile 變量都是如此,普通變量與 volatile 變量的區別是,volatile 的特殊規則保證了新值能立即同步到主內存,以及每次使用前立即從主內存刷新。因此,可以說 volatile 保證了多線程操作時變量的可見性,而普通變量則不能保證這一點。

        除了 volatile 之外,Java 還有兩個關鍵字能實現可見性,即 synchronized 和 final。同步塊的可見性是由 “對一個變量執行 unlock 操作之前,必須先把此變量同步會主內存中(執行 store、write 操作)” 這條規則獲得的,而 final 關鍵字的可見性是指:被 final 修飾的字段在構造器中一旦初始化完成,並且構造器沒有把 “this” 的引用傳遞出去(this 引用逃逸是一件很危險的事情,其他線程有可能通過這個引用訪問到 “初始化了一半” 的對象),那在其他線程中就能看見 final 字段的值。如代碼清單 12-7 所示,變量 i 與 j 都具備可見性,它們無須同步就能被其他線程正確訪問。

代碼清單 12-7  final 與可見性

  1. public static final int i;  
  2.   
  3. public final int j;  
  4.   
  5. static {  
  6.    i = 0;  
  7.    // do something  
  8. }   
  9.   
  10. {  
  11.    // 也可以選擇在構造函數中初始化  
  12.    j = 0;  
  13.    // do something  
  14. }  

        有序性(Ordering):Java 內存模型的有序性在前面講解 volatile 時也詳細地討論過了,Java 程序中天然的有序性可以總結爲一句話:如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。前半句是指 “線程內表現爲串行的語義” (Within-Thread As-If-Serial Semantics),後半句是指 “指令重排序” 現象和 “工作內存與主內存同步延遲” 現象。

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

        介紹完併發中 3 種重要的特性後,讀者有沒有發現 synchronized 關鍵字在需要這 3 種特性的時候都可以作爲其中一種的解決方案?看起來很 “萬能” 吧。的確,大部分的併發控制操作都能使用 synchronized 來完成。synchronized 的 “萬能” 也間接造就了它被程序員濫用的局面,越 “萬能” 的併發控制,通常會伴隨着越大的性能影響,這點在後面講解虛擬機鎖優化時再介紹。

先行發生原則

        如果 Java 內存模型中所有的有序性都僅僅靠 volatile 和 synchronized 來完成,那麼有一些操作將會變得很煩瑣,但是我們在編寫 Java 併發代碼的時候並沒有感覺到這一點,這是因爲 Java 語言中有一個 “先行發生”(happens-before)的原則。這個原則非常重要,它是判斷數據是否存在競爭、線程是否安全的主要依據,依靠這個原則,我們可以通過幾條規則一攬子地解決併發環境下兩個操作之間是否可能存在衝突的所有問題。

        現在就來看看 “先行發生” 原則指的是什麼。先行發生是 Java 內存模型中定義的兩項操作之間的偏序關係如果說操作 A 先行發生與操作 B,其實就是說在發生操作 B 之前,操作 A 產生的影響能被操作 B 觀察到,“影響” 包括了修改了內存中共享變量的值、發送了消息、調用了方法等。這句話不難理解,但它意味着什麼呢?我們可以舉個例子來說明一下,如代碼清單 12-8 中所示的這 3 句僞代碼。

代碼清單 12-8  先行發生原則示例 1

  1. // 以下操作在線程 A 中執行  
  2. i = 1;  
  3.   
  4. // 以下操作在線程 B 中執行  
  5. j = i;  
  6.   
  7. // 以下操作在線程 C 中執行  
  8. i = 2;  

        假設線程 A 中的操作 “i=1” 先行發生於線程 B 的操作 “j=i”,那麼可以確定在線程 B 的操作執行後,變量 j 的值一定等於 1,得出這個結論的依據有兩個:一是根據先行發生原則,“i=1” 的結果可以被觀察到;而是線程 C 還沒 “登場”,線程 A 操作結束之後沒有其他線程會修改變量 i 的值。現在再來考慮線程 C,我們依然保持線程 A 和線程 B 之間的先行發生關係,而線程 C 出現在線程 A 和線程 B 的操作之間,但是線程 C 與線程 B 沒有先行發生關係,那 j 的值會是多少呢?答案是不確定!1 和 2 都有可能,因爲線程 C 對變量 i 的影響可能會被線程 B 觀察到,也可能不會,這時候線程 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 語言無須任何同步手段保障就能成立的先行發生規則就只有上面這些了,筆者演示一下如何使用這些規則去判定操作間是否具備順序性,對於讀寫共享變量的操作來說,就是線程是否完全,讀者還可以從下面這個例子中感受一下 “時間上的先後順序” 與 “先行發生” 之間有什麼不同。演示例子如代碼清單 12-9 所示。

代碼清單 12-9  先行發生原則示例 2

  1. private int value = 0;  
  2.   
  3. public void setValue(int value) {  
  4.     this.value = value;  
  5. }  
  6.   
  7. public int getValue() {  
  8.     return value;  
  9. }  

        代碼清單 12-9 中顯示的是一組再普通不過的 getter/setter 方法,假設存在線程 A 和 B,線程 A 先(時間上的先後)調用了 “setValue(1)”,然後線程 B 調用了同一個對象的 “getValue()”,那麼線程 B 收到的返回值是什麼?

        我們依次分析一下先行發生原則中的各項規則,由於兩個方法分別由線程 A 和線程 B 調用,不在一個線程中,所以程序次序規則在這裏不適用;由於沒有同步塊,自然就不會發生 lock 和 unlock 操作,所以管程鎖定規則不適用;用於 value 變量沒有被 volatile 關鍵字修飾,所以 volatile 變量規則不適用;後面的線程啓動、終止、中斷規則和對象中介規則也和這裏完全沒有關係。因爲沒有一個適用的先行發生規則,所以最後一條傳遞性也無從談起,因此我們可以判斷儘管線程 A 在操作時間上先於線程 B,但是無法確定線程 B 中 “getValue()” 方法的返回結果,換句話說,這裏面的操作不是線程安全的。

        那怎麼修復這個問題呢?我們至少有兩種比較簡單的方案可以選擇:要麼把 getter/setter 方法都定義爲 synchronized 方法,這樣就可以套用管程鎖定規則;要麼把 value 定義爲 volatile 變量,由於 setter 方法對 value 的修改不依賴 value 的原值,滿足 volatile 關鍵字使用場景,這樣就可以套用 volatile 變量規則來實現先行發生關係。

        通過上面的例子,我們可以得出結論:一個操作 “時間上的先發生” 不代表這個操作會是 “先行發生”,那如果一個操作 “先行發生” 是否就能推導出這個操作必定是 “時間上的先發生” 呢?很遺憾,這個推論也是不成立的,一個典型的例子就是多次提到的 “指令重排序”,演示例子如代碼清單 12-10 所示。

代碼清單 12-10  先行發生原則示例 3

  1. //  以下操作在同一個線程中執行  
  2. int i = 1;  
  3. int j = 2;  

        代碼清單 12-10 的兩條賦值語句在同一個線程之中,根據程序次序規則,“int i=1” 的操作先行發生於 “int j=2”,但是 “int j=2” 的代碼完全可能先被處理器執行,這並不影響先行發生原則的正確性,因爲我們在這條線程之中沒有辦法感知到這點。

        上面兩個例子綜合起來證明了一個結論:時間先後順序與先行發生原則之間基本沒有太大的關係,所以我們衡量併發完全問題的時候不要受到時間順序的干擾,一切必須以先行發生原則爲準

Java 與線程

        併發不一定要依賴多線程(如 PHP 中很常見的多進程併發),但是在 Java 裏面談論併發,大多數都與線程脫不開關係。既然我們探討的話題是 Java 虛擬機的特性,那講到 Java 線程,我們就從 Java 線程在虛擬機中的實現開始講起。

線程的實現

        我們知道,線程是比進程更輕量級的調度執行單位,線程的引入,可以把一個進程的資源分配和執行調度分開,各個線程既可以共享進程資源(內存地址、文件 I/O 等),又可以獨立調度(線程是 CPU 調度的基本單位)。

        主流的操作系統都提供了線程實現,Java 語言則提供了在不同硬件和操作系統平臺下對線程操作的統一處理,每個已經執行 start() 且還未結束的 java.lang.Thread 類的實例就代表了一個線程。我們注意到 Thread 類與大部分的 java API 有顯著的差別,它的所有關鍵方法都是聲明爲 Native 的。在 Java API 中,一個 Native 方法往往意味着這個方法沒有使用或無法使用平臺無關的手段來實現(當然也可能是爲了執行效率而使用 Native 方法,不過,通常最高效率的手段也就是平臺相關的手段)。正因爲如此,作者把本節的標題定爲 “線程的實現” 而不是 “Java 線程的實現”。

        實現線程主要有 3 種方式:使用內核線程實現、使用用戶線程實現和使用用戶線程加輕量級進程混合實現。

1. 使用內核線程實現

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

        程序一般不會直接去使用內核線程,而是去使用內核線程的一種高級接口——輕量級進程(Light Weight Process,LWP),輕量級進程就是我們通常意義上所講的線程,由於每個輕量級進程都由一個內核線程支持,因此只有先支持內核線程,纔能有輕量級進程。這種輕量級進程與內核線程之間 1 : 1 的關係稱爲一對一的線程模型,如圖 12-3 所示。

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

2. 使用用戶線程實現

        從廣義上來講,一個線程只要不是內核線程,就可以認爲是用戶線程(User Thread,UT),因此,從這個定義上講,輕量級進程也屬於用戶線程,但輕量級進程的實現始終是建立在內核之上的,許多操作都要進行系統調用,效率會收到限制。

        而狹義上的用戶線程指的是完全建立在用戶控件的線程庫上,系統內核不能感知線程存在的實現。用戶線程的建立同步銷燬調度完全在用戶態中完成,不需要內核的幫助。如果程序實現得當,這種線程不需要切換到內核態,因此操作可以是非常快速且低消耗的,也可以支持規模更大的線程數量,部分高性能數據庫中的多線程就是由用戶線程實現的。這種進程與用戶線程之間 1:N 的關係稱爲一對多的線程模型,如圖 12-4 所示。

        使用用戶線程的優勢在於不需要系統內核支援,劣勢也在於沒有系統內核的支援,所有的線程操作都需要用戶程序自己處理。線程的創建、切換和調度都是需要考慮的問題,而且由於操作系統只把處理器資源分配到進程,那諸如 “阻塞如何處理”、“多處理器系統中如何將線程映射到其他處理器上” 這類問題解決起來將會異常困難,甚至不可能完成。因而使用用戶線程實現的程序一般都比較複雜,除了以前在不支持多線程的操作系統中(如 DOS)的多線程程序與少數有特殊需求的程序外,現在使用用戶線程的程序越來越少了,Java、Ruby 等語言都曾經使用過用戶線程,最終又都放棄使用它。

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

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


        許多 UNIX 系列的操作系統,如 Solaris、HP-UX 等都提供了 N:M 的線程模型實現。

4. Java 線程的實現

        Java 線程在 JDK 1.2 之前,是基於稱爲 “綠色線程”(Green Threads)的用戶線程實現的,而在 JDK 1.2 中,線程模型替換爲基於操作系統原生線程模型來實現。因此,在目前的 JDK 版本中,操作系統支持怎樣的線程模型,在很大程度上決定了 Java 虛擬機的線程是怎樣映射的,這點在不同的平臺上沒有辦法達成一致,虛擬機規範中也並未限定 Java 線程需要使用哪種線程模型來實現。線程模型只對線程的併發規模操作成本產生影響,對 Java 程序的編碼和運行過程來說,這些差異都是透明的

        對於 Sun SDK 來說,它的 Windows 版與 Linux 版都是使用一對一的線程模型實現的,一條 Java 線程就映射到一條輕量級進程之中,因爲 Windows 和 linux 系統提供的線程模型就是一對一的。

        而在 Solaris 平臺中,由於操作系統的線程特性可以同時支持一對一(通過 Bound Threads 或 Alternate Libthread 實現)級多對多(通過 LWP/Thread Based Synchronization 實現)的線程模型,因此在 Solaris 版的 JDK 中也對應提供了兩個平臺專有的虛擬機參數:-XX:+UseLWPSynchronization(默認值)和 -XX:+UseBoundThreads 來明確指定虛擬機使用哪種線程模型。

Java 線程調度

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

        如果使用協同式調度的多線程系統,線程的執行時間由線程本身來控制,線程把自己的工作執行完了之後,要主動通知系統切換到另外一個線程上。協同式多線程的最大好處是實現簡單,而且由於線程要把自己的事情幹完後纔會進行線程切換,切換操作對線程自己是可知的,所以沒有什麼線程同步的問題。Lua 語言中的“協同例程”就是這類實現。它的壞處也很明顯:線程執行時間不可控制,甚至如果一個線程編寫有問題,一直不告知系統進行線程切換,那麼程序就會一直阻塞在那裏。很久以前的 Windows 3.x 系統就是使用協同式來實現多進程多任務的,相當不穩定,一個進程堅持不讓出 CPU 執行時間久可能會導致整個系統崩潰

        如果使用搶佔式調度的多線程系統,那麼每個線程將由系統來分配執行時間,線程的切換不由線程本身來決定(在 Java 中,Thread.yield() 可以讓出執行時間,但是要獲取執行時間的話,線程本身是沒有什麼辦法的)。在這種實現線程調度的方式下,線程的執行時間是系統可控的,也不會有一個線程導致整個進程阻塞的問題,Java 使用的線程調度方式就是搶佔式調度。與前面所說的 Windows 3.x 的例子相對,在 Windows 9x/NT 內核中就是使用搶佔式來實現多進程的,當一個進程出了問題,我們還可以使用任務管理器把這個進程 “殺掉”,而不至於導致系統崩潰。

        雖然 Java 線程調度是系統自動完成的,但是我們還是可以 “建議” 系統給某些線程多分配一點執行時間,另外一些線程則可以少分配一點——這項操作可以通過設置線程優先級來完成。Java 語言一共設置了 10 個級別的線程優先級(Thread.MIN_PRORITY 至 Thread.MAX_PRIORITY),在兩個線程同時處於 Ready 狀態時,優先級越高的線程越容易被系統選擇執行。

        不過,線程優先級並不是太靠譜,原因是 Java 的線程是通過映射到系統的原生線程上來實現的,所以線程調度最終還是取決於操作系統,雖然現在很多操作系統都提供線程優先級的概念,但是並不見得能與 Java 線程的優先級一一對應,如 Solaris 中有 2147483648(2^31)種優先級,但Windows 中就只有 7 種,比 Java 線程優先級多的系統還好說,中間留下一點空位就可以了,但比 Java 線程優先級少的系統,就不得不出現幾個優先級相同的情況了,表 12-1 顯示了 Java 線程優先級與 Windows 線程優先級之間的對應關係,Windows 平臺的 JDK 中使用了除 THREAD_PRIORITY_IDLE 之外的其餘 6 種線程優先級。

        上文說到 “線程優先級並不是太靠譜”,不僅僅是說在一些平臺上不同的優先級實際會變得相同這一點,還有其他情況讓我們不能太讓依賴優先級:優先級可能會被系統自行改變。例如,在 Windows 系統中存在一個存在一個稱謂  “優先級推進器” (Priority Boosting,當然它可以被關閉掉)的功能,它的大致作用就是當系統發現一個線程執行得到特別 “勤奮努力” 的話,可能會越過線程優先級去爲它分配執行時間。因此,我們不能在程序中通過優先級來完全準確地判斷一組狀態都爲 Ready 的線程將會先執行哪一個。

狀態轉換

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

  • 新建(New):創建後尚未啓動的線程處於這種狀態。
  • 運行(Runnable):Runnable 包括了操作系統線程狀態中的 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):已終止線程的線程狀態,線程已經結束執行。

        上述 5 種狀態在遇到特定事件發生的時候將會互相轉換,它們的轉換關係如圖 12-6 所示。

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