Java內存模型辟邪劍譜之JVM-Java Memory Model

01導言

多線程、高併發問題相信是每一位從事Java研發工作的程序員都不可迴避的一個重要話題。從啓動一個線程,到使用volatile、synchronized、final關鍵字,到使用wait()、notify()、notifyAll()、join()方法,再到編寫複雜的多線程程序,不知道大家有沒有思考過這樣一個問題,爲什麼要使用這些API,或者說這些API到底給編程人員提供了什麼樣的保證,才使得在多線程環境下程序的運行結果能夠符合預期。它就是Java Memory Model(後續簡稱JMM)。本文就帶領大家一起,繞道這些API的背後,一探究竟。

推薦Spring Cloud分佈式微服務雲架構電子商務源碼

                    掃碼體驗

02約法三章-建立共識

探討任何話題都需要探討者站在一個共識基礎之上,否則探討將混亂不堪。正如一位名人曾經說過:”沒有共識的討論,都是擡槓“。我深以爲然,所以在探討JMM之前,需要建立以下幾點共識。

  • JMM只是一個抽象內存模型。
  • JMM和物理機內存模型不是一個範疇。
  • JMM和Java運行時數據區沒有直接對應關係。

03以史爲鑑-回看計算機內存模型

1、現代計算機內存模型

物理機遇到的併發問題與Java虛擬機中的情況有不少相似之處,物理機對併發問題的處理方案對虛擬機的實現也有相當大的參考價值。現代計算機中,CPU的指令速度遠遠超過內存的存取速度,由於計算機的存儲設備與CPU的運算速度有幾個數量級的差距,所以現在計算機中都不得不加入一層讀寫速度儘可能接近CPU運算速度的高速緩存(cache)來作爲內存和CPU之間的緩衝。

基於高速緩存的存儲交互很好的解決了CPU和內存的速度的矛盾,但也引入了一個新的問題,緩存一致性,在多處理器系統中,每個CPU都有自己的高速緩存,而他們又共享同一主內存,當多個處理器運算任務都涉及到同一塊主內存區域時,將可能導致各自的緩存數據不一致。爲了解決這個問題,需要各個處理器在訪問內存時,需要遵循一些協議,例如MSI、EMSI、MOSI等。
圖片

2、緩存一致性

爲了解決這個問題,先後有過兩種辦法:

  • 總線鎖機制

總線鎖就是使用CPU提供的一個LOCK#信號,當一個處理器在總線上輸出此信號,其他處理器的請求將被阻塞,那麼該處理器就可以獨佔共享鎖。這樣就保證了數據一致性。

  • 緩存鎖機制

但是總線鎖定開銷太大,我們需要控制鎖的力度,所以又有了緩存鎖,核心就是緩存一致性協議,不同的CPU硬件廠商實現方式稍有不同,有MSI、MESI、MOSI等。

3、多線程編程面臨的問題

多線程編程面臨的兩個重要的問題是:

  • 線程之間的通信
  • 線程之間的同步

線程之間的通信是指線程之間通過什麼方式來交換信息。

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

線程的通信方式:

  • 共享內存
  • 消息傳遞

在共享內存的併發模式裏,線程之間共享程序的公共狀態,線程之間通過讀-寫內存中的公共狀態來實現隱式通信。

在消息傳遞的併發模式裏,同步是顯式進行的,程序員必須顯式指定某個方法或某段代碼需要在線程之間互斥進行。

圖2 共享內存併發模型

在消息傳遞的併發模式裏,線程之間沒有公共狀態,線程之間必須明確發送消息來顯式進行通信。

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

圖片

04師夷長技-直面JSR133

1、JSR133是什麼

JSR-133規範,即Java內存模型與線程規範,由JSR-133專家組開發。JSR-133規範是JSR-176(定義Java平臺Tiger(5.0)發佈版的重要特性)的一部分。本規範的標準內容將合併到Java語言規範、Java虛擬機規範以及java.lang包的類說明中。

2、 JSR133傾訴的對象是誰

身邊好多同事反饋看不懂JSR133的內容,一方面是因爲文檔全部爲英文,並且包含大量的專業英語。另外一方面是沒有弄明白JSR133傾訴的對象到底是誰。如果弄明白的傾訴的對象,然後對號入座就能理解JSR133在說什麼。JSR133傾訴的對象有兩個,一個是使用者(程序員),另外一個是JMM的實現方(JVM)。面向程序員,JSR133通過happens-before規則給使用者提供了同步語義的保證。面向實現者,JSR133限制了編譯器和處理器的優化,如下圖4:

圖片

3、JSR133的主要內容是什麼

JSR133主要描述了JMM的主要的規則和限制,並詳細闡述了一些同步原語的內存語義,詳細的請查看下一章節,JSR133的目錄,如下圖5:

圖片

05抽絲剝繭-專注JMM

1 JMM內存模型概述

前面在第三章節,講述了共享內存和消息傳遞併發模型,java採用的是共享內存併發模型。

在java中,所有的實例域,靜態域和數組元素都存儲在堆內存中,堆內存在線程之間共享。局部變量,方法參數和異常處理器參數不會在線程之間共享,他們不會有內存可見性問題,也不受內存模型的影響。

java線程之間的通信由java內存模型(JMM)控制,JMM決定了一個線程對共享變量的寫入何時對另一個線程可見。JMM定義了多線程和主內存之間的抽象關係:線程之間的共享變量存儲在主內存中,每個線程都有一個私有的本地化內存,本地內存中存儲了該線程用以讀/寫共享變量的副本。本地內存只是JMM的抽象,並不真實存在,它涵蓋了緩存、寫緩衝區、寄存器以及其他的硬件和編譯器優化。java內存模型的抽象示意,如圖6:
圖片

2 重排序

在執行程序時,爲了提高性能,編譯器和處理器常常會對指令做重排序。總的來說重排序分成兩類:

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

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

這些重排序可能會導致多線程出現內存可見性問題。對於編譯器,JMM的編譯器重排序規則會禁止特定類型的編譯器重排序。對於處理器重排序,JMM的處理器重排序規則會要求Java編譯器在生成指令序列時,插入特定類型的內存屏障指令,通過內存屏障指令來禁止特定類型的處理器重排序。

JMM屬於語言級的內存模型,它確保在不同的編譯器和不同的處理器平臺上,通過禁止特定類型的編譯器重排序和處理器重排序,爲程序員提供一致的內存可見性保證。

重排序對多線程的影響

下面我們從一個很經典的代碼例子說明重排序的問題,代碼如下:

class RecordExample {
  int a = 0 ;
  boolean flag = false ;
  public void write(){
    a = 1 ;                 //步驟1
    flage = true ;        //步驟2
  }
  public void reader(){
    if(flag){              //步驟3
      int i = a * a;   //步驟4
  }
}

flag變量是個標記,用來標識變量a是否已被寫入。這裏假設有兩個線程A和B,A首先執行writer()方法,隨後B線程接着執行reader()。線程B在執行操作4時,能否看到線程A在操作1對共享變量a的寫入呢?

答案是:不一定能看到。

由於操作1和操作2沒有數據依賴關係,編譯器和處理器可以對這兩個操作重排序;同樣,操作3和操作4沒有數據依賴關係,編譯器和處理器也可以對這兩個操作重排序。當操作1和操作2重排序時,可能產生什麼效果?如下圖7。

圖片

如上圖,操作1和操作2做了重排序。程序執行時,線程A首先寫標記變量flag,隨後線程B讀取這個變量。由於條件判斷爲真,線程B將讀取變量a。此時,變量a還沒有被線程A寫入,在這裏多線程的語義被重排序破壞了!

3 原子性、可見性、有序性

原子性:

一個或多個操作,要麼全部執行且在執行過程中不被任何因素打斷,要麼全部不執行。在java中當我們討論一個操作具有原子性問題一般是指這個操作會被線程的隨機調度打斷。比如下面的操作:

int a = 1;                   //原子操作
int a = b;                   //非原子操作,分兩步操作第一步讀取b的值,第二部將b賦值a
int a = a + 1;               //非原子操作,分兩步操作第一步讀取a的值,第二部將計算結果賦值給a
a ++ ;                       //非原子操作,同上

JMM對原子性問題的保證如下:

自帶原子性保證:在java中,對基本數據類型的變量的讀取和賦值操作是原子性操作。

synchronized:synchronized可以保證邊界操作結果的原子性。synchronized可以防止多個線程併發的執行同一段代碼,從結果上保證原子性。

Lock鎖:Lock鎖保證原子性的原理和synchronized類似。

原子類操作:JDK提供了很多原子操作類來保證操作的原子性,例如基礎類型:AtomicXxx;引用類型AtomicReference等。原子類的底層是使用CAS機制,這個機制對原子性的保證和synchroinized有本質的區別。CAS機制保證了整個賦值操作是原子的不能被打斷,二synchronized只能保證代碼最終執行結果的正確性,也就是說,synchronized消除了原子性問題對代碼最後執行結果的影響。

可見性:

在多線程環境下,一個線程對共享變量的修改,不僅要對本線程可見,而且要對其他線程可見。造成可見性的主要原因是由於CPU多核心和高速緩存(L1,L2,L3)。JMM對可見性問題,提供瞭如下保證:

volatile:使用volatile關鍵字修飾一個變量可以保證變量的可見性,大概的保證語義如下(詳細的參看volatile的內存語義章節)

  • 線程對共享變量的副本做了修改,會立刻刷新最新值到主內存中。
  • 線程對共享變量的副本做了修改,其他其他線程中對這個變量拷貝的副本會時效;其他線程如果需要對這個共享變量進行讀寫,必須重新從主內存中加載。

synchronized:使用synchronized代碼塊或者synchronized方法也可以保證共享變量的可見性。當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中。當線程獲取鎖時,JMM會把該線程對應的本地內存置爲無效,從而使得被監聽器保護的臨界區代碼必須從主內存中讀取共享變量,從而實現共享變量的可見性。

Lock鎖:使用Lock相關實現類也可以保證共享變量的可見性。其原理同synchronized。

原子操作類:原子類底層使用的是CAS機制。java中CAS機制每次都會從主內存中獲取最新值進行compare,比較一致之後纔會將新值set到主內存中去。而且這個操作是一個原子操作,所以CAS每次操作每次拿到的都是主內存中的最新值,每次set的值也會立即寫到主內存中。

有序性:

程序執行的順序按照代碼的先後順序執行。在JMM允許的重排序環境下,單線程的執行結果和沒有重排序的情況下保持一致。JMM中提供一下方式來保證有序性:

happens-before原則:happens-before原則是java內存模型中定義的兩項操作之間的偏序關係,如果操作A先行發生於操作B,也就是說發生操作B之前,操作A產生的影響能被操作B觀察到。這裏的“影響”包括修改共享變量,方法調用。詳細的happens-before說明請參看happens-before原則章節。

synchronized機制:synchronized能夠保證有序性是因爲synchronized可以保證同一時間只有一個線程訪問代碼塊,而單線程環境下,JMM能夠保證代碼的串行語義;雖然使用synchronized的代碼塊,還可以發生指令重排序,但是synchronized可以保證只有一個線程執行,所以最後的結果還是正確的。

volatile機制:volatile的底層是使用內存屏障(詳細請參看內存屏障章節)來保障有序性的。寫volatile變量時,可以確保volatile寫之前的操作不會被編譯器重排序到volatile寫之後。讀volatile變量時,可以確保volatile讀之後的操作不會被編譯器重排序到volatile讀之前。

多線程面臨的兩個問題線程之間的通信和線程之間的同步,這兩個問題如果仔細分析,從結果的角度看線程之間的通信就是可見性問題,線程之間的同步就是原子性和有序性的問題。

總結JMM對特性提供的支持如下:

4 happens-before原則

JSR133使用happens-before來闡述操作之間的內存可見性。在JMM中,如果一個操作的結果需要對另一個操作可見,那麼這兩個操作之間必然要存在happens-before關係。這裏提到的兩個操作既可以是一個線程之內,也可以是不同線程之間。

在《併發編程的藝術》一書中,對happens-before的定義如下:

在JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須要存在happens-before關係。這裏提到的兩個操作既可以是一個線程之內,也可以是不同線程之間。兩個操作之間具有happens-before關係,並不意味着前一個操作必須要在後一個操作之前執行!happens-before僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前。

happens-before規則如下:

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

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

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

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

join()規則(Thread Join Rule):如果線程A執行線程B.join()併成功返回,那麼線程B中的任意操作happens-before於線程A從B.join()操作成功返回。

程序中斷規則(Thread Interruption Rule):對線程interrupt()的調用happens-before於被中斷線程的interrupted()或者isInterrupted()。

finalizer規則(Finalizer Rule):一個對象構造函數的結束happens-before於該對象finalizer()的開始。

傳遞性規則(Transitivity):如果A happens-before B,且B happens-before C ,那麼A happens-before C。

瞭解了happens-before原則,下面舉例幫助理解:

private int value = 0;
public void setValue(int value)
{
    this.value = value;
}
public int getValue()
{
    return value;
}

假設兩個線程A和B,線程A先(在時間上先)調用了這個對象的setValue(1),接着線程B調用了getValue()方法,那麼B的返回值是多少?

對照happens-before原則,上面的操作不滿下面的條件:

  • 不是同一個線程,所以不涉及:程序順序規則。
  • 不涉及同步,所以不涉及:監視器鎖規則。
  • 沒有volatile,所以不涉及:volatile變量規則。
  • 沒有線程的啓動和中斷,所以不涉及:start()規則,join規則,程序中斷規則。
  • 沒有對象的創建和終結,所以不涉及:finalizer規則。
  • 更沒有傳遞規則。

所以,一條規則都不滿足,儘管線程A在時間上與線程B具有先後順序,但是,卻不滿足happens-before原則,也就是有序性並不會保障,所以線程B獲取到的數據是不安全的!!!這也反向說明了happens-before原則提到的關係和時間的先後順序沒有關係。

時間先後順序與先行發生原則之間基本沒有太大關係,所以我們衡量併發安全問題的時候不要收到時間順序的干擾,一切必須以先行發生原則爲準。只有真正滿足了happens-before原則,才能保證安全。

5 內存屏障

內存屏障(Memory Barrier),也稱爲內存柵障,屏障指令等,是一類同步屏障指令,是CPU或編譯器在對內存隨機訪問的操作中的一個同步點,使得此點之前的所有讀寫操作都執行後纔可以執行此點之後的操作。大多數現代計算機爲了提高性能而採取亂序執行,這使得內存屏障成爲必須。

語義上,內存屏障之前的所有寫操作都要寫入內存;內存屏障之後的讀操作都可以獲得同步屏障之前的寫操作的結果。因此,對於敏感的程序塊,寫操作之後、讀操作之前可以插入內存屏障。

CPU層面的內存屏障

CPU層面的內存屏障分爲三類:

寫屏障(Store Memory Barrier):告訴處理器在寫屏障之前的所有已經存儲在存儲緩存(store bufferes)中的數據同步到主內存,簡單來說就是使得寫屏障之前的指令的結果對寫屏障之後的讀或者寫是可見的。

讀屏障(Load Memory Barrier):處理器在讀屏障之後的讀操作,都在讀屏障之後執行。配合寫屏障,使得寫屏障之前的內存更新對於讀屏障之後的讀操作是可見的。

全屏障(Full Memory Barrier):確保屏障前的內存讀寫操作的結果提交到內存之後,再執行屏障後的讀寫操作。

JMM層面的內存屏障

在JMM中將內存屏障分爲四類:LoadLoad Barrier;StoreStore Barrier;LoadStore Barrier;StoreLoad Barrier,內存屏障的詳細解釋如下圖8(圖片來源於《併發編程藝術》):

6 volatile的內存語義

volatile是java提供的一種輕量級的同步機制,在併發編程中,它也扮演着比較重要的角色。一方面volatile不會造成上下文切換的開銷,另一方面它又不能像synchronized那樣保證所有場景下線程安全,因此必須在合適的場景下使用volatile機制。前面一個章節,我們瞭解到volatile可以支持可見性和有序性,那麼它是通過怎樣的機制來實現這些特性的?其核心原理就是上一章節描述的內存屏障。

volatile寫-讀的內存語義

  • 當寫一個volatile變量時,JMM會把該線程對應的本地內存中的變量值刷新到主內存。
  • 當讀一個volatile變量時,JMM會把該線程對應的本地內存置爲無效,線程將從主內存中讀取共享變量。

volatile內存語義的實現

爲了實現volatile的內存語義,JMM會限制兩種類型的重排序,下圖是JMM針對編譯器指定的volatile重排序規則表:

  • 當第二個操作爲volatile寫操作時,不管第一個操作是什麼,都不能進行重排序。這個規則確保volatile寫之前的所有操作都不會被重排序到volatile寫之後。
  • 當第一個操作爲volatile讀操作時,不管第二個操作時什麼,都不能進行重排序。這個規則確保volatile讀之後的所有操作都不會被重排序到volatile讀之前。
  • 當第一個操作時volatile寫操作時,第二個操作時讀操作,不能進行重排序。

爲了實現以上規則,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序,下面是基於保守策略(根據不同虛擬機策略不同)的JMM內存屏障插入策略:

  • 在每個volatile寫操作的前面插入一個StoreStore屏障(禁止前面的寫與volatile寫重排序)。
  • 在每個volatile寫操作的後面插入一個StoreLoad屏障(禁止volatile寫與後面可能有的讀和寫重排序)。
  • 在每個volatile讀操作的後面插入一個LoadLoad屏障(禁止volatile讀與後面的讀操作重排序)。
  • 在每個volatile讀操作的後面插入一個LoadStore屏障(禁止volatile讀與後面的寫操作重排序)。

下圖爲volati  寫操作插入內存屏障後生成的指令序列示意圖。

圖片

上述volatile寫和volatile讀的內存屏障插入策略非常保守,在實際執行時,只要不改變volatile寫-讀的內存語義,編譯器可以根據具體情況忽略不必要的屏障。

7 final的內存語義

在平時的開發過程中常常使用final關鍵字來修飾方法,保證方法不能被子類重寫,那使用final修飾變量又表達什麼內存語義呢?

final的內存語義

  • 在構造函數內對一個final域的寫入,與隨後把這個構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。
  • 初次讀取一個包含final域對象的引用,與隨後初次讀取這個final域,這兩個操作之間不能重排序。

final的內存語義實現

  • 寫final域的重排序規則會要求編譯器在final域寫之後,構造函數返回之前,插入一個StoreStore屏障。
  • 讀final域的重排序規則會要求編譯器在final域讀之前插入一個LoadLoad屏障。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章