摘要:本文會先闡述在并發(fā)編程中解決的問題多線程可見性,然后再詳細(xì)講解原則本身。所以與內(nèi)存之間的高速緩存就是導(dǎo)致線程可見性問題的一個原因。原則上面討論了中多線程共享變量的可見性問題及產(chǎn)生這種問題的原因。
Happens-Before是一個非常抽象的概念,然而它又是學(xué)習(xí)Java并發(fā)編程不可跨域的部分。本文會先闡述Happens-Before在并發(fā)編程中解決的問題——多線程可見性,然后再詳細(xì)講解Happens-Before原則本身。
Java多線程可見性在現(xiàn)代操作系統(tǒng)上編寫并發(fā)程序時,除了要注意線程安全性(多個線程互斥訪問臨界資源)以外,還要注意多線程對共享變量的可見性,而后者往往容易被人忽略。
可見性是指當(dāng)一個線程修改了共享變量的值,其它線程能夠適時得知這個修改。在單線程環(huán)境中,如果在程序前面修改了某個變量的值,后面的程序一定會讀取到那個變量的新值。這看起來很自然,然而當(dāng)變量的寫操作和讀操作在不同的線程中時,情況卻并非如此。
/** *《Java并發(fā)編程實(shí)戰(zhàn)》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,但事實(shí)上很可能會輸出0,或者根本無法終止。這是因?yàn)樯厦娴某绦蛉鄙?em>線程間變量可見性的保證,所以在主線程中寫入的變量值,可能無法被讀線程感知到。
為什么會出現(xiàn)線程可見性問題要想解釋為什么會出現(xiàn)線程可見性問題,需要從計(jì)算機(jī)處理器結(jié)構(gòu)談起。我們都知道計(jì)算機(jī)運(yùn)算任務(wù)需要CPU和內(nèi)存相互配合共同完成,其中CPU負(fù)責(zé)邏輯計(jì)算,內(nèi)存負(fù)責(zé)數(shù)據(jù)存儲。CPU要與內(nèi)存進(jìn)行交互,如讀取運(yùn)算數(shù)據(jù)、存儲運(yùn)算結(jié)果等。由于內(nèi)存和CPU的計(jì)算速度有幾個數(shù)量級的差距,為了提高CPU的利用率,現(xiàn)代處理器結(jié)構(gòu)都加入了一層讀寫速度盡可能接近CPU運(yùn)算速度的高速緩存來作為內(nèi)存與CPU之間的緩沖:將運(yùn)算需要使用的數(shù)據(jù)復(fù)制到緩存中,讓CPU運(yùn)算可以快速進(jìn)行,計(jì)算結(jié)束后再將計(jì)算結(jié)果從緩存同步到主內(nèi)存中,這樣處理器就無須等待緩慢的內(nèi)存讀寫了。
高速緩存的引入解決了CPU和內(nèi)存之間速度的矛盾,但是在多CPU系統(tǒng)中也帶來了新的問題:緩存一致性。在多CPU系統(tǒng)中,每個CPU都有自己的高速緩存,所有的CPU又共享同一個主內(nèi)存。如果多個CPU的運(yùn)算任務(wù)都涉及到主內(nèi)存中同一個變量時,那同步回主內(nèi)存時以哪個CPU的緩存數(shù)據(jù)為準(zhǔn)呢?這就需要各個CPU在數(shù)據(jù)讀寫時都遵循同一個協(xié)議進(jìn)行操作。
參考上圖,假設(shè)有兩個線程A、B分別在兩個不同的CPU上運(yùn)行,它們共享同一個變量X。如果線程A對X進(jìn)行修改后,并沒有將X更新后的結(jié)果同步到主內(nèi)存,則變量X的修改對B線程是不可見的。所以CPU與內(nèi)存之間的高速緩存就是導(dǎo)致線程可見性問題的一個原因。
CPU和主內(nèi)存之間的高速緩存還會導(dǎo)致另一個問題——重排序。假設(shè)A、B兩個線程共享兩個變量X、Y,A和B分別在不同的CPU上運(yùn)行。在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í)行指令序列時,都有可能對指令進(jìn)行重排序。Java語言規(guī)范要求JVM只在單個線程內(nèi)部維護(hù)一種類似串行的語義,即只要程序的最終結(jié)果與嚴(yán)格串行環(huán)境中執(zhí)行的結(jié)果相同即可。所以在單線程環(huán)境中,我們無法察覺到重排序,因?yà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)容都是計(jì)算機(jī)體系結(jié)構(gòu)方面的東西,并不是Java語言所特有的。事實(shí)上,很多主流程序語言(如C/C++)都存在多線程可見性的問題,這些語言是借助物理硬件和操作系統(tǒng)的內(nèi)存模型來處理多線程可見性問題的,因此不同平臺上內(nèi)存模型的差異,會影響到程序的執(zhí)行結(jié)果。Java虛擬機(jī)規(guī)范定義了自己的內(nèi)存模型JMM(Java Memory Model)來屏蔽掉不同硬件和操作系統(tǒng)的內(nèi)存模型差異,以實(shí)現(xiàn)讓Java程序在各種平臺下都能達(dá)到一致的內(nèi)存訪問結(jié)果。所以對于Java程序員,無需了解底層硬件和操作系統(tǒng)內(nèi)存模型的知識,只要關(guān)注Java自己的內(nèi)存模型,就能夠解決Java語言中的內(nèi)存可見性問題了。
上面討論了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ī)則包括:
程序順序規(guī)則
鎖定規(guī)則
volatile變量規(guī)則
線程啟動規(guī)則
線程結(jié)束規(guī)則
中斷規(guī)則
終結(jié)器規(guī)則
傳遞性規(guī)則
下面我們將詳細(xì)講述這8條規(guī)則的具體內(nèi)容。
程序順序規(guī)則在一個線程內(nèi)部,按照程序代碼的書寫順序,書寫在前面的代碼操作Happens-Before書寫在后面的代碼操作。這時因?yàn)?em>Java語言規(guī)范要求JVM在單個線程內(nèi)部要維護(hù)類似嚴(yán)格串行的語義,如果多個操作之間有先后依賴關(guān)系,則不允許對這些操作進(jì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變量的寫操作及這個寫操作之前的所有操作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變量的操作會禁止與其它普通變量的操作進(jìn)行重排序,例如上面代碼中會禁止initialized = true與它上面的兩行代碼進(jìn)行重排序(但是它上面的代碼之間是可以重排序的),否則會導(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變量在讀操作和寫操作上有著相同的語義。
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)建的線程都是可見的。
線程中的任何操作都Happens-Before其它線程檢測到該線程已經(jīng)結(jié)束。這個說法有些抽象,下面舉例子對其進(jìn)行說明。
假設(shè)兩個線程s、t。在線程s中調(diào)用t.join()方法。則線程s會被掛起,等待t線程運(yùn)行結(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é)束的線程是可見的。
一個線程在另一個線程上調(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都是可見的。
一個對象的構(gòu)造函數(shù)執(zhí)行結(jié)束Happens-Before它的finalize()方法的開始。
“結(jié)束”和“開始”表明在時間上,一個對象的構(gòu)造函數(shù)必須在它的finalize()方法調(diào)用時執(zhí)行完。
根據(jù)這條原則,可以確保在對象的finalize方法執(zhí)行時,該對象的所有field字段值都是可見的。
如果操作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)住笔?em>CPU緩存和重排序。那么如果要保證多個線程間共享的變量對每個線程都及時可見,一種極端的做法就是禁止使用所有的重排序和CPU緩存。即關(guān)閉所有的編譯器、操作系統(tǒng)和處理器的優(yōu)化,所有指令順序全部按照程序代碼書寫的順序執(zhí)行。去掉CPU高速緩存,讓CPU的每次讀寫操作都直接與主存交互。
當(dāng)然,上面的這種極端方案是絕對不可取的,因?yàn)檫@會極大影響處理器的計(jì)算性能,并且對于那些非多線程共享的變量是不公平的。
重排序和CPU高速緩存有利于計(jì)算機(jī)性能的提高,但卻對多CPU處理的一致性帶來了影響。為了解決這個矛盾,我們可以采取一種折中的辦法。我們用分割線把整個程序劃分成幾個程序塊,在每個程序塊內(nèi)部的指令是可以重排序的,但是分割線上的指令與程序塊的其它指令之間是不可以重排序的。在一個程序塊內(nèi)部,CPU不用每次都與主內(nèi)存進(jìn)行交互,只需要在CPU緩存中執(zhí)行讀寫操作即可,但是當(dāng)程序執(zhí)行到分割線處,CPU必須將執(zhí)行結(jié)果同步到主內(nèi)存或從主內(nèi)存讀取最新的變量值。那么,Happens-Before規(guī)則就是定義了這些程序塊的分割線。下圖展示了一個使用鎖定原則作為分割線的例子:
如圖所示,這里的unlock M和lock M就是劃分程序的分割線。在這里,紅色區(qū)域和綠色區(qū)域的代碼內(nèi)部是可以進(jìn)行重排序的,但是unlock和lock操作是不能與它們進(jìn)行重排序的。即第一個圖中的紅色部分必須要在unlock M指令之前全部執(zhí)行完,第二個圖中的綠色部分必須全部在lock M指令之后執(zhí)行。并且在第一個圖中的unlock M指令處,紅色部分的執(zhí)行結(jié)果要全部刷新到主存中,在第二個圖中的lock M指令處,綠色部分用到的變量都要從主存中重新讀取。
在程序中加入分割線將其劃分成多個程序塊,雖然在程序塊內(nèi)部代碼仍然可能被重排序,但是保證了程序代碼在宏觀上是有序的。并且可以確保在分割線處,CPU一定會和主內(nèi)存進(jìn)行交互。Happens-Before原則就是定義了程序中什么樣的代碼可以作為分隔線。并且無論是哪條Happens-Before原則,它們所產(chǎn)生分割線的作用都是相同的。
在寫作本文時,我主要參考的是《Java并發(fā)編程實(shí)戰(zhàn)》和《深入理解Java虛擬機(jī)》的最后一章,此外有部分內(nèi)容是我自己對并發(fā)編程的一些淺薄理解,希望能夠?qū)﹂喿x的人有所幫助。如有錯誤的地方,歡迎大家指正。
文章版權(quán)歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請注明本文地址:http://systransis.cn/yun/67705.html
摘要:在構(gòu)建一個對象的過程中,更要考慮到多線程間共享數(shù)據(jù)的一致性問題,否則很可能會發(fā)生一個在線程中構(gòu)建完整的對象,在線程中看到的卻只被構(gòu)建了一部分。例如下面的代碼上面的代碼本意是想實(shí)現(xiàn)一個單例模式,但在多線程環(huán)境下,這個單例模式將很容易被打破。 在上一篇文章《從Java多線程可見性談Happens-Before原則》中,我們詳細(xì)討論了在并發(fā)編程中Happens-Before原則對多線程共享變...
摘要:掌握的內(nèi)存模型,你就是解決并發(fā)問題最靚的仔編譯優(yōu)化說的具體一些,這些方法包括和關(guān)鍵字,以及內(nèi)存模型中的規(guī)則。掌握的內(nèi)存模型,你就是解決并發(fā)問題最靚的仔共享變量藍(lán)色的虛線箭頭代表禁用了緩存,黑色的實(shí)線箭頭代表直接從主內(nèi)存中讀寫數(shù)據(jù)。 摘要:如果編寫的并發(fā)程序出現(xiàn)問題時,很難通過調(diào)試來解決相應(yīng)的問題,此時,需要一行行的檢查代碼...
摘要:這個規(guī)則比較好理解,無論是在單線程環(huán)境還是多線程環(huán)境,一個鎖處于被鎖定狀態(tài),那么必須先執(zhí)行操作后面才能進(jìn)行操作。線程啟動規(guī)則獨(dú)享的方法先行于此線程的每一個動作。 1. 指令重排序 關(guān)于指令重排序的概念,比較復(fù)雜,不好理解。我們從一個例子分析: public class SimpleHappenBefore { /** 這是一個驗(yàn)證結(jié)果的變量 */ private st...
摘要:內(nèi)存模型對內(nèi)存模型的介紹對內(nèi)存模型的結(jié)構(gòu)圖的線程之間的通信是通過共享內(nèi)存的方式進(jìn)行隱式通信,即線程把某狀態(tài)寫入主內(nèi)存中的共享變量,線程讀取的值,這樣就完成了通信。 Java內(nèi)存模型(JMM) 1.對內(nèi)存模型的介紹 ①對Java內(nèi)存模型的結(jié)構(gòu)圖 java的線程之間的通信是通過共享內(nèi)存的方式進(jìn)行隱式通信,即線程A把某狀態(tài)寫入主內(nèi)存中的共享變量X,線程B讀取X的值,這樣就完成了通信。是一種...
摘要:并發(fā)設(shè)計(jì)的三大原則原子性原子性對共享變量的操作相對于其他線程是不可干擾的,即其他線程的執(zhí)行只能在該原子操作完成后或開始前執(zhí)行。發(fā)現(xiàn)兩個線程運(yùn)行結(jié)束后的值為。這就是在多線程情況下要求程序執(zhí)行的順序按照代碼的先后順序執(zhí)行的原因之一。 并發(fā)設(shè)計(jì)的三大原則 原子性 原子性:對共享變量的操作相對于其他線程是不可干擾的,即其他線程的執(zhí)行只能在該原子操作完成后或開始前執(zhí)行。 通過一個小例子理解 pu...
閱讀 1884·2021-09-22 15:29
閱讀 3361·2019-08-30 15:44
閱讀 3570·2019-08-30 15:43
閱讀 1769·2019-08-30 13:48
閱讀 1497·2019-08-29 13:56
閱讀 2483·2019-08-29 12:12
閱讀 976·2019-08-26 11:35
閱讀 1059·2019-08-26 10:25