从Java多线程可见性谈Happens-Before原则
Happens-Before是一個非常抽象的概念,然而它又是學(xué)習(xí)Java并發(fā)編程不可跨域的部分。本文會先闡述Happens-Before在并發(fā)編程中解決的問題——多線程可見性,然后再詳細(xì)講解Happens-Before原則本身。
Java多線程可見性
在現(xiàn)代操作系統(tǒng)上編寫并發(fā)程序時,除了要注意線程安全性(多個線程互斥訪問臨界資源)以外,還要注意多線程對共享變量的可見性,而后者往往容易被人忽略。
可見性是指當(dāng)一個線程修改了共享變量的值,其它線程能夠適時得知這個修改。在單線程環(huán)境中,如果在程序前面修改了某個變量的值,后面的程序一定會讀取到那個變量的新值。這看起來很自然,然而當(dāng)變量的寫操作和讀操作在不同的線程中時,情況卻并非如此。
上面的代碼中,主線程和讀線程都訪問共享變量ready和number。程序看起來會輸出42,但事實上很可能會輸出0,或者根本無法終止。這是因為上面的程序缺少線程間變量可見性的保證,所以在主線程中寫入的變量值,可能無法被讀線程感知到。
為什么會出現(xiàn)線程可見性問題
要想解釋為什么會出現(xiàn)線程可見性問題,需要從計算機處理器結(jié)構(gòu)談起。我們都知道計算機運算任務(wù)需要CPU和內(nèi)存相互配合共同完成,其中CPU負(fù)責(zé)邏輯計算,內(nèi)存負(fù)責(zé)數(shù)據(jù)存儲。CPU要與內(nèi)存進行交互,如讀取運算數(shù)據(jù)、存儲運算結(jié)果等。由于內(nèi)存和CPU的計算速度有幾個數(shù)量級的差距,為了提高CPU的利用率,現(xiàn)代處理器結(jié)構(gòu)都加入了一層讀寫速度盡可能接近CPU運算速度的高速緩存來作為內(nèi)存與CPU之間的緩沖:將運算需要使用的數(shù)據(jù)復(fù)制到緩存中,讓CPU運算可以快速進行,計算結(jié)束后再將計算結(jié)果從緩存同步到主內(nèi)存中,這樣處理器就無須等待緩慢的內(nèi)存讀寫了。
高速緩存的引入解決了CPU和內(nèi)存之間速度的矛盾,但是在多CPU系統(tǒng)中也帶來了新的問題:緩存一致性。在多CPU系統(tǒng)中,每個CPU都有自己的高速緩存,所有的CPU又共享同一個主內(nèi)存。如果多個CPU的運算任務(wù)都涉及到主內(nèi)存中同一個變量時,那同步回主內(nèi)存時以哪個CPU的緩存數(shù)據(jù)為準(zhǔn)呢?這就需要各個CPU在數(shù)據(jù)讀寫時都遵循同一個協(xié)議進行操作。
參考上圖,假設(shè)有兩個線程A、B分別在兩個不同的CPU上運行,它們共享同一個變量X。如果線程A對X進行修改后,并沒有將X更新后的結(jié)果同步到主內(nèi)存,則變量X的修改對B線程是不可見的。所以CPU與內(nèi)存之間的高速緩存就是導(dǎo)致線程可見性問題的一個原因。
CPU和主內(nèi)存之間的高速緩存還會導(dǎo)致另一個問題——重排序。假設(shè)A、B兩個線程共享兩個變量X、Y,A和B分別在不同的CPU上運行。在A中先更改變量X的值,然后再更改變量Y的值。這時有可能發(fā)生Y的值被同步回主內(nèi)存,而X的值沒有同步回主內(nèi)存的情況,此時對于B線程來說是無法感知到X變量被修改的,或者可以認(rèn)為對于B線程來說,Y變量的修改被重排序到了X變量修改的前面。上面的程序NoVisibility類中有可能輸出0就是這種情況,雖然在主線程中是先修改number變量,再修改ready變量,但對于讀線程來說,ready變量的修改有可能被重排序到number變量修改之前。
此外,為了提高程序的執(zhí)行效率,編譯器在生成指令序列時和CPU執(zhí)行指令序列時,都有可能對指令進行重排序。Java語言規(guī)范要求JVM只在單個線程內(nèi)部維護一種類似串行的語義,即只要程序的最終結(jié)果與嚴(yán)格串行環(huán)境中執(zhí)行的結(jié)果相同即可。所以在單線程環(huán)境中,我們無法察覺到重排序,因為程序重排序后的執(zhí)行結(jié)果與嚴(yán)格按順序執(zhí)行的結(jié)果相同。就像在類NoVisibility的主線程中,先修改ready變量還是先修改number變量對于主線程自己的執(zhí)行結(jié)果是沒有影響的,但是如果number變量和ready變量的修改發(fā)生重排序,對讀線程是有影響的。所以在編寫并發(fā)程序時,我們一定要注意重排序?qū)Χ嗑€程執(zhí)行結(jié)果的影響。
看到這里大家一定會發(fā)現(xiàn),我們所討論的CPU高速緩存、指令重排序等內(nèi)容都是計算機體系結(jié)構(gòu)方面的東西,并不是Java語言所特有的。事實上,很多主流程序語言(如C/C++)都存在多線程可見性的問題,這些語言是借助物理硬件和操作系統(tǒng)的內(nèi)存模型來處理多線程可見性問題的,因此不同平臺上內(nèi)存模型的差異,會影響到程序的執(zhí)行結(jié)果。Java虛擬機規(guī)范定義了自己的內(nèi)存模型JMM(Java Memory Model)來屏蔽掉不同硬件和操作系統(tǒng)的內(nèi)存模型差異,以實現(xiàn)讓Java程序在各種平臺下都能達(dá)到一致的內(nèi)存訪問結(jié)果。所以對于Java程序員,無需了解底層硬件和操作系統(tǒng)內(nèi)存模型的知識,只要關(guān)注Java自己的內(nèi)存模型,就能夠解決Java語言中的內(nèi)存可見性問題了。
Happens-Before原則
上面討論了Java中多線程共享變量的可見性問題及產(chǎn)生這種問題的原因。下面我們看一下如何解決這個問題,即當(dāng)一個多線程共享變量被某個線程修改后,如何讓這個修改被需要讀取這個變量的線程感知到。
為了方便程序員開發(fā),將底層的煩瑣細(xì)節(jié)屏蔽掉,JMM定義了Happens-Before原則。只要我們理解了Happens-Before原則,無需了解JVM底層的內(nèi)存操作,就可以解決在并發(fā)編程中遇到的變量可見性問題。
JVM定義的Happens-Before原則是一組偏序關(guān)系:對于兩個操作A和B,這兩個操作可以在不同的線程中執(zhí)行。如果A Happens-Before B,那么可以保證,當(dāng)A操作執(zhí)行完后,A操作的執(zhí)行結(jié)果對B操作是可見的。
Happens-Before的規(guī)則包括:
下面我們將詳細(xì)講述這8條規(guī)則的具體內(nèi)容。
程序順序規(guī)則
在一個線程內(nèi)部,按照程序代碼的書寫順序,書寫在前面的代碼操作Happens-Before書寫在后面的代碼操作。這時因為Java語言規(guī)范要求JVM在單個線程內(nèi)部要維護類似嚴(yán)格串行的語義,如果多個操作之間有先后依賴關(guān)系,則不允許對這些操作進行重排序。
鎖定規(guī)則
對鎖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兩個方法共享同一個監(jiān)視器鎖。假設(shè)setValue方法在線程A中執(zhí)行,getValue方法在線程B中執(zhí)行。setValue方法會先對value變量賦值,然后釋放鎖。getValue方法會先獲取到同一個鎖后,再讀取value的值。所以根據(jù)鎖定原則,線程A中對value變量的修改,可以被線程B感知到。
如果這個兩個方法上沒有synchronized聲明,則在線程A中執(zhí)行setValue方法對value賦值后,線程B中g(shù)etValue方法返回的value值并不能保證是最新值。
本條鎖定規(guī)則對顯示鎖(ReentrantLock)和內(nèi)置鎖(synchronized)在加鎖和解鎖等操作上有著相同的內(nèi)存語義。
對于鎖定原則,可以像下面這樣去理解:同一時刻只能有一個線程執(zhí)行鎖中的操作,所以鎖中的操作被重排序外界是不關(guān)心的,只要最終結(jié)果能被外界感知到就好。除了重排序,剩下影響變量可見性的就是CPU緩存了。在鎖被釋放時,A線程會把釋放鎖之前所有的操作結(jié)果同步到主內(nèi)存中,而在獲取鎖時,B線程會使自己CPU的緩存失效,重新從主內(nèi)存中讀取變量的值。這樣,A線程中的操作結(jié)果就會被B線程感知到了。
volatile變量規(guī)則
對一個volatile變量的寫操作及這個寫操作之前的所有操作Happens-Before對這個變量的讀操作及這個讀操作之后的所有操作。
Map configOptions; char[] configText; //線程間共享變量,用于保存配置信息 // 此變量必須定義為volatile volatile boolean initialized = false;// 假設(shè)以下代碼在線程A中執(zhí)行 // 模擬讀取配置信息,當(dāng)讀取完成后將initialized設(shè)置為true以通知其他線程配置可用configOptions = new HashMap(); configText = readConfigFile(fileName); processConfigOptions(configText, configOptions); initialized = true;// 假設(shè)以下代碼在線程B中執(zhí)行 // 等待initialized為true,代表線程A已經(jīng)把配置信息初始化完成 while (!initialized) { sleep(); } //使用線程A中初始化好的配置信息 doSomethingWithConfig();上面這段代碼,讀取配置文件的操作和使用配置信息的操作分別在兩個不同的線程A、B中執(zhí)行,兩個線程通過共享變量configOptions傳遞配置信息,并通過共享變量initialized作為初始化是否完成的通知。initialized變量被聲明為volatile類型的,根據(jù)volatile變量規(guī)則,volatile變量的寫入操作Happens-Before對這個變量的讀操作,所以在線程A中將變量initialized設(shè)為true,線程B中是可以感知到這個修改操作的。
但是更牛逼的是,volatile變量不僅可以保證自己的變量可見性,還能保證書寫在volatile變量寫操作之前的操作對其它線程的可見性。考慮這樣一種情況,如果volatile變量僅能保證自己的變量可見性,那么當(dāng)線程B感知到initialized已經(jīng)變成true然后執(zhí)行doSomethingWithConfig操作時,可能無法獲取到configOptions最新值而導(dǎo)致操作結(jié)果錯誤。所以volatile變量不僅可以保證自己的變量可見性,還能保證書寫在volatile變量寫操作之前的操作Happens-Before書寫在volatile變量讀操作之后的那些操作。
可以這樣理解volatile變量的寫入和讀取操作流程:
首先,volatile變量的操作會禁止與其它普通變量的操作進行重排序,例如上面代碼中會禁止initialized = true與它上面的兩行代碼進行重排序(但是它上面的代碼之間是可以重排序的),否則會導(dǎo)致程序結(jié)果錯誤。volatile變量的寫操作就像是一條基準(zhǔn)線,到達(dá)這條線之后,不管之前的代碼有沒有重排序,反正到達(dá)這條線之后,前面的操作都已完成并生成好結(jié)果。
然后,在volatile變量寫操作發(fā)生后,A線程會把volatile變量本身和書寫在它之前的那些操作的執(zhí)行結(jié)果一起同步到主內(nèi)存中。
最后,當(dāng)B線程讀取volatile變量時,B線程會使自己的CPU緩存失效,重新從主內(nèi)存讀取所需變量的值,這樣無論是volatile本身,還是書寫在volatile變量寫操作之前的那些操作結(jié)果,都能讓B線程感知到,也就是上面程序中的initialized和configOptions變量的最新值都可以讓線程B感知到。
原子變量與volatile變量在讀操作和寫操作上有著相同的語義。
線程啟動規(guī)則
Thread對象的start方法及書寫在start方法前面的代碼操作Happens-Before此線程的每一個動作。
start方法和新線程中的動作一定是在兩個不同的線程中執(zhí)行。線程啟動規(guī)則可以這樣去理解:調(diào)用start方法時,會將start方法之前所有操作的結(jié)果同步到主內(nèi)存中,新線程創(chuàng)建好后,需要從主內(nèi)存獲取數(shù)據(jù)。這樣在start方法調(diào)用之前的所有操作結(jié)果對于新創(chuàng)建的線程都是可見的。
線程終止規(guī)則
線程中的任何操作都Happens-Before其它線程檢測到該線程已經(jīng)結(jié)束。這個說法有些抽象,下面舉例子對其進行說明。
假設(shè)兩個線程s、t。在線程s中調(diào)用t.join()方法。則線程s會被掛起,等待t線程運行結(jié)束才能恢復(fù)執(zhí)行。當(dāng)t.join()成功返回時,s線程就知道t線程已經(jīng)結(jié)束了。所以根據(jù)本條原則,在t線程中對共享變量的修改,對s線程都是可見的。類似的還有Thread.isAlive方法也可以檢測到一個線程是否結(jié)束。
可以猜測,當(dāng)一個線程結(jié)束時,會把自己所有操作的結(jié)果都同步到主內(nèi)存。而任何其它線程當(dāng)發(fā)現(xiàn)這個線程已經(jīng)執(zhí)行結(jié)束了,就會從主內(nèi)存中重新刷新最新的變量值。所以結(jié)束的線程A對共享變量的修改,對于其它檢測了A線程是否結(jié)束的線程是可見的。
中斷規(guī)則
一個線程在另一個線程上調(diào)用interrupt,Happens-Before被中斷線程檢測到interrupt被調(diào)用。
假設(shè)兩個線程A和B,A先做了一些操作operationA,然后調(diào)用B線程的interrupt方法。當(dāng)B線程感知到自己的中斷標(biāo)識被設(shè)置時(通過拋出InterruptedException,或調(diào)用interrupted和isInterrupted),operationA中的操作結(jié)果對B都是可見的。
終結(jié)器規(guī)則
一個對象的構(gòu)造函數(shù)執(zhí)行結(jié)束Happens-Before它的finalize()方法的開始。
“結(jié)束”和“開始”表明在時間上,一個對象的構(gòu)造函數(shù)必須在它的finalize()方法調(diào)用時執(zhí)行完。
根據(jù)這條原則,可以確保在對象的finalize方法執(zhí)行時,該對象的所有field字段值都是可見的。
傳遞性規(guī)則
如果操作A Happens-Before B,B Happens-Before C,那么可以得出操作A Happens-Before C。
再次思考Happens-Before規(guī)則的真正意義
到這里我們已經(jīng)討論了線程的可見性問題和導(dǎo)致這個問題的原因,并詳細(xì)闡述了8條Happens-Before原則和它們是如何幫助我們解決變量可見性問題的。下面我們在深入思考一下,Happens-Before原則到底是如何解決變量間可見性問題的。
我們已經(jīng)知道,導(dǎo)致多線程間可見性問題的兩個“罪魁禍?zhǔn)住笔荂PU緩存和重排序。那么如果要保證多個線程間共享的變量對每個線程都及時可見,一種極端的做法就是禁止使用所有的重排序和CPU緩存。即關(guān)閉所有的編譯器、操作系統(tǒng)和處理器的優(yōu)化,所有指令順序全部按照程序代碼書寫的順序執(zhí)行。去掉CPU高速緩存,讓CPU的每次讀寫操作都直接與主存交互。
當(dāng)然,上面的這種極端方案是絕對不可取的,因為這會極大影響處理器的計算性能,并且對于那些非多線程共享的變量是不公平的。
重排序和CPU高速緩存有利于計算機性能的提高,但卻對多CPU處理的一致性帶來了影響。為了解決這個矛盾,我們可以采取一種折中的辦法。我們用分割線把整個程序劃分成幾個程序塊,在每個程序塊內(nèi)部的指令是可以重排序的,但是分割線上的指令與程序塊的其它指令之間是不可以重排序的。在一個程序塊內(nèi)部,CPU不用每次都與主內(nèi)存進行交互,只需要在CPU緩存中執(zhí)行讀寫操作即可,但是當(dāng)程序執(zhí)行到分割線處,CPU必須將執(zhí)行結(jié)果同步到主內(nèi)存或從主內(nèi)存讀取最新的變量值。那么,Happens-Before規(guī)則就是定義了這些程序塊的分割線。下圖展示了一個使用鎖定原則作為分割線的例子:
如圖所示,這里的unlock M和lock M就是劃分程序的分割線。在這里,紅色區(qū)域和綠色區(qū)域的代碼內(nèi)部是可以進行重排序的,但是unlock和lock操作是不能與它們進行重排序的。即第一個圖中的紅色部分必須要在unlock M指令之前全部執(zhí)行完,第二個圖中的綠色部分必須全部在lock M指令之后執(zhí)行。并且在第一個圖中的unlock M指令處,紅色部分的執(zhí)行結(jié)果要全部刷新到主存中,在第二個圖中的lock M指令處,綠色部分用到的變量都要從主存中重新讀取。
在程序中加入分割線將其劃分成多個程序塊,雖然在程序塊內(nèi)部代碼仍然可能被重排序,但是保證了程序代碼在宏觀上是有序的。并且可以確保在分割線處,CPU一定會和主內(nèi)存進行交互。Happens-Before原則就是定義了程序中什么樣的代碼可以作為分隔線。并且無論是哪條Happens-Before原則,它們所產(chǎn)生分割線的作用都是相同的。
小結(jié)
在寫作本文時,我主要參考的是《Java并發(fā)編程實戰(zhàn)》和《深入理解Java虛擬機》的最后一章,此外有部分內(nèi)容是我自己對并發(fā)編程的一些淺薄理解,希望能夠?qū)﹂喿x的人有所幫助。如有錯誤的地方,歡迎大家指正。
from:?https://segmentfault.com/a/1190000011458941
總結(jié)
以上是生活随笔為你收集整理的从Java多线程可见性谈Happens-Before原则的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: java 8大happen-before
- 下一篇: 基于当前分支的某一个commit号创建分