摘要:本文會先闡述在并發編程中解決的問題多線程可見性,然后再詳細講解原則本身。所以與內存之間的高速緩存就是導致線程可見性問題的一個原因。原則上面討論了中多線程共享變量的可見性問題及產生這種問題的原因。
Happens-Before是一個非常抽象的概念,然而它又是學習Java并發編程不可跨域的部分。本文會先闡述Happens-Before在并發編程中解決的問題——多線程可見性,然后再詳細講解Happens-Before原則本身。
Java多線程可見性在現代操作系統上編寫并發程序時,除了要注意線程安全性(多個線程互斥訪問臨界資源)以外,還要注意多線程對共享變量的可見性,而后者往往容易被人忽略。
可見性是指當一個線程修改了共享變量的值,其它線程能夠適時得知這個修改。在單線程環境中,如果在程序前面修改了某個變量的值,后面的程序一定會讀取到那個變量的新值。這看起來很自然,然而當變量的寫操作和讀操作在不同的線程中時,情況卻并非如此。
/** *《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的規則包括:
程序順序規則
鎖定規則
volatile變量規則
線程啟動規則
線程結束規則
中斷規則
終結器規則
傳遞性規則
下面我們將詳細講述這8條規則的具體內容。
程序順序規則在一個線程內部,按照程序代碼的書寫順序,書寫在前面的代碼操作Happens-Before書寫在后面的代碼操作。這時因為Java語言規范要求JVM在單個線程內部要維護類似嚴格串行的語義,如果多個操作之間有先后依賴關系,則不允許對這些操作進行重排序。
鎖定規則對鎖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。
再次思考Happens-Before規則的真正意義到這里我們已經討論了線程的可見性問題和導致這個問題的原因,并詳細闡述了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虛擬機》的最后一章,此外有部分內容是我自己對并發編程的一些淺薄理解,希望能夠對閱讀的人有所幫助。如有錯誤的地方,歡迎大家指正。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/67705.html
摘要:在構建一個對象的過程中,更要考慮到多線程間共享數據的一致性問題,否則很可能會發生一個在線程中構建完整的對象,在線程中看到的卻只被構建了一部分。例如下面的代碼上面的代碼本意是想實現一個單例模式,但在多線程環境下,這個單例模式將很容易被打破。 在上一篇文章《從Java多線程可見性談Happens-Before原則》中,我們詳細討論了在并發編程中Happens-Before原則對多線程共享變...
摘要:掌握的內存模型,你就是解決并發問題最靚的仔編譯優化說的具體一些,這些方法包括和關鍵字,以及內存模型中的規則。掌握的內存模型,你就是解決并發問題最靚的仔共享變量藍色的虛線箭頭代表禁用了緩存,黑色的實線箭頭代表直接從主內存中讀寫數據。 摘要:如果編寫的并發程序出現問題時,很難通過調試來解決相應的問題,此時,需要一行行的檢查代碼...
摘要:這個規則比較好理解,無論是在單線程環境還是多線程環境,一個鎖處于被鎖定狀態,那么必須先執行操作后面才能進行操作。線程啟動規則獨享的方法先行于此線程的每一個動作。 1. 指令重排序 關于指令重排序的概念,比較復雜,不好理解。我們從一個例子分析: public class SimpleHappenBefore { /** 這是一個驗證結果的變量 */ private st...
摘要:內存模型對內存模型的介紹對內存模型的結構圖的線程之間的通信是通過共享內存的方式進行隱式通信,即線程把某狀態寫入主內存中的共享變量,線程讀取的值,這樣就完成了通信。 Java內存模型(JMM) 1.對內存模型的介紹 ①對Java內存模型的結構圖 java的線程之間的通信是通過共享內存的方式進行隱式通信,即線程A把某狀態寫入主內存中的共享變量X,線程B讀取X的值,這樣就完成了通信。是一種...
摘要:并發設計的三大原則原子性原子性對共享變量的操作相對于其他線程是不可干擾的,即其他線程的執行只能在該原子操作完成后或開始前執行。發現兩個線程運行結束后的值為。這就是在多線程情況下要求程序執行的順序按照代碼的先后順序執行的原因之一。 并發設計的三大原則 原子性 原子性:對共享變量的操作相對于其他線程是不可干擾的,即其他線程的執行只能在該原子操作完成后或開始前執行。 通過一個小例子理解 pu...
閱讀 1859·2021-09-22 15:29
閱讀 3351·2019-08-30 15:44
閱讀 3558·2019-08-30 15:43
閱讀 1763·2019-08-30 13:48
閱讀 1489·2019-08-29 13:56
閱讀 2474·2019-08-29 12:12
閱讀 965·2019-08-26 11:35
閱讀 1052·2019-08-26 10:25