Happens-Before是一個很是抽象的概念,然而它又是學習Java併發編程不可跨域的部分。本文會先闡述Happens-Before在併發編程中解決的問題——多線程可見性,而後再詳細講解Happens-Before原則自己。程序員
在現代操做系統上編寫併發程序時,除了要注意線程安全性(多個線程互斥訪問臨界資源)之外,還要注意多線程對共享變量的可見性,然後者每每容易被人忽略。
可見性是指當一個線程修改了共享變量的值,其它線程可以適時得知這個修改。在單線程環境中,若是在程序前面修改了某個變量的值,後面的程序必定會讀取到那個變量的新值。這看起來很天然,然而當變量的寫操做和讀操做在不一樣的線程中時,狀況卻並不是如此。編程
/** *《Java併發編程實戰》27頁程序清單3-1 */ public class NoVisibility { private static boolean ready; private static int number; private static class ReaderThread extends Thread { public void run() { while(!ready) { Thread.yield(); } System.out.println(number); } } public static void main(String[] args) { new ReaderThread().start(); //啓動一個線程 number = 42; ready = true; } }
上面的代碼中,主線程和讀線程都訪問共享變量ready和number。程序看起來會輸出42,但事實上極可能會輸出0,或者根本沒法終止。這是由於上面的程序缺乏線程間變量可見性的保證,因此在主線程中寫入的變量值,可能沒法被讀線程感知到。跨域
要想解釋爲何會出現線程可見性問題,須要從計算機處理器結構談起。咱們都知道計算機運算任務須要CPU和內存相互配合共同完成,其中CPU負責邏輯計算,內存負責數據存儲。CPU要與內存進行交互,如讀取運算數據、存儲運算結果等。因爲內存和CPU的計算速度有幾個數量級的差距,爲了提升CPU的利用率,現代處理器結構都加入了一層讀寫速度儘量接近CPU運算速度的高速緩存來做爲內存與CPU之間的緩衝:將運算須要使用的數據複製到緩存中,讓CPU運算能夠快速進行,計算結束後再將計算結果從緩存同步到主內存中,這樣處理器就無須等待緩慢的內存讀寫了。
高速緩存的引入解決了CPU和內存之間速度的矛盾,可是在多CPU系統中也帶來了新的問題:緩存一致性。在多CPU系統中,每一個CPU都有本身的高速緩存,全部的CPU又共享同一個主內存。若是多個CPU的運算任務都涉及到主內存中同一個變量時,那同步回主內存時以哪一個CPU的緩存數據爲準呢?這就須要各個CPU在數據讀寫時都遵循同一個協議進行操做。
緩存
參考上圖,假設有兩個線程A、B分別在兩個不一樣的CPU上運行,它們共享同一個變量X。若是線程A對X進行修改後,並無將X更新後的結果同步到主內存,則變量X的修改對B線程是不可見的。因此CPU與內存之間的高速緩存就是致使線程可見性問題的一個緣由。
CPU和主內存之間的高速緩存還會致使另外一個問題——重排序。假設A、B兩個線程共享兩個變量X、Y,A和B分別在不一樣的CPU上運行。在A中先更改變量X的值,而後再更改變量Y的值。這時有可能發生Y的值被同步回主內存,而X的值沒有同步回主內存的狀況,此時對於B線程來講是沒法感知到X變量被修改的,或者能夠認爲對於B線程來講,Y變量的修改被重排序到了X變量修改的前面。上面的程序NoVisibility類中有可能輸出0就是這種狀況,雖然在主線程中是先修改number變量,再修改ready變量,但對於讀線程來講,ready變量的修改有可能被重排序到number變量修改以前。
此外,爲了提升程序的執行效率,編譯器在生成指令序列時和CPU執行指令序列時,都有可能對指令進行重排序。Java語言規範要求JVM只在單個線程內部維護一種相似串行的語義,即只要程序的最終結果與嚴格串行環境中執行的結果相同便可。因此在單線程環境中,咱們沒法察覺到重排序,由於程序重排序後的執行結果與嚴格按順序執行的結果相同。就像在類NoVisibility的主線程中,先修改ready變量仍是先修改number變量對於主線程本身的執行結果是沒有影響的,可是若是number變量和ready變量的修改發生重排序,對讀線程是有影響的。因此在編寫併發程序時,咱們必定要注意重排序對多線程執行結果的影響。
看到這裏你們必定會發現,咱們所討論的CPU高速緩存、指令重排序等內容都是計算機體系結構方面的東西,並非Java語言所特有的。事實上,不少主流程序語言(如C/C++)都存在多線程可見性的問題,這些語言是藉助物理硬件和操做系統的內存模型來處理多線程可見性問題的,所以不一樣平臺上內存模型的差別,會影響到程序的執行結果。Java虛擬機規範定義了本身的內存模型JMM(Java Memory Model)來屏蔽掉不一樣硬件和操做系統的內存模型差別,以實現讓Java程序在各類平臺下都能達到一致的內存訪問結果。因此對於Java程序員,無需瞭解底層硬件和操做系統內存模型的知識,只要關注Java本身的內存模型,就可以解決Java語言中的內存可見性問題了。安全
上面討論了Java中多線程共享變量的可見性問題及產生這種問題的緣由。下面咱們看一下如何解決這個問題,即當一個多線程共享變量被某個線程修改後,如何讓這個修改被須要讀取這個變量的線程感知到。
爲了方便程序員開發,將底層的煩瑣細節屏蔽掉,JMM定義了Happens-Before原則。只要咱們理解了Happens-Before原則,無需瞭解JVM底層的內存操做,就能夠解決在併發編程中遇到的變量可見性問題。
JVM定義的Happens-Before原則是一組偏序關係:對於兩個操做A和B,這兩個操做能夠在不一樣的線程中執行。若是A Happens-Before B,那麼能夠保證,當A操做執行完後,A操做的執行結果對B操做是可見的。
Happens-Before的規則包括:多線程
下面咱們將詳細講述這8條規則的具體內容。併發
在一個線程內部,按照程序代碼的書寫順序,書寫在前面的代碼操做Happens-Before書寫在後面的代碼操做。這時由於Java語言規範要求JVM在單個線程內部要維護相似嚴格串行的語義,若是多個操做之間有前後依賴關係,則不容許對這些操做進行重排序。app
對鎖M解鎖以前的全部操做Happens-Before對鎖M加鎖以後的全部操做。函數
class HappensBeforeLock { private int value = 0; public synchronized void setValue(int value) { this.value = value; } public synchronized int getValue() { return value; } }
上面這段代碼,setValue和getValue兩個方法共享同一個監視器鎖。假設setValue方法在線程A中執行,getValue方法在線程B中執行。setValue方法會先對value變量賦值,而後釋放鎖。getValue方法會先獲取到同一個鎖後,再讀取value的值。因此根據鎖定原則,線程A中對value變量的修改,能夠被線程B感知到。
若是這個兩個方法上沒有synchronized聲明,則在線程A中執行setValue方法對value賦值後,線程B中getValue方法返回的value值並不能保證是最新值。
本條鎖定規則對顯示鎖(ReentrantLock)和內置鎖(synchronized)在加鎖和解鎖等操做上有着相同的內存語義。
對於鎖定原則,能夠像下面這樣去理解:同一時刻只能有一個線程執行鎖中的操做,因此鎖中的操做被重排序外界是不關心的,只要最終結果能被外界感知到就好。除了重排序,剩下影響變量可見性的就是CPU緩存了。在鎖被釋放時,A線程會把釋放鎖以前全部的操做結果同步到主內存中,而在獲取鎖時,B線程會使本身CPU的緩存失效,從新從主內存中讀取變量的值。這樣,A線程中的操做結果就會被B線程感知到了。性能
對一個volatile變量的寫操做及這個寫操做以前的全部操做Happens-Before對這個變量的讀操做及這個讀操做以後的全部操做。
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();
上面這段代碼,讀取配置文件的操做和使用配置信息的操做分別在兩個不一樣的線程A、B中執行,兩個線程經過共享變量configOptions傳遞配置信息,並經過共享變量initialized做爲初始化是否完成的通知。initialized變量被聲明爲volatile類型的,根據volatile變量規則,volatile變量的寫入操做Happens-Before對這個變量的讀操做,因此在線程A中將變量initialized設爲true,線程B中是能夠感知到這個修改操做的。
可是更牛逼的是,volatile變量不只能夠保證本身的變量可見性,還能保證書寫在volatile變量寫操做以前的操做對其它線程的可見性。考慮這樣一種狀況,若是volatile變量僅能保證本身的變量可見性,那麼當線程B感知到initialized已經變成true而後執行doSomethingWithConfig操做時,可能沒法獲取到configOptions最新值而致使操做結果錯誤。因此volatile變量不只能夠保證本身的變量可見性,還能保證書寫在volatile變量寫操做以前的操做Happens-Before書寫在volatile變量讀操做以後的那些操做。
能夠這樣理解volatile變量的寫入和讀取操做流程:
首先,volatile變量的操做會禁止與其它普通變量的操做進行重排序,例如上面代碼中會禁止initialized = true與它上面的兩行代碼進行重排序(可是它上面的代碼之間是能夠重排序的),不然會致使程序結果錯誤。volatile變量的寫操做就像是一條基準線,到達這條線以後,無論以前的代碼有沒有重排序,反正到達這條線以後,前面的操做都已完成並生成好結果。
而後,在volatile變量寫操做發生後,A線程會把volatile變量自己和書寫在它以前的那些操做的執行結果一塊兒同步到主內存中。
最後,當B線程讀取volatile變量時,B線程會使本身的CPU緩存失效,從新從主內存讀取所需變量的值,這樣不管是volatile自己,仍是書寫在volatile變量寫操做以前的那些操做結果,都能讓B線程感知到,也就是上面程序中的initialized和configOptions變量的最新值均可以讓線程B感知到。
原子變量與volatile變量在讀操做和寫操做上有着相同的語義。
Thread對象的start方法及書寫在start方法前面的代碼操做Happens-Before此線程的每個動做。
start方法和新線程中的動做必定是在兩個不一樣的線程中執行。線程啓動規則能夠這樣去理解:調用start方法時,會將start方法以前全部操做的結果同步到主內存中,新線程建立好後,須要從主內存獲取數據。這樣在start方法調用以前的全部操做結果對於新建立的線程都是可見的。
線程中的任何操做都Happens-Before其它線程檢測到該線程已經結束。這個說法有些抽象,下面舉例子對其進行說明。
假設兩個線程s、t。在線程s中調用t.join()方法。則線程s會被掛起,等待t線程運行結束才能恢復執行。當t.join()成功返回時,s線程就知道t線程已經結束了。因此根據本條原則,在t線程中對共享變量的修改,對s線程都是可見的。相似的還有Thread.isAlive方法也能夠檢測到一個線程是否結束。
能夠猜想,當一個線程結束時,會把本身全部操做的結果都同步到主內存。而任何其它線程當發現這個線程已經執行結束了,就會從主內存中從新刷新最新的變量值。因此結束的線程A對共享變量的修改,對於其它檢測了A線程是否結束的線程是可見的。
一個線程在另外一個線程上調用interrupt,Happens-Before被中斷線程檢測到interrupt被調用。
假設兩個線程A和B,A先作了一些操做operationA,而後調用B線程的interrupt方法。當B線程感知到本身的中斷標識被設置時(經過拋出InterruptedException,或調用interrupted和isInterrupted),operationA中的操做結果對B都是可見的。
一個對象的構造函數執行結束Happens-Before它的finalize()方法的開始。
「結束」和「開始」代表在時間上,一個對象的構造函數必須在它的finalize()方法調用時執行完。
根據這條原則,能夠確保在對象的finalize方法執行時,該對象的全部field字段值都是可見的。
若是操做A Happens-Before B,B Happens-Before C,那麼能夠得出操做A Happens-Before C。
到這裏咱們已經討論了線程的可見性問題和致使這個問題的緣由,並詳細闡述了8條Happens-Before原則和它們是如何幫助咱們解決變量可見性問題的。下面咱們在深刻思考一下,Happens-Before原則究竟是如何解決變量間可見性問題的。
咱們已經知道,致使多線程間可見性問題的兩個「罪魁禍首」是CPU緩存和重排序。那麼若是要保證多個線程間共享的變量對每一個線程都及時可見,一種極端的作法就是禁止使用全部的重排序和CPU緩存。即關閉全部的編譯器、操做系統和處理器的優化,全部指令順序所有按照程序代碼書寫的順序執行。去掉CPU高速緩存,讓CPU的每次讀寫操做都直接與主存交互。
固然,上面的這種極端方案是絕對不可取的,由於這會極大影響處理器的計算性能,而且對於那些非多線程共享的變量是不公平的。
重排序和CPU高速緩存有利於計算機性能的提升,但卻對多CPU處理的一致性帶來了影響。爲了解決這個矛盾,咱們能夠採起一種折中的辦法。咱們用分割線把整個程序劃分紅幾個程序塊,在每一個程序塊內部的指令是能夠重排序的,可是分割線上的指令與程序塊的其它指令之間是不能夠重排序的。在一個程序塊內部,CPU不用每次都與主內存進行交互,只須要在CPU緩存中執行讀寫操做便可,可是當程序執行到分割線處,CPU必須將執行結果同步到主內存或從主內存讀取最新的變量值。那麼,Happens-Before規則就是定義了這些程序塊的分割線。下圖展現了一個使用鎖定原則做爲分割線的例子:
如圖所示,這裏的unlock M和lock M就是劃分程序的分割線。在這裏,紅色區域和綠色區域的代碼內部是能夠進行重排序的,可是unlock和lock操做是不能與它們進行重排序的。即第一個圖中的紅色部分必需要在unlock M指令以前所有執行完,第二個圖中的綠色部分必須所有在lock M指令以後執行。而且在第一個圖中的unlock M指令處,紅色部分的執行結果要所有刷新到主存中,在第二個圖中的lock M指令處,綠色部分用到的變量都要從主存中從新讀取。
在程序中加入分割線將其劃分紅多個程序塊,雖然在程序塊內部代碼仍然可能被重排序,可是保證了程序代碼在宏觀上是有序的。而且能夠確保在分割線處,CPU必定會和主內存進行交互。Happens-Before原則就是定義了程序中什麼樣的代碼能夠做爲分隔線。而且不管是哪條Happens-Before原則,它們所產生分割線的做用都是相同的。
在寫做本文時,我主要參考的是《Java併發編程實戰》和《深刻理解Java虛擬機》的最後一章,此外有部份內容是我本身對併發編程的一些淺薄理解,但願可以對閱讀的人有所幫助。若有錯誤的地方,歡迎你們指正。