從零開始瞭解多執行緒知識之開始篇目 -- jvm&volatile

寧劍文發表於2020-11-28

本文章主要介紹到到了一些CPU快取一致性協議的基礎知識,由此引出的多執行緒知識,同時談到了多執行緒中資料操作 原子性 可見性 有序性 的問題
從執行緒的基本概念到多執行緒下工作的資料安全問題,主要談到了java知識中volatile關鍵字,使用例項的模式講解了 volatile可見性,有序性,指令重排的問題
接下來大家一起來學習學習吧

CPU多核快取儲存結構圖

1.電腦儲存結構概念

多CPU

     一個現代計算機通常由兩個或者多個CPU,如果要執行多個程式(程式)的話,假如只有 一個CPU的話,就意味著要經常進行程式上下文切換
     因為單CPU即便是多核的,也只是多個 處理器核心,其他裝置都是共用的,所以多個程式就必然要經常進行程式上下文切換,這個代價是很高的。 

CPU多核

    一個現代CPU除了處理器核心之外還包括暫存器、L1L2L3快取這些儲存裝置、浮點運算 單元、整數運算單元等一些輔助運算裝置以及內部匯流排等。
    一個多核的CPU也就是一個CPU上 有多個處理器核心,這樣有什麼好處呢?比如說現在我們要在一臺計算機上跑一個多執行緒的程式
    因為是一個程式裡的執行緒,所以需要一些共享一些儲存變數,如果這臺計算機都是單核單執行緒CPU的話,就意味著這個程式的不同執行緒需要經常在CPU之間的外部匯流排上通訊,
    同時還 要處理不同CPU之間不同快取導致資料不一致的問題,所以在這種場景下多核單CPU的架構就 能發揮很大的優勢,通訊都在內部匯流排,共用同一個快取。 

CPU暫存器

    每個CPU都包含一系列的暫存器,它們是CPU內記憶體的基礎。CPU在暫存器上執行操作的速度遠大於在主存上執行的速度。
    這是因為CPU訪問暫存器的速度遠大於主存。

CPU快取

    即高速緩衝儲存器,是位於CPU與主記憶體間的一種容量較小但速度很高的儲存器。
    由於CPU的速度遠高於主記憶體,CPU直接從記憶體中存取資料要等待一定時間週期,因此出現了CPU快取
    Cache中儲存著CPU剛用過或迴圈使用的一部分資料,當CPU再次使用該部分資料時可從Cache中直接呼叫, 減少CPU的等待時間,提高了系統的效率。 
    CPU快取包括 一級Cache(L1 Cache) 二級Cache(L2 Cache) 三級Cache(L3 Cache) 

記憶體

    一個計算機還包含一個主存。
    所有的CPU都可以訪問主存。主存通常比CPU中的快取大得多。 

CPU讀取儲存器資料過程

    CPU要取暫存器XX的值,只需要一步:直接讀取。 
    CPU要取L1 cache的某個值,需要1-3步(或者更多):把cache行鎖住,把某個資料拿 來,解鎖,如果沒鎖住就慢了。
    CPU要取L2 cache的某個值,先要到L1 cache裡取,L1當中不存在,在L2裡,L2開始加 鎖,加鎖以後,把L2裡的資料複製到L1,再執行讀L1的過程,上面的3步,再解鎖。
    CPU取L3 cache的也是一樣,只不過先由L3複製到L2,從L2複製到L1,從L1到CPU。 
    CPU取記憶體則複雜:通知記憶體控制器佔用匯流排頻寬,通知記憶體加鎖,發起記憶體讀請求, 
    等待回應,回應資料儲存到L3(如果沒有就到L2),再從L3/2到L1,再從L1到CPU,之後解除匯流排鎖定。 

多執行緒環境下存在的問題

快取一致性問題

在多處理器系統中,每個處理器都有自己的快取記憶體,而它們又共享同一主記憶體 (MainMemory)。
基於快取記憶體的儲存互動很好地解決了處理器與記憶體的速度矛盾,但是 也引入了新的問題:快取一致性(CacheCoherence)。
當多個處理器的運算任務都涉及同一 塊主記憶體區域時,將可能導致各自的快取資料不一致的情況,如果真的發生這種情況,
那同步回到主記憶體時以誰的快取資料為準呢?為了解決一致性的問題,需要各個處理器訪問快取時都 遵循一些協議,
在讀寫時要根據協議來進行操作,這類協議有MSI、 MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol,等等

指令重排序問題

為了使得處理器內部的運算單元能儘量被充分利用,處理器可能會對輸入程式碼進行亂序執行(Out-Of-Order Execution)優化,
處理器會在計算之後將亂序執行的結果重組,保證該 結果與順序執行的結果是一致的,但並不保證程式中各個語句計算的先後順序與輸入程式碼中的 順序一致。
因此,如果存在一個計算任務依賴另一個計算任務的中間結果,那麼其順序性並不 能靠程式碼的先後順序來保證。
與處理器的亂序執行優化類似,Java虛擬機器的即時編譯器中也有 類似的指令重排序(Instruction Reorder)優化

2.什麼是執行緒

現代作業系統在執行一個程式時,會為其建立一個程式。例如,啟動一個Java程式,作業系統就會建立一個Java程式。
現代作業系統排程CPU的最小單元是執行緒,也叫輕量級程式,在一個程式裡可以建立多個執行緒,
這些執行緒都擁有各自的計數器、堆疊和區域性變數等屬性,並且能夠訪問共享的記憶體變數。
處理器在這些執行緒上高速切換,讓使用者感覺到這些執行緒在同時執行。

執行緒的實現可以分為兩類:

1、使用者級執行緒(User-Level Thread)
2、核心線執行緒(Kernel-Level Thread)

在瞭解執行緒之前,需要知道系統存在兩種空間:使用者空間和核心空間
其中核心空間只能由核心程式碼進行方訪問,使用者程式碼無法直接訪問如果使用者程式碼要訪問核心空間,
需要藉助核心空間提供的訪問介面
系統中使用者程式執行在使用者方式下,而系統呼叫執行在核心方式下。
在這兩種方式下所用的堆疊不一樣:使用者方式下用的是一般的堆疊,而核心方式下用的是固定大小的堆疊(一般為一個記憶體頁的大小)

比如一個4G記憶體的空間,可能只有3GB可以用於使用者應用程式。一個程式只能執行在使用者方式(usermode)或核心方式(kernelmode)下,
每個程式都有自己的3G使用者空間,它們共享1GB的核心空間。當一個程式從使用者空間進入核心空間時,它就不再有自己的程式空間了。
這也就是為什麼我們經常說執行緒上下文切換會涉及到使用者態到核心態的切換原因所在

使用者執行緒:

指不需要核心支援而在使用者程式中實現的執行緒,其不依賴於作業系統核心,應用程式利用執行緒庫提供建立、同步、排程和管理執行緒的函式來控制使用者執行緒。
另外,使用者執行緒是由應用程式利用執行緒庫建立和管理,不依賴於作業系統核心。不需要使用者態/核心態切換,速度快。
作業系統核心不知道多執行緒的存在,因此一個執行緒阻塞將使得整個程式(包括它的所有執行緒)阻塞(可理解為序列化的)
由於這裡的處理器時間片分配是以程式為基本單位,所以每個執行緒執行的時間相對減少。

核心執行緒:

執行緒的所有管理操作都是由作業系統核心完成的。核心儲存執行緒的狀態和上下文資訊,
當一個執行緒執行了引起阻塞的系統呼叫時,核心可以排程該程式的其他執行緒執行。
在多處理器系統上,核心可以分派屬於同一程式的多個執行緒在多個處理器上執行,提高程式執行的並行度。
由於需要核心完成執行緒的建立、排程和管理,所以和使用者級執行緒相比這些操作要慢得多,但是仍然比程式的建立和管理操作要快。
大多數市場上的作業系統,如Windows,Linux等都支援核心級執行緒。

以下是使用者執行緒和核心執行緒的介面圖,使用者執行緒空間中,並沒有程式-執行緒對應關係表,但核心執行緒中有

Java執行緒與系統核心執行緒關係模型

Java執行緒
JVM中建立執行緒有2種方式

   1. new java.lang.Thread().start()
   2. 使用JNI將一個native thread attach到JVM中
   針對 new java.lang.Thread().start()這種方式,只有呼叫start()方法的時候,才會真正的在

JVM中去建立執行緒,主要的生命週期步驟有:

1. 建立對應的JavaThread的instance
2. 建立對應的OSThread的instance
3. 建立實際的底層作業系統的native thread
4. 準備相應的JVM狀態,比如ThreadLocal儲存空間分配等
5. 底層的native thread開始執行,呼叫java.lang.Thread生成的Object的run()方法
6. 當java.lang.Thread生成的Object的run()方法執行完畢返回後,或者丟擲異常終止後,終止native thread
7. 釋放JVM相關的thread的資源,清除對應的JavaThread和OSThread

針對JNI將一個native thread attach到JVM中,主要的步驟有:

1. 通過JNI call AttachCurrentThread申請連線到執行的JVM例項
2. JVM建立相應的JavaThread和OSThread物件
3. 建立相應的java.lang.Thread的物件
4. 一旦java.lang.Thread的Object建立之後,JNI就可以呼叫Java程式碼了
5. 當通過JNI call DetachCurrentThread之後,JNI就從JVM例項中斷開連線
6. JVM清除相應的JavaThread, OSThread, java.lang.Thread物件

3.為什麼用到併發?併發會產生什麼問題

併發程式設計的本質其實就是利用多執行緒技術,在現代多核的CPU的背景下,催生了併發程式設計
的趨勢,通過併發程式設計的形式可以將多核CPU的計算能力發揮到極致,效能得到提升。除此之
外,面對複雜業務模型,並行程式會比序列程式更適應業務需求,而併發程式設計更能吻合這種業務拆分。

即使是單核處理器也支援多執行緒執行程式碼,CPU通過給每個執行緒分配CPU時間片來實現
這個機制。時間片是CPU分配給各個執行緒的時間,因為時間片非常短,所以CPU通過不停地切
換執行緒執行,讓我們感覺多個執行緒是同時執行的,時間片一般是幾十毫秒(ms)。
併發不等於並行:併發指的是多個任務交替進行,而並行則是指真正意義上的“同時進
行”。實際上,如果系統內只有一個CPU,而使用多執行緒時,那麼真實系統環境下不能並行,
只能通過切換時間片的方式交替進行,而成為併發執行任務。真正的並行也只能出現在擁有多個CPU的系統中。

併發的優點:

1. 充分利用多核CPU的計算能力;
2. 方便進行業務拆分,提升應用效能;

併發產生的問題:

高併發場景下,導致頻繁的上下文切換
臨界區執行緒安全問題,容易出現死鎖的,產生死鎖就會造成系統功能不可用

其它

CPU通過時間片分配演算法來迴圈執行任務,當前任務執行一個時間片後會切換到下一個任務。
但是,在切換前會儲存上一個任務的狀態,以便下次切換回這個任務時,可以再載入這個任務的狀態。
所以任務從儲存到再載入的過程就是一次上下文切換。

什麼是JMM模型?

Java記憶體模型(Java Memory Model簡稱JMM)是一種抽象的概念,並不真實存在,它描
述的是一組規則或規範,通過這組規範定義了程式中各個變數(包括例項欄位,靜態欄位和構
成陣列物件的元素)的訪問方式。JVM執行程式的實體是執行緒,而每個執行緒建立時JVM都會為
其建立一個工作記憶體(有些地方稱為棧空間),用於儲存執行緒私有的資料,而Java記憶體模型中規
定所有變數都儲存在主記憶體,主記憶體是共享記憶體區域,所有執行緒都可以訪問,但執行緒對變數的
操作(讀取賦值等)必須在工作記憶體中進行,首先要將變數從主記憶體拷貝的自己的工作記憶體空
間,然後對變數進行操作,操作完成後再將變數寫回主記憶體,不能直接操作主記憶體中的變數,
工作記憶體中儲存著主記憶體中的變數副本拷貝,前面說過,工作記憶體是每個執行緒的私有資料區
域,因此不同的執行緒間無法訪問對方的工作記憶體,執行緒間的通訊(傳值)必須通過主記憶體來完
成。

JMM不同於JVM記憶體區域模型 (JVM是是實際存在的,JMM只是邏輯規則)

JMM與JVM記憶體區域的劃分是不同的概念層次,更恰當說JMM描述的是一組規則,通過
這組規則控制程式中各個變數在共享資料區域和私有資料區域的訪問方式,JMM是圍繞原子性,有序性、可見性展開。

JMM與Java記憶體區域唯一相似點,都存在共享資料區域和私有資料區域,在JMM中主記憶體屬於共享資料區域,
從某個程度上講應該包括了堆和方法區,而工作記憶體資料執行緒私有資料區域,
從某個程度上講則應該包括程式計數器、虛擬機器棧以及本地方法棧。

執行緒,工作記憶體,主記憶體工作互動圖(基於JMM規範):

主記憶體

 主要儲存的是Java例項物件,所有執行緒建立的例項物件都存放在主記憶體中,不管該例項對
 象是成員變數還是方法中的本地變數(也稱區域性變數),當然也包括了共享的類資訊、常量、靜
 態變數。由於是共享資料區域,多條執行緒對同一個變數進行訪問可能會發生執行緒安全問題。

工作記憶體

主要儲存當前方法的所有本地變數資訊(工作記憶體中儲存著主記憶體中的變數副本拷貝),每
個執行緒只能訪問自己的工作記憶體,即執行緒中的本地變數對其它執行緒是不可見的,就算是兩個線
程執行的是同一段程式碼,它們也會各自在自己的工作記憶體中建立屬於當前執行緒的本地變數,當
然也包括了位元組碼行號指示器、相關Native方法的資訊。注意由於工作記憶體是每個執行緒的私有
資料,執行緒間無法相互訪問工作記憶體,因此儲存在工作記憶體的資料不存線上程安全問題。

根據JVM虛擬機器規範主記憶體與工作記憶體的資料儲存型別以及操作方式,對於一個例項物件中的成員方法而言,
如果方法中包含本地變數是基本資料型別(boolean,byte,short,char,int,long,float,double),
將直接儲存在工作記憶體的幀棧結構中,但倘若本地變數是引用型別,那麼該變數的引用會儲存在功能記憶體的幀棧中,
而物件例項將儲存在主記憶體(共享資料區域,堆)中。但對於例項物件的成員變數,不管它是基本資料型別或者
包裝型別(Integer、Double等)還是引用型別,都會被儲存到堆區。至於static變數以及類本身
相關資訊將會儲存在主記憶體中。需要注意的是,在主記憶體中的例項物件可以被多執行緒共享,倘
若兩個執行緒同時呼叫了同一個物件的同一個方法,那麼兩條執行緒會將要操作的資料拷貝一份到
自己的工作記憶體中,執行完成操作後才重新整理到主記憶體

模型如下圖所示

JMM存在的必要性

在明白了Java記憶體區域劃分、硬體記憶體架構、Java多執行緒的實現原理與Java記憶體模型的具
體關係後,接著來談談Java記憶體模型存在的必要性。由於JVM執行程式的實體是執行緒,而每個
執行緒建立時JVM都會為其建立一個工作記憶體(有些地方稱為棧空間),用於儲存執行緒私有的數
據,執行緒與主記憶體中的變數操作必須通過工作記憶體間接完成,主要過程是將變數從主記憶體拷貝
的每個執行緒各自的工作記憶體空間,然後對變數進行操作,操作完成後再將變數寫回主記憶體,如
果存在兩個執行緒同時對一個主記憶體中的例項物件的變數進行操作就有可能誘發執行緒安全問題。

假設主記憶體中存在一個共享變數x,現在有A和B兩條執行緒分別對該變數x=1進行操作,
A/B執行緒各自的工作記憶體中存在共享變數副本x。假設現在A執行緒想要修改x的值為2,而B執行緒
卻想要讀取x的值,那麼B執行緒讀取到的值是A執行緒更新後的值2還是更新前的值1呢?答案是,不確定,
即B執行緒有可能讀取到A執行緒更新前的值1,也有可能讀取到A執行緒更新後的值2,
這是因為工作記憶體是每個執行緒私有的資料區域,而執行緒A變數x時,首先是將變數從主記憶體拷貝到A
執行緒的工作記憶體中,然後對變數進行操作,操作完成後再將變數x寫回主內,而對於B執行緒的也
是類似的,這樣就有可能造成主記憶體與工作記憶體間資料存在一致性問題,假如A執行緒修改完後
正在將資料寫回主記憶體,而B執行緒此時正在讀取主記憶體,即將x=1拷貝到自己的工作記憶體中,
這樣B執行緒讀取到的值就是x=1,但如果A執行緒已將x=2寫回主記憶體後,B執行緒才開始讀取的
話,那麼此時B執行緒讀取到的就是x=2,但到底是哪種情況先發生呢?

如圖 

以上關於主記憶體與工作記憶體之間的具體互動協議,即一個變數如何從主記憶體拷貝到工作內
存、如何從工作記憶體同步到主記憶體之間的實現細節,Java記憶體模型定義了以下八種操作來完
成。

JMM-同步八種操作介紹

(1)lock(鎖定):作用於主記憶體的變數,把一個變數標記為一條執行緒獨佔狀態
(2)unlock(解鎖):作用於主記憶體的變數,把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定
(3)read(讀取):作用於主記憶體的變數,把一個變數值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用
(4)load(載入):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中
(5)use(使用):作用於工作記憶體的變數,把工作記憶體中的一個變數值傳遞給執行引擎
(6)assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦給工作記憶體的變數
(7)store(儲存):作用於工作記憶體的變數,把工作記憶體中的一個變數的值傳送到主記憶體中,以便隨後的write的操作
(8)write(寫入):作用於工作記憶體的變數,它把store操作從工作記憶體中的一個變數的值傳送到主記憶體的變數中

如果要把一個變數從主記憶體中複製到工作記憶體中,就需要按順序地執行read和load操作,
如果把變數從工作記憶體中同步到主記憶體中,就需要按順序地執行store和write操作。但Java內
存模型只要求上述操作必須按順序執行,而沒有保證必須是連續執行。

同步規則分析

1)不允許一個執行緒無原因地(沒有發生過任何assign操作)把資料從工作記憶體同步回主記憶體中

2)一個新的變數只能在主記憶體中誕生,不允許在工作記憶體中直接使用一個未被初始化(load或者assign)的變數。
   即就是對一個變數實施use和store操作之前,必須先自行assign和load操作。

3)一個變數在同一時刻只允許一條執行緒對其進行lock操作,但lock操作可以被同一執行緒重複執行多次,多次執行lock後,
  只有執行相同次數的unlock操作,變數才會被解鎖。lock和unlock必須成對出現。

4)如果對一個變數執行lock操作,將會清空工作記憶體中此變數的值,在執行引擎使用這個變
  量之前需要重新執行load或assign操作初始化變數的值。

5)如果一個變數事先沒有被lock操作鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被其他執行緒鎖定的變數。

6)對一個變數執行unlock操作之前,必須先把此變數同步到主記憶體中(執行store和write操作) 

併發程式設計的可見性,原子性與有序性問題

原子性

原子性指的是一個操作是不可中斷的,即使是在多執行緒環境下,一個操作一旦開始就不會
被其他執行緒影響。
在java中,對基本資料型別的變數的讀取和賦值操作是原子性操作有點要注意的是,對於
32位系統的來說,long型別資料和double型別資料(對於基本資料型別,
byte,short,int,float,boolean,char讀寫是原子操作),它們的讀寫並非原子性的,也就是說如
果存在兩條執行緒同時對long型別或者double型別的資料進行讀寫是存在相互干擾的,因為對
於32位虛擬機器來說,每次原子讀寫是32位的,而long和double則是64位的儲存單元,這樣會
導致一個執行緒在寫時,操作完前32位的原子操作後,輪到B執行緒讀取時,恰好只讀取到了後32
位的資料,這樣可能會讀取到一個既非原值又不是執行緒修改值的變數,它可能是“半個變
量”的數值,即64位資料被兩個執行緒分成了兩次讀取。但也不必太擔心,因為讀取到“半個變
量”的情況比較少見,至少在目前的商用的虛擬機器中,幾乎都把64位的資料的讀寫操作作為原
子操作來執行,因此對於這個問題不必太在意,知道這麼回事即可。
X=10;  //原子性(簡單的讀取、將數字賦值給變數)
Y = x;  //變數之間的相互賦值,不是原子操作
X++;  //對變數進行計算操作
X = x+1;

可見性

理解了指令重排現象後,可見性容易了,可見性指的是當一個執行緒修改了某個共享變數的
值,其他執行緒是否能夠馬上得知這個修改的值。對於序列程式來說,可見性是不存在的,因為
我們在任何一個操作中修改了某個變數的值,後續的操作中都能讀取這個變數值,並且是修改
過的新值。
但在多執行緒環境中可就不一定了,前面我們分析過,由於執行緒對共享變數的操作都是執行緒
拷貝到各自的工作記憶體進行操作後才寫回到主記憶體中的,這就可能存在一個執行緒A修改了共享
變數x的值,還未寫回主記憶體時,另外一個執行緒B又對主記憶體中同一個共享變數x進行操作,但
此時A執行緒工作記憶體中共享變數x對執行緒B來說並不可見,這種工作記憶體與主記憶體同步延遲現象
就造成了可見性問題,另外指令重排以及編譯器優化也可能導致可見性問題,通過前面的分
析,我們知道無論是編譯器優化還是處理器優化的重排現象,在多執行緒環境下,確實會導致程
序輪序執行的問題,從而也就導致可見性問題。

有序性

有序性是指對於單執行緒的執行程式碼,我們總是認為程式碼的執行是按順序依次執行的,這樣
的理解並沒有毛病,畢竟對於單執行緒而言確實如此,但對於多執行緒環境,則可能出現亂序現
象,因為程式編譯成機器碼指令後可能會出現指令重排現象,重排後的指令與原指令的順序未
必一致,要明白的是,在Java程式中,倘若在本執行緒內,所有操作都視為有序行為,如果是多
執行緒環境下,一個執行緒中觀察另外一個執行緒,所有操作都是無序的,前半句指的是單執行緒內保
證序列語義執行的一致性,後半句則指指令重排現象和工作記憶體與主記憶體同步延遲現象。

JMM如何解決原子性&可見性&有序性問題?

原子性問題

 除了JVM自身提供的對基本資料型別讀寫操作的原子性外,可以通過 synchronized和Lock實現原子性。
 因為synchronized和Lock能夠保證任一時刻只有一個執行緒訪問該程式碼塊。

可見性問題

volatile關鍵字保證可見性。當一個共享變數被volatile修飾時,它會保證修改的值立即被
其他的執行緒看到,即修改的值立即更新到主存中,當其他執行緒需要讀取時,它會去記憶體中讀取
新值。synchronized和Lock也可以保證可見性,因為它們可以保證任一時刻只有一個執行緒能
訪問共享資源,並在其釋放鎖之前將修改的變數重新整理到記憶體中

有序性問題

在Java裡面,可以通過volatile關鍵字來保證一定的“有序性”(具體原理在下一節講述
volatile關鍵字)。另外可以通過synchronized和Lock來保證有序性,很顯然,synchronized
和Lock保證每個時刻是有一個執行緒執行同步程式碼,相當於是讓執行緒順序執行同步程式碼,自然就
保證了有序性。

Java記憶體模型:

每個執行緒都有自己的工作記憶體(類似於前面的快取記憶體)。執行緒對變數的
所有操作都必須在工作記憶體中進行,而不能直接對主存進行操作。並且每個執行緒不能訪問其他
執行緒的工作記憶體。Java記憶體模型具備一些先天的“有序性”,即不需要通過任何手段就能夠得
到保證的有序性,這個通常也稱為happens-before 原則。如果兩個操作的執行次序無法從
happens-before原則推匯出來,那麼它們就不能保證它們的有序性,虛擬機器可以隨意地對它
們進行重排序。

指令重排序:

java語言規範規定JVM執行緒內部維持順序化語義。即只要程式的最終結果與
它順序化情況的結果相等,那麼指令的執行順序可以與程式碼順序不一致,此過程叫指令的重排
序。指令重排序的意義是什麼?JVM能根據處理器特性(CPU多級快取系統、多核處理器等)
適當的對機器指令進行重排序,使機器指令能更符合CPU的執行特性,最大限度的發揮機器性
能

下圖為從原始碼到最終執行的指令序列示意圖

as-if-serial語義

    as-if-serial語義的意思是:不管怎麼重排序(編譯器和處理器為了提高並行度),(單線
    程)程式的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。
    為了遵守as-if-serial語義,編譯器和處理器不會對存在資料依賴關係的操作做重排序,因
    為這種重排序會改變執行結果。但是,如果操作之間不存在資料依賴關係,這些操作就可能被
    編譯器和處理器重排序。

happens-before 原則

只靠sychronized和volatile關鍵字來保證原子性、可見性以及有序性,那麼編寫併發程式可能會顯得十分麻煩,
幸運的是,從JDK5開始,Java使用新的JSR-133記憶體模型,提供了happens-before原則來輔助保證程式執行的原子性、可見性以及有序性的問題,
它是判斷資料是否存在競爭、執行緒是否安全的依據,happens-before 原則內容如下

1. 程式順序原則,即在一個執行緒內必須保證語義序列性,也就是說按照程式碼順序執行。

2. 鎖規則解鎖(unlock)操作必然發生在後續的同一個鎖的加鎖(lock)之前,也就是說,
    如果對於一個鎖解鎖後,再加鎖,那麼加鎖的動作必須在解鎖動作之後(同一個鎖)。
    
3. volatile規則 volatile變數的寫,先發生於讀,這保證了volatile變數的可見性,簡單
    的理解就是,volatile變數在每次被執行緒訪問時,都強迫從主記憶體中讀該變數的值,而當
    該變數發生變化時,又會強迫將最新的值重新整理到主記憶體,任何時刻,不同的執行緒總是能
    夠看到該變數的最新值。
    
4. 執行緒啟動規則 執行緒的start()方法先於它的每一個動作,即如果執行緒A在執行執行緒B的
    start方法之前修改了共享變數的值,那麼當執行緒B執行start方法時,執行緒A對共享變數
    的修改對執行緒B可見
    
5. 傳遞性 A先於B ,B先於C 那麼A必然先於C

6. 執行緒終止規則 執行緒的所有操作先於執行緒的終結,Thread.join()方法的作用是等待當前
    執行的執行緒終止。假設線上程B終止之前,修改了共享變數,執行緒A從執行緒B的join方法
    成功返回後,執行緒B對共享變數的修改將對執行緒A可見。
    
7. 執行緒中斷規則 對執行緒 interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中
    斷事件的發生,可以通過Thread.interrupted()方法檢測執行緒是否中斷。
    
8. 物件終結規則 物件的建構函式執行,結束先於finalize()方法

volatile記憶體語義
volatile是Java虛擬機器提供的輕量級的同步機制。
volatile保證可見性與有序性,但是不能保證原子性,要保證原子性需要藉助synchronized、Lock鎖機制,同理也能保證有序性與可見性。
因為synchronized和Lock能夠保證任一時刻只有一個執行緒訪問該程式碼塊。

volatile關鍵字有如下兩個作用

保證被volatile修飾的共享變數對所有執行緒總數可見的,也就是當一個執行緒修改了一個被volatile修飾共享變數的值,新值總是可以被其他執行緒立即得知。
禁止指令重排序優化。

volatile的可見性
關於volatile的可見性作用,我們必須意識到被volatile修飾的變數對所有執行緒總數立即可見的,會showtime,底層被編譯的時候會有lock訊號
對volatile變數的所有寫操作總是能立刻反應到其他執行緒中
示例

    /
     * 以下程式碼先執行執行緒A,一直執行i++,然後執行執行緒B,更改initFlag的值為true,想要退出迴圈
     * 但是如果變數不加volatile或者不加鎖,由於執行緒A中的initFlag從第一次從主記憶體中load到執行緒A工作記憶體後
     * 一直使用的執行緒A的快取資料,即便線上程B中更改了initFlag,但是並沒有showtime給執行緒A
     * 執行緒A使用的仍然是他快取中的,並沒有去主記憶體中獲取,所以當前程式碼要實現initFlag可見,
     * 可以加volatile關鍵字實現volatile寫(更改後一定會寫到主記憶體中並且會showtime),(保證可見性)
     * 或者加同步程式碼塊synchronized
     *      加synchronized原因:看程式碼第三版
     *
     */
    public class VolatileVisibilitySample {
        private boolean  initFlag = false;
        static Object object = new Object();
        public void refresh(){
            //普通寫操作,(主要改成volatile寫就可以)
            this.initFlag = true;
            String threadname = Thread.currentThread().getName();
            System.out.println("執行緒:"+threadname+":修改共享變數initFlag");
        }
        public void load(){
            String threadname = Thread.currentThread().getName();
            int i = 0;
    
    
            // 第一版 initFlag沒加volatile,後面的列印不會出現  (空跑會一直佔用CPU使用權,優先順序別非常高)
            //while (!initFlag){ }
    
            // 第二版,加一個變數 initFlag沒加volatile,後面的列印不會出現,因為i和他沒關係
            //while (!initFlag){ i++;}
    
            // 第三版 加同步塊 initFlag沒加volatile,後面的列印會出現,
            // 存在同步塊,這裡可能引起阻塞,競爭可能導致上下文切換,執行緒的上下文切換會把執行緒的資訊等資料回寫到記憶體的 任務狀態段 裡面
            // 所以可能重新去主記憶體load資料,能知道initFlag已經改變,得以更新執行緒A的記憶體副本
            while (!initFlag){
                synchronized (object){
                    i++;
                }
            }
            System.out.println("執行緒:"+threadname+"當前執行緒嗅探到initFlag的狀態的改變"+i);
        }
        public static void main(String[] args){
            VolatileVisibilitySample sample = new VolatileVisibilitySample();
            Thread threadA = new Thread(()->{ sample.refresh(); },"threadA");
            Thread threadB = new Thread(()->{ sample.load(); },"threadB");
            threadB.start();
            try {
                 Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            threadA.start();
        }
    }

volatile無法保證原子性

    public class VolatileVisibility {
        public  static  volatile int  i =0;
        public  static  void  increase(){i++;}
    }

    在併發場景下,i變數的任何改變都會立馬反應到其他執行緒中,但是如此存在多條執行緒同時呼叫increase()方法的話,
    就會出現執行緒安全問題,畢竟i++;操作並不具備原子性,該操作是先讀取值,然後寫回一個新值,相當於原來的值加上1,
    分兩步完成,如果第二個執行緒在第一個執行緒讀取舊值和寫回新值期間讀取i的域值,那麼第二個執行緒就會與第一個執行緒一起看到同一
    個值,並執行相同值的加1操作,這也就造成了執行緒安全失敗,因此對於increase方法必須使用synchronized修飾,以便保證執行緒安全,需
    要注意的是一旦使用synchronized修飾方法後,由於synchronized本身也具備與volatile相同的特性,即可見性,
    因此在這樣種情況下就完全可以省去volatile修飾變數

volatile禁止重排優化

volatile關鍵字另一個作用就是禁止指令重排優化,從而避免多執行緒環境下程式出現亂序
執行的現象,關於指令重排優化前面已詳細分析過,這裡主要簡單說明一下volatile是如何實
現禁止指令重排優化的。先了解一個概念,記憶體屏障(Memory Barrier)。
記憶體屏障,又稱記憶體柵欄,是一個CPU指令,它的作用有兩個,一是保證特定操作的執行順序,
二是保證某些變數的記憶體可見性(利用該特性實現volatile的記憶體可見性)。
由於編譯器和處理器都能執行指令重排優化。如果在指令間插入一條Memory Barrier則會告訴編譯器和CPU,
不管什麼指令都不能和這條Memory Barrier指令重排序,也就是說通過插入記憶體屏
障禁止在記憶體屏障前後的指令執行重排序優化。Memory Barrier的另外一個作用是強制刷出
各種CPU的快取資料,因此任何CPU上的執行緒都能讀取到這些資料的最新版本。
總之,volatile變數正是通過記憶體屏障實現其在記憶體中的語義,即可見性和禁止重排優化。

下面看一個非常典型的禁止重排優化的例子DCL,如下

    /
     * volatile保證指令重排(原理是插入了屏障)
     */
    public class Singleton {
    
        /
         * 檢視彙編指令
         * -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp
         */
        private volatile static Singleton myinstance;
    
        public static Singleton getInstance() {
            if (myinstance == null) {
                synchronized (Singleton.class) {
                    if (myinstance == null) {
    
                        //多執行緒環境下可能會出現問題的地方
    
                        / 物件建立過程,本質可以分文三步
                         * 1. 申請地址 address=allocate
                         * 2.地址上例項化物件 new Singleton()
                         * 3.第三步 myinstance=address
                         *
                         * 要加volatile關鍵字,為了阻止指令重排,原因:
                         *  其中這三步無法保證原子性,第二步和第三步可能存在指令重排
                         *  當很高的高併發請求下,如果不進行兩層判斷,
                         *  如果程式執行了第一步申請地址之後
                         *  如果第三步和第二步進行了指令重排,那麼會導致myinstance=address
                         *  但是這時候address是空的,在使用的時候就會報錯
                         *
                         *
                         *
                         */
                        myinstance = new Singleton();
                        //物件延遲初始化
                        //
                    }
                }
            }
            return myinstance;
        }
        public static void main(String[] args) {
            Singleton.getInstance();
        }
    
        /
         * 如果在多執行緒環境下就可以出現執行緒安全問題。原因在於某一個執行緒執行到第一次檢測,讀
         * 取到的instance不為null時,instance的引用物件可能沒有完成初始化。
         * 因為instance = new Singleton();可以分為以下3步完成(虛擬碼)
              memory = allocate();//1.分配物件記憶體空間
              instance(memory);//2.初始化物件
              instance = memory;//3.設定instance指向剛分配的記憶體地址,此時
              instance!=null
         * 由於步驟1和步驟2間可能會重排序,如下:
              memory=allocate();//1.分配物件記憶體空間
              instance=memory;//3.設定instance指向剛分配的記憶體地址,此時instance!
              =null,但是物件還沒有初始化完成!
              instance(memory);//2.初始化物件
         * 由於步驟2和步驟3不存在資料依賴關係,而且無論重排前還是重排後程式的執行結果在單
         * 執行緒中並沒有改變,因此這種重排優化是允許的。但是指令重排只會保證序列語義的執行的一
         * 致性(單執行緒),但並不會關心多執行緒間的語義一致性。所以當一條執行緒訪問instance不為null
         * 時,由於instance例項未必已初始化完成,也就造成了執行緒安全問題。那麼該如何解決呢,很
         * 簡單,我們使用volatile禁止instance變數被執行指令重排優化即可。
         * 
         //禁止指令重排優化
         private volatile static Singleton myinstance;
         */
    }

指令重排+讀寫屏障例項

/
 * 指令重排,不允許使用volatile的話,手動插入屏障理解
 *
 * 從程式碼上理解,正常的邏輯思維情況下下,可能列印的結果只有三種
 *  1,1 (當執行緒1執行了a=1,同時執行緒2執行了b=1的時候)
 *  1,0(當執行緒1執行了a=1,執行緒2還沒執行,y=b取了預設值)
 *  0,1(當執行緒2執行了b=1,執行緒1還沒執行,x=a取了預設值)
 *
 *  指令重排的結果
 *  0,0(a=1和x=b進行了指令重排,b=1和y=a進行了指令重排,xy都取了ab預設值)
 *
 */
public class VolatileReOrderSample {
    private static int x = 0, y = 0;
    private static int a = 0, b =0;
    //private volatile static int a = 0, b =0;
    static Object object = new Object();

    public static void main(String[] args) throws InterruptedException {
        int i = 0;

        for (;;){
            i++;
            x = 0; y = 0;
            a = 0; b = 0;
            Thread t1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    //由於執行緒one先啟動,下面這句話讓它等一等執行緒two. 讀著可根據自己電腦的實際效能適當調整等待時間.
                    shortWait(10000);
                    a = 1; //是讀還是寫?store,volatile寫
                    //storeload ,讀寫屏障,不允許volatile寫與第二部volatile讀發生重排
                    //手動加記憶體屏障
                    //UnsafeInstance.reflectGetUnsafe().storeFence();

                    // 如果a,b使用volatile修飾,防止指令重排:這個操作 先讀volatile,然後寫普通變數b
                    x = b;
                    //分兩步進行,第一步先volatile讀,第二步再普通寫
                }
            });
            Thread t2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    //UnsafeInstance.reflectGetUnsafe().storeFence();
                    y = a;
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            String result = "第" + i + "次 (" + x + "," + y + ")";
            if(x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }

    }
    public static void shortWait(long interval){
        long start = System.nanoTime();
        long end;
        do{
            end = System.nanoTime();
        }while(start + interval >= end);
    }
}

volatile記憶體語義的實現

前面提到過重排序分為編譯器重排序和處理器重排序。為了實現volatile記憶體語義,JMM
會分別限制這兩種型別的重排序型別。
下面是JMM針對編譯器制定的volatile重排序規則表

    是否能重排序                    第二個操作
      第一個操作       普通讀/寫      volatile讀      volatile寫
      普通讀/寫                                        NO
      volatile讀       NO              NO             NO 
      volatile寫                       NO             NO

舉例來說,第三行最後一個單元格的意思是:

在程式中,當第一個操作為普通變數的讀或寫時,如果第二個操作為volatile寫,則編譯器不能重排序這兩個操作。
從上圖可以看出:

當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。
這個規則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之後。

當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。
這個規則確保volatile讀之後的操作不會被編譯器重排序到volatile讀之前。

當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。

為了實現volatile的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序。
對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎不可能。
為此,JMM採取保守策略。下面是基於保守策略的JMM記憶體屏障插入策略。

∙在每個volatile寫操作的前面插入一個StoreStore屏障。
∙在每個volatile寫操作的後面插入一個StoreLoad屏障。
∙在每個volatile讀操作的後面插入一個LoadLoad屏障。
∙在每個volatile讀操作的後面插入一個LoadStore屏障。

上述記憶體屏障插入策略非常保守,但它可以保證在任意處理器平臺,任意的程式中都能得
到正確的volatile記憶體語義。
下面是保守策略下,volatile寫插入記憶體屏障後生成的指令序列示意圖

上圖中StoreStore屏障可以保證在volatile寫之前,其前面的所有普通寫操作已經對任意處理器可見了。
這是因為StoreStore屏障將保障上面所有的普通寫在volatile寫之前重新整理到主記憶體。

這裡比較有意思的是,volatile寫後面的StoreLoad屏障。此屏障的作用是避免volatile寫與後面可能有的volatile讀/寫操作重排序。
因為編譯器常常無法準確判斷在一個volatile寫的後面是否需要插入一個StoreLoad屏障(比如,一個volatile寫之後方法立即return)。

為了保證能正確實現volatile的記憶體語義,JMM在採取了保守策略:

在每個volatile寫的後面,或者在每個volatile 讀的前面插入一個StoreLoad屏障。從整
體執行效率的角度考慮,JMM最終選擇了在每個 volatile寫的後面插入一個StoreLoad屏障。

因為volatile寫-讀記憶體語義的常見使用模式是:

一個寫執行緒寫volatile變數,多個讀執行緒讀同一個volatile變數。
當讀執行緒的數量大大超過寫執行緒時,選擇在volatile寫之後插入StoreLoad屏障將帶來可觀的執行效率的提升。
從這裡可以看到JMM 在實現上的一個特點:首先確保正確性,然後再去追求執行效率。

下圖是在保守策略下,volatile讀插入記憶體屏障後生成的指令序列示意圖

上圖中LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序。
LoadStore屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序。
上述volatile寫和volatile讀的記憶體屏障插入策略非常保守。在實際執行時,只要不
改變 volatile寫-讀的記憶體語義,編譯器可以根據具體情況省略不必要的屏障。
下面通過具體的示例

public class VolatileBarrierExample {
    int a;
    volatile int m1 = 1;
    volatile int m2 = 2;

    void readAndWrite() {
        int i = m1;   // 第一個volatile讀
        int j = m2;   // 第二個volatile讀

        a = i + j;    // 普通寫

        m1 = i + 1;   // 第一個volatile寫
        m2 = j * 2;   // 第二個 volatile寫
    }
}

針對readAndWrite()方法,編譯器在生成位元組碼時可以做如下的優化

注意,最後的StoreLoad屏障不能省略。因為第二個volatile寫之後,方法立即return。
此時編 譯器可能無法準確斷定後面是否會有volatile讀或寫,為了安全起見,編譯器通常會在這裡插 入一個StoreLoad屏障。
上面的優化針對任意處理器平臺,由於不同的處理器有不同“鬆緊度”的處理器內 存模 型,
記憶體屏障的插入還可以根據具體的處理器記憶體模型繼續優化。以X86處理器為
例,圖3-21 中除最後的StoreLoad屏障外,其他的屏障都會被省略。
前面保守策略下的volatile讀和寫,在X86處理器平臺可以優化成如下圖所示。前文提到過,X86處理器僅會對寫-讀操作做重排序。
X86不會對讀-讀、讀-寫和寫-寫操作做重排序,因此在X86處理器中會省略掉這3種操作型別對應的記憶體屏障。
在X86中,JMM僅需 在volatile寫後面插入一個StoreLoad屏障即可正確實現volatile寫-讀的記憶體
語義。這意味著在 X86處理器中,volatile寫的開銷比volatile讀的開銷會大很多(因為執行StoreLoad屏障開銷會比較大)

過多使用cas(compareandswap)和volatile導致的bus匯流排風暴
volatile 基於底層快取一致協議

cpu --> 工作記憶體 --> bus匯流排(快取一致性協議) ---> 主記憶體
cpu1 --> 工作記憶體 --> bus匯流排(快取一致性協議) ---> 主記憶體

如果使用volatile特別多或者熱別多原子的cas,會導致工作記憶體見產生特別多無效工作記憶體變數,由於volatile在bus中無限showtime,
導致bus匯流排互動變得特別多,其他有意義的操作互動變得延遲
這時候和synchronized比較,還不如使用synchronized

相關文章