《深刻理解Java虛擬機》-----第12章 Java內存模型與線程

概述

多任務處理在現代計算機操做系統中幾乎已經是一項必備的功能了。在許多狀況下,讓計算機同時去作幾件事情,不只是由於計算機的運算能力強大了,還有一個很重要的緣由是計算機的運算速度與它的存儲和通訊子系統速度的差距太大,大量的時間都花費在磁盤I/O、網絡通訊或者數據庫訪問上。若是不但願處理器在大部分時間裏都處於等待其餘資源的狀態,就必須使用一些手段去把處理器的運算能力「壓榨」出來,不然就會形成很大的浪費,而讓計算機同時處理幾項任務則是最容易想到、也被證實是很是有效的「壓榨」手段。html

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

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

Amdahl定律經過系統中並行化與串行化的比重來描述多處理器系統能得到的運算加速能力,摩爾定律則用於描述處理器晶體管數量與運行效率之間的發展關係。這兩個定律的更替表明了近年來硬件發展從追求處理器頻率到追求多核心並行處理的發展過程。數據庫

硬件的效率與一致性

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

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

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

圖 12-1 處理器、高速緩存、主內存間的交互關係緩存

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

Java內存模型

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

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

主內存與工做內存

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

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

圖 12-2 線程、主內存、工做內存三者的交互關係(請與圖12-1對比)

注:

  • 若是局部變量是一個reference類型,它引用的對象在Java堆中可被各個線程共享,可是reference自己在Java棧的局部變量表中,它是線程私有的。
  • 「拷貝副本」,如「假設線程中訪問一個10MB的對象,也會把這10MB的內存複製一份拷貝出來嗎?」,事實上並不會如此,這個對象的引用、對象中某個在線程訪問到的字段是有可能存在拷貝的,但不會有虛擬機實現成把整個對象拷貝A一次。
  • volatile變量依然有工做內存的拷貝,可是因爲它特殊的操做順序性規定,因此看起來如同直接在主內存中讀寫訪問通常,所以這裏的描述對於volatile也並不存在例外。
  • 除了實例數據,Java堆還保存了對象的其餘信息,對於HotSpot虛擬機來說,有Mark Word(存儲對象哈希碼、GC標誌、GC年齡、同步鎖等信息)、Klass Point(指向存儲類型元數據的指針)及一些用於字節對齊補白的填充數據(若是實例數據恰好知足8字節對齊的話,則能夠不存在補白)。

內存間交互操做

關於主內存與工做內存之間具體的交互協議,即一個變量如何從主內存拷貝到工做內存、如何從工做內存同步回主內存之類的實現細節,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程序中哪些內存訪問操做在併發下是安全的。因爲這種定義至關嚴謹但又十分煩瑣,實踐起來很麻煩,因此在後文將介紹這種定義的一個等效判斷原則——先行發生原則,用來肯定一個訪問在併發環境下是否安全。

注: 
基於理解難度和嚴謹性考慮,最新的JSR-133文檔中,已經放棄採用這8種操做去定義Java內存模型的訪問協議了(僅是描述方式改變了,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的運算

/**
 * volatile變量自增運算測試
 * 
 * @author zzm
 */
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次自增操做,若是這段代碼可以正確併發的話,最後輸出的結果應該是200000。讀者運行完這段代碼以後,並不會得到指望的結果,並且會發現每次運行程序,輸出的結果都不同,都是一個小於200000的數字,這是爲何呢?

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

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

public static void increase();
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 14:0
line 15:8 

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

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

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

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

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

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

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

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

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

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(); 

代碼清單12-4中的程序是一段僞代碼,其中描述的場景十分常見,只是咱們在處理配置文件時通常不會出現併發而已。若是定義initialized變量時沒有使用volatile修飾,就可能會因爲指令重排序的優化,致使位於線程A中最後一句的代碼「initialized=true」被提早執行(這裏雖然使用Java做爲僞代碼,但所指的重排序優化是機器級的優化操做,提早執行是指這句話對應的彙編代碼被提早執行),這樣在線程B中使用配置信息的代碼就可能出現錯誤,而volatile關鍵字則能夠避免此類狀況的發生。

指令重排序是併發編程中最容易讓開發人員產生疑惑的地方,除了上面僞代碼的例子以外,筆者再舉一個能夠實際操做運行的例子來分析volatile關鍵字是如何禁止指令重排序優化的。代碼清單12-5是一段標準的DCL單例代碼,能夠觀察加入volatile和未加入volatile關鍵字時所生成彙編代碼的差異(如何得到JIT的彙編代碼,請參考4.2.7節)。

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

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();
    }
} 

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

代碼清單12-6

0x01a3de0f:mov$0x3375cdb0,%esi         ;……beb0cd75 33
                                        ;{oop('Singleton')}
0x01a3de14:mov%eax,0x150(%esi)      ;……89865001 0000
0x01a3de1a:shr$0x9,%esi                ;……c1ee09
0x01a3de1d:movb$0x0,0x1104800(%esi)    ;……c6860048 100100
0x01a3de24:lock addl$0x0,(%esp)        ;……f0830424 00*putstatic instance
                                        ;-
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能夠重排到指令一、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對變量V執行的前一個動做是assign的時候,線程T才能對變量V執行store動做;而且,只有當線程T對變量V執行的後一個動做是store的時候,線程T才能對變量V執行assign動做。線程T對變量V的assign動做能夠認爲是和線程T對變量V的store、write動做相關聯,必須連續一塊兒出現(這條規則要求在工做內存中,每次修改V後都必須馬上同步回主內存中,用於保證其餘線程能夠看到本身對變量V所作的修改)。

注: 
volatile屏蔽指令重排序的語義在JDK 1.5中才被徹底修復,此前的JDK中即便將變量聲明爲volatile也仍然不能徹底避免重排序所致使的問題(主要是volatile變量先後的代碼仍然存在重排序問題),這點也是在JDK 1.5以前的Java中沒法安全地使用DCL(雙鎖檢測)來實現單例模式的緣由。 
Doug Lea列出了各類處理器架構下的內存屏障指令:http://g.oswego.edu/dl/jmm/cookbook.html

對於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 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與可見性

public static final int i;
public final int j;
static{
    i=0//do something
}
{
    //也能夠選擇在構造函數中初始化
    j=0//do something

有序性(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

//如下操做在線程A中執行
i=1//如下操做在線程B中執行
j=i;
//如下操做在線程C中執行
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

private int value=0;
pubilc void setValue(int value){
    this.value=value;
}
public int getValue(){
    return value;
} 

代碼清單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

//如下操做在同一個線程中執行
int i=1int 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所示。

圖 12-3 輕量級進程與內核線程之間1:1的關係

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

2.使用用戶線程實現

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

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

圖 12-4 進程與用戶線程之間1:N的關係

使用用戶線程的優點在於不須要系統內核支援,劣勢也在於沒有系統內核的支援,全部的線程操做都須要用戶程序本身處理。線程的建立、切換和調度都是須要考慮的問題,並且因爲操做系統只把處理器資源分配到進程,那諸如「阻塞如何處理」、「多處理器系統中如何將線程映射到其餘處理器上」這類問題解決起來將會異常困難,甚至不可能完成。於是使用用戶線程實現的程序通常都比較複雜,除了之前在不支持多線程的操做系統中(如DOS)的多線程程序與少數有特殊需求的程序外,如今使用用戶線程的程序愈來愈少了,Java、Ruby等語言都曾經使用過用戶線程,最終又都放棄使用它。

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

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

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

圖 12-5 用戶線程與輕量級進程之間N:M的關係

4.Java線程的實現

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

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

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

Windows下有纖程包(Fiber Package),Linux下也有NGPT(在2.4內核的年代)來實現N:M模型,可是它們都沒有成爲主流。

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_PRIORITY至Thread.MAX_PRIORITY),在兩個線程同時處於Ready狀態時,優先級越高的線程越容易被系統選擇執行。

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

上文說到「線程優先級並非太靠譜」,不只僅是說在一些平臺上不一樣的優先級實際會變得相同這一點,還有其餘狀況讓咱們不能太依賴優先級:優先級可能會被系統自行改變。例如,在Windows系統中存在一個稱爲「優先級推動器」(Priority Boosting,固然它能夠被關閉掉)的功能,它的大體做用就是當系統發現一個線程執行得特別「勤奮努力」的話,可能會越過線程優先級去爲它分配執行時間。所以,咱們不能在程序中經過優先級來徹底準確地判斷一組狀態都爲Ready的線程將會先執行哪個。

狀態轉換

Java語言定義了5種線程狀態,在任意一個時間點,一個線程只能有且只有其中的一種狀態,這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):已終止線程的線程狀態,線程已經結束執行。

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

圖 12-6 線程狀態轉換關係

相關文章
相關標籤/搜索