說點JMM 讓你的面試錦上添花

java識堂 發佈 2020-05-01T22:34:54+00:00

並發編程關鍵問題JDK天生就是多線程的,多線程大大提速了程序運行的速度,但是凡事有利就有弊,並發編程時經常會涉及到線程之間的通信跟同步問題,一般也說是可見性、原子性、有序性。

並發編程關鍵問題

JDK天生就是多線程的,多線程大大提速了程序運行的速度,但是凡事有利就有弊,並發編程時經常會涉及到線程之間的通信跟同步問題,一般也說是可見性、原子性、有序性。

線程通信

線程的通信是指線程之間通過什麼機制來交換信息,在編程中常用的通信機制有兩個,共享內存消息傳遞

  1. 共享內存。

在共享內存的並發模型中線程之間共享程序的公共數據狀態,線程之前通過讀寫內存中的公共內存區域來進行信息的傳遞,典型的共享內存通信方式就是通過共享對象來進行通信。

  1. 消息傳遞,比如在Linux系統中同步機制有管道、信號、消息隊列、信號量、套接字這幾種方式。

在消息傳遞的並發模型中,線程之間是沒有共享狀態的,線程之間必須通過明確的發送消息來顯式的進行通信,在Java中的典型通信方式就是wait()跟notify()。

在C/C++中可以同時支持共享內存跟消息傳遞機制,Java中採用的是共享內存模型。


線程同步

同步是指程序用於控制不同線程之間操作發生相對順序的機制。

在共享內存並發模型里,同步是顯式進行的。程式設計師必須顯式指定某個方法或某段代碼需要在線程之間互斥執行。

在消息傳遞的並發模型里,由於消息的發送必須在消息的接收之前,因此同步是隱式進行的。

JMM

現代計算機物理上的內存模型

緩存

了解JMM前我們先了解下現代計算機物理上的數據存儲模型。

隨著CPU技術的發展,CPU的執行速度越來越快。而由於內存的技術並沒有太大的變化,我執行一個任務一共耗時10秒,結果CPU獲取數據耗時8秒,CPU計算耗時2秒,大部分時間都用來獲取數據上了。

怎麼解決這個問題呢?就是在CPU和內存之間增加高速緩存。緩存的概念就是保存一份數據拷貝。他的特點是速度快,內存小,並且昂貴。

以後程序運行獲取數據就是如下的步驟了。

並且隨著CPU計算能力的不斷提升,一層緩存就慢慢的無法滿足要求了,就逐漸的衍生出多級緩存。按照數據讀取順序和與CPU結合的緊密程度,CPU緩存可以分為一級緩存(L1),二級緩存(L2),部分高端CPU還具有三級緩存(L3),每一級緩存中所儲存的全部數據都是下一級緩存的一部分。這三種緩存的技術難度和製造成本是相對遞減的,所以其容量也是相對遞增的,性能對比如下:

單核CPU只含有一套L1,L2,L3緩存;如果CPU含有多個核心,即多核CPU,則每個核心都含有一套L1(甚至和L2)緩存,而共享L3(或者和L2)緩存。

緩存一致性

隨著計算機能力不斷提升,開始支持多線程。那麼問題就來了。我們分別來分析下單線程、多線程在單核CPU、多核CPU中的影響。

  1. 單線程。cpu核心的緩存只被一個線程訪問。緩存獨占,不會出現訪問衝突等問題。
  2. 單核CPU多線程。進程中的多個線程會同時訪問進程中的共享數據,CPU將某塊內存加載到緩存後,不同線程在訪問相同的物理地址的時候,都會映射到相同的緩存位置,這樣即使發生線程的切換,緩存仍然不會失效。但由於任何時刻只能有一個線程在執行,因此不會出現緩存訪問衝突。
  3. 多核CPU,多線程。每個核都至少有一個L1 緩存。多個線程訪問進程中的某個共享內存,且這多個線程分別在不同的核心上執行,則每個核心都會在各自的caehe中保留一份共享內存的緩衝。由於多核是可以並行的,可能會出現多個線程同時寫各自的緩存的情況,而各自的cache之間的數據就有可能不同。

在CPU和主存之間增加緩存,在多線程場景下就可能存在緩存一致性問題,也就是說,在多核CPU中,每個核的自己的緩存中,關於同一個數據的緩存內容可能不一致。

緩存一致性(Cache Coherence):在多處理器系統中,每個處理器都有自己的高速緩存,而它們又共享同一主內存(MainMemory)。當多個處理器的運算任務都涉及同一塊主內存區域時,將可能導致各自的緩存數據不一致,比如共享內存的一個變量在多個CPU之間的共享。如果真的發生這種情況,那同步回到主內存時以誰的緩存數據為準呢?為了解決一致性的問題,需要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。

不一致demo如下:

//線程A 執行如下
a = 1 // A1
x = b // A2
-----
// 線程B 執行如下
b = 2 // B1
y = a // B2
複製代碼

處理器A和處理器B按程序的順序並行執行內存訪問,最終可能得到x=y=0的結果。

處理器A和處理器B可以同時把共享變量寫入自己的寫緩衝區(A1,B1),a=1,b=2。

寫操作a = 1,b = 2要經過A3跟B3 刷新到共享緩存才算完畢。

如果這一步在第二步執行前執行了(A2,B2),x=b,y=a。程序就可以得到x=y=0的結果。

處理器優化和指令重排

上面提到在在CPU和主存之間增加緩存,在多線程場景下會存在緩存一致性問題。除了這種情況,還有一種硬體問題也比較重要。那就是為了使處理器內部的運算單元能夠儘量的被充分利用,處理器可能會對輸入代碼進行亂序執行處理。這就是處理器優化。

除了現在很多流行的處理器會對代碼進行優化亂序處理,很多程式語言的編譯器也會有類似的優化,比如Java的JIT

可想而知,如果任由處理器優化和編譯器對指令重排的話,就可能導致各種各樣的問題。硬體級別跟編譯器級別都會對這些問題進行解決。

並發編程問題

前面說的都是跟硬體相關的問題,我們需要知道軟體的基層是硬體,軟體在這樣的層面上運行就會出現原子性、可見性、有序性問題。 其實,原子性問題,可見性問題和有序性問題。是人們抽象定義出來的。而這個抽象的底層問題就是前面提到的緩存一致性、處理器優化、指令重排問題。

一般而言並發編程,為了保證數據的安全,需要滿足以下三個特性:

原子性:指在一個操作中就是cpu不可以在中途暫停然後再調度,既不被中斷操作,要不執行完成,要不就不執行。

可見性:指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。

有序性:程序執行的順序按照代碼的先後順序執行。

你可以發現緩存一致性問題其實就是可見性問題。而處理器優化是可以導致原子性問題的。指令重排即會導致有序性問題。

內存模型

前面提到的,緩存一致性、處理器優化、指令重排問題是硬體的不斷升級導致的。那麼,有沒有什麼機制可以很好的解決上面的這些問題呢 為了保證並發編程中可以滿足原子性、可見性及有序性。有一個重要的概念,那就是內存模型。

為了保證共享內存的正確性(可見性、有序性、原子性),內存模型定義了共享內存系統中多線程程序讀寫操作行為的規範。通過這些規則來規範對內存的讀寫操作,從而保證指令執行的正確性。它與處理器有關、與緩存有關、與並發有關、與編譯器也有關。他解決了CPU多級緩存、處理器優化、指令重排等導致的內存訪問問題,保證了並發場景下的一致性、原子性和有序性。

內存模型解決並發問題主要採用兩種方式:限制處理器優化和使用內存屏障。

JMM

前面說到計算機內存模型是解決多線程場景下並發問題的一個重要規範。那麼具體的實現是如何的呢,不同的程式語言,在實現上可能有所不同。

我們知道,Java程序是需要運行在Java虛擬機上面的,Java內存模型(Java Memory Model ,JMM)就是一種符合內存模型規範的,屏蔽了各種硬體和作業系統的訪問差異的,保證了Java程序在各種平台下對內存的訪問都能保證效果一致的機制及規範。

提到Java內存模型,一般指的是JDK 5 開始使用的新的內存模型,主要由JSR-133: JavaTM Memory Model and Thread Specification 描述。簡單形象圖如下:

JMM功能:

這是一種虛擬的規範,作用於工作內存和主存之間數據同步過程。目的是解決由於多線程通過共享內存進行通信時,存在的本地內存數據不一致、編譯器會對代碼指令重排序、處理器會對代碼亂序執行等帶來的問題。

PS:

這裡面提到的主內存和工作內存(高速緩存,寄存器),可以簡單的類比成計算機內存模型中的主存和緩存的概念。特別需要注意的是,主內存和工作內存與JVM內存結構中的Java堆、棧、方法區等並不是同一個層次的內存劃分,無法直接類比。《深入理解Java虛擬機》中認為,如果一定要勉強對應起來的話,從變量、主內存、工作內存的定義來看,主內存主要對應於Java堆中的對象實例數據部分。工作內存則對應於虛擬機棧中的部分區域。

任意的線程之間通信方式簡單如下:

在JVM內部,Java內存模型把內存分成了兩部分:線程棧區和堆區,關於JVM具體的講解參考以前博文,這裡只給出大致架構圖,細節部分都寫過了。

JMM帶來的問題
  1. 共享對象對各個線程的可見性

A 線程讀取主內存數據修改後還沒來得及將修改數據同步到主內存,主內存數據就又被B線程讀取了。

  1. 共享對象的競爭現象

AB兩個線程同時讀取主內存數據,然後同時加1,再返回。

對於上面的問題無非就是變量用volatile,加鎖,CAS等這樣的操作來解決。

指令重排

在執行程序時,為了提高性能,編譯器和處理器常常會對指令做重排序。 比如:

code1 // 耗時10秒
code2 // 耗時2秒
----
如果code1跟code2符合指令重拍的要求,code2不會一直等到code1執行完畢再執行。
複製代碼

編譯的原始碼可能經過如下重排加速才是最終CPU執行的指令。

  1. 編譯器優化的重排序

編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。

  1. 指令級並行的重排序

現代處理器採用了指令級並行技術(Instruction-LevelParallelism,ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。

  1. 內存系統的重排序

處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操作看上去可能是在亂序執行(處理器重排)

數據依賴跟控制依賴

重排序對於數據依賴性跟控制依賴性的代碼不會重拍。

  1. 數據依賴 如果兩個操作訪問同一個變量,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在數據依賴性,這樣的代碼都不允許重排(重排後結果就不一樣了)。數據依賴分下列三種類型:
  2. 控制依賴 flag變量是個標記,用來標識變量a是否已被寫入,在use方法中比變量i依賴if (flag)的判斷,這裡就叫控制依賴,如果發生了重排序,結果就不對了。
public void use(){
   if(flag){ //A
      int i = a*a;// B 
      ....
   }
}
複製代碼
as-if-serial

不管如何重排序,都必須保證代碼在單線程下的運行正確,連單線程下都無法正確,更不用討論多線程並發的情況,所以就提出了一個as-if-serial的概念, as-if-serial語義的意思是:

不管怎麼重排序(編譯器和處理器為了提高並行度),(單線程)程序的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。為了遵守as-if-serial語義,編譯器和處理器不會對存在數據依賴關係的操作做重排序,因為這種重排序會改變執行結果。(強調一下,這裡所說的數據依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操作,不同處理器之間和不同線程之間的數據依賴性不被編譯器和處理器考慮)但是,如果操作之間不存在數據依賴關係,這些操作依然可能被編譯器和處理器重排序。

int a = 1; //1
int b = 2;//2
int c = a + b ;// 3
複製代碼

1和3之間存在數據依賴關係,同時2和3之間也存在數據依賴關係。因此在最終執行的指令序列中,3不能被重排序到1和2的前面(3排到1和2的前面,程序的結果將會被改變)。但1和2之間沒有數據依賴關係,編譯器和處理器可以重排序1和2之間的執行順序。asif-serial語義使單線程下無需擔心重排序的干擾,也無需擔心內存可見性問題

多線程下重排問題

比如下面的類中兩個經典函數,如果AB線程分別同時執行不同的函數,

  1. 線程A對12指令重排,AB線程執行順序為 2-3-4-1。
  2. 線程B對34進行了指令重排,先讀取a值為0,然後計算出a*a= 0,臨時存儲下來,然後如果線程A執行完畢後導致use函數裡的i最終是0。

解決在並發下的問題

內存屏障

內存屏障(Memory Barrier,或有時叫做內存柵欄,Memory Fence)是一種CPU指令,用於控制特定條件下的重排序和內存可見性問題。Java編譯器也會根據內存屏障的規則禁止重排序。 Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序,從而讓程序按我們預想的流程去執行。

保證特定操作的執行順序。

影響某些數據(或則是某條指令的執行結果)的內存可見性。

編譯器和CPU能夠重排序指令,保證最終相同的結果,嘗試優化性能。插入一條Memory Barrier會告訴編譯器和CPU:

不管什麼指令都不能和這條Memory Barrier指令重排序。

Memory Barrier所做的另外一件事是強制刷出各種CPU cache,如一個Write-Barrier(寫入屏障)將刷出所有在Barrier之前寫入cache 的數據,因此,任何CPU上的線程都能讀取到這些數據的最新版本。

目前有4種屏障.。

  1. LoadLoad 屏障

序列:Load1,Loadload,Load2 讀 讀 大白話就是Load1一定要在Load2前執行,及時Load1執行慢Load2也要等Load1執行完。通常能執行預加載指令/支持亂序處理的處理器中需要顯式聲明Loadload屏障,因為在這些處理器中正在等待的加載指令能夠繞過正在等待存儲的指令。 而對於總是能保證處理順序的處理器上,設置該屏障相當於無操作。

  1. StoreStore 屏障 寫 寫

序列:Store1,StoreStore,Store2 大白話就是Store1的指令任何操作都可以及時的從高速緩存區寫入到共享區,確保其他線程可以讀到最新數據,可以理解為確保可見性。通常情況下,如果處理器不能保證從寫緩衝或/和緩存向其它處理器和主存中按順序刷新數據,那麼它需要使用StoreStore屏障。

  1. LoadStore 屏障 讀 寫

序列: Load1; LoadStore; Store2 大致作用跟第一個類似,確保Load1的數據在Store2和後續Store指令被刷新之前讀取。在等待Store指令可以越過loads指令的亂序處理器上需要使用LoadStore屏障。

  1. StoreLoad 屏障 寫 讀

序列: Store1; StoreLoad; Load2 確保Store1數據對其他處理器變得可見(指刷新到內存),之前於Load2及所有後續裝載指令的裝載。StoreLoad Barriers會使該屏障之前的所有內存訪問指令(存儲和裝載指令)完成之後,才執行該屏障之後的內存訪問指令。 StoreLoad Barriers是一個全能型 的屏障,它同時具有其他3個屏障的效果。現代的多處理器大多支持該屏障(其他類型的屏障不一定被所有處理器支持)。

臨界區

也就是加鎖,運行兩個函數的時候都加上相同的鎖,這樣就保證了兩個線程執行兩個函數的有序性,在同步方法裡只要負責as-if-serial即可。

Happens-Before

因為有指令重排的存在會導致難以理解CPU內部運行規則,JDK用happens-before的概念來闡述操作之間的內存可見性。在JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須要存在happens-before關係 。其中CPU的happens-before無需任何同步手段就可以保證的。

如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。(對程式設計師來說)

兩個操作之間存在happens-before關係,並不意味著Java平台的具體實現必須要按照happens-before關係指定的順序來執行。如果重排序之後的執行結果,與按happens-before關係來執行的結果一致,那麼這種重排序是允許的(對編譯器和處理器 來說)

happens-before具體規則Mark下,以備不時之需。

程序順序規則:一個線程中的每個操作,happens-before於該線程中的任意後續操作。

監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。

volatile變量規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。

傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。

start()規則:如果線程A執行操作ThreadB.start()(啟動線程B),那麼A線程的ThreadB.start()操作happens-before於線程B中的任意操作。

join()規則:如果線程A執行操作ThreadB.join()並成功返回,那麼線程B中的任意操作happens-before於線程A從ThreadB.join()操作成功返回。 7.線程中斷規則:對線程interrupt方法的調用happens-before於被中斷線程的代碼檢測到中斷事件的發生。

volatile語義

volatile保證變量的可見性,同時還具有弱原子性。關於volatile以前博文寫過細節不再重複,指令重排的時候對volatile規則如下:

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

在每個volatile讀操作的後面插入一個LoadLoad屏障。在每個volatile讀操作的後面插入一個LoadStore屏障

鎖內存語義

有點類似於重型版本的volatile,功能如下:

當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中。。

當線程獲取鎖時,JMM會把該線程對應的本地內存置為無效。從而使得被監視器保護的臨界區代碼必須從主內存中讀取共享變量。

final內存語義

編譯器和處理器要遵守兩個重排序規則。

在構造函數內對一個final域的寫入,與隨後把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。 看代碼備註1

初次讀一個包含final域的對象的引用,與隨後初次讀這個final域,這兩個操作之間不能重排序。看代碼備註2

class SoWhat{
    final int b;
    SoWhat(){
        b = 1412;
    }
    public static void main(String[] args) {
        SoWhat soWhat = new SoWhat();
        // 備註1:禁止在 b = 1 這個語句執行完之前,系統將新new出來的對象地址賦值給了sowhat。
        System.out.println(soWhat); //A
        System.out.println(soWhat.b); //B
        // 備註2: A  B 兩個指令不能重排序。
    }
}
複製代碼

final為引用類型時,增加了如下規則:

在構造函數內對一個final引用的對象的成員域的寫入,與隨後在構造函數外把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。

class SoWhat{
    final Object b;
    SoWhat(){
        this.b = new Object(); //  A
    }
    public static void main(String[] args) {
        SoWhat soWhat = new SoWhat(); //B
        // 含義是 必須A執行完畢了 才可以執行B
    }
}
複製代碼

final語義在處理器中的實現

會要求編譯器在final域的寫之後,構造函數return之前插入一個StoreStore屏障。

讀final域的重排序規則要求編譯器在讀final域的操作前面插入一個LoadLoad屏障。



作者:SoWhat1412
連結:https://juejin.im/post/5ea4f5596fb9a03c6a41881a

關鍵字: