Java內存模型中volatile的內存語義及對同步的做用

原文發表於個人博客編程

volatile關鍵字: 使用volatile關鍵字修飾的的變量,總能「看到」任意線程對它最後的寫入,即總能保證任意線程在讀寫volatile修飾的變量時,老是從內存中讀取最新的值。如下是volatile在內存中的語義實現及同步的原理。數組

一:接觸內存模型

Java中的實例、靜態變量以及數組都存儲在堆內存中,可在線程之間共享。而Java進程間通訊由Java內存模型(JMM)控制,JMM能夠決定共享變量的寫入什麼時候對另外一個線程可見。(從JDK5開始,Java使用JSR-133內存模型,從該規定開始,即便是在32位的機器上,一個64位的double/long的讀操做也必須知足原子性)緩存

Java內存模型示意圖
[圖1.1]
本地內存是JMM抽象的一個概念多線程

二:順序一致性與重排序

從我學習編程語言開始,所認知的是「程序順序執行」。然而,順序一致性只是一種理想模型。從源代碼到機器指令的這一過程當中,編譯器和處理器每每會對指令作一些重排序從而提升性能,可是重排序會依據一個標準:併發

  • 不改變單線程程序語義app

  • 不影響數據依賴。編程語言

happens-before

若是一個操做的執行結果須要對另外一個操做可見,則兩個操做之間知足happens-before關係。happens-before具備傳遞性
對於一個volatile變量的寫操做,happens-before於任意後續對這個變量的讀性能

as-if-serial

as-if-serial規定,若是操做直接存在數據依賴關係,則不容許重排序。無論怎麼重排序,都必須遵照as-if-serial語義。學習

int a = 1;         //(1)
int b = 2;         //(2)
int c = a + b;     //(3)

上面的代碼中,(1)(2)之間不存在以來和happens-before關係,能夠重排序,而(1)(3)和(2)(3)之間都存在as-if-serial關係,不能重排序優化

as-if-serial保護單線程程程序的語義正確性,使咱們無需擔憂重排序對咱們的影響,也使咱們產生一種錯覺:單線程程序就是順序執行的。

拓展資料--重排序的三種類型:

 (1)編譯器優化重排序
 (2)指令集並行重排序
 (3)內存系統的重排序

三:多線程與重排序的思考

咱們將happens-before和as-if-serial的關係引入到多線程中。咱們能夠將多線程的全部操做想象成在時間軸上的順序執行的單線程程序。(如下流程圖使用Markdown語法繪製,有些地方不支持)

互不干涉的併發

在多線程的程序中,假如線程相互之間不涉及共享的變量,亦即互相不干涉,則兩個線程之間既沒有happens-before的關係,也沒有as-if-serial語義的約束,因此各個線程之間操做能夠任意合併重排序:

  • 線程A的執行流程

st=>start: 線程A
op1=>operation: op-a-1
op2=>operation: op-a-2
op3=>operation: op-a-3
e=>end
st->op1->op2->op3->e
  • 線程B的執行流程

st=>start: 線程B
op1=>operation: op-b-1
op2=>operation: op-b-2
op3=>operation: op-b-3
e=>end
st->op1->op2->op3->e
  • 併發的可能的執行順序

st=>start: 重排序
op1=>operation: op-a-1
op2=>operation: op-b-1
op3=>operation: op-b-2
op4=>operation: op-a-2
op5=>operation: op-a-3
op6=>operation: op-b-3
e=>end
st->op1->op2->op3->op4->op5->op6->e
共享變量的併發

當線程之間涉及到共享變量時,涉及到了線程之間的通訊,即如圖1.1所示,此時併發所存在的問題(髒讀、幻讀、不可重複讀)明顯可見,可是,若是線程沒有正確地同步(通訊),線程之間沒法明確共享變量什麼時候被寫入。由於此時所面對的問題就如將線程合併到時間軸上和重排序後是否違反happens-before和as-if-serial的語義了:

  • 線程A

st=>start: 線程A
op1=>operation: a讀共享變量x
op2=>operation: a寫共享變量x
e=>end
st->op1->op2->e
  • 線程B

st=>start: 線程B
op1=>operation: b讀共享變量x
op2=>operation: b寫共享變量x
e=>end
st->op1->op2->e
  • 假如不一樣步,程序可能的執行順序

st=>start: 重排序
op1=>operation: a讀共享變量x
op2=>operation: b寫共享變量x
op3=>operation: b讀共享變量x
op4=>operation: a寫共享變量x
e=>end
st->op1->op2->3->4->e

上面的程序執行順序很顯然有髒讀的問題,而程序併發執行的正確語義應該有以下兩種:

  • a讀共享變量x happens-before a寫共享變量x,a寫共享變量x happens-before b讀共享變量x, b讀共享變量x happens-before b寫共享變量x

  • b讀共享變量x happens-before b寫共享變量x,b寫共享變量x happens-before a讀共享變量x,a讀共享變量x happens-before a寫共享變量x

因此,爲程序保證併發操做的正確性,多線程對共享變量的非原子操做上,必須採用有效的通訊方式來使其對共享變量的操做對其它線程可見,這就引入了volatile同步方式。

四:從volatile的內存語義到鎖

JMM經過在指令序列中插入內存屏障來限制編譯器的指令重排序,實現volatile的內存語義

JSR-133中,對於volatile變量寫的內存屏障插入策略
  • 普通讀

  • 普通寫

  • StoreStore屏障:禁止前面的普通寫和volatile寫重排序,保證前面的普通變量寫從本地內存緩存刷新到主存中

  • volatile寫

  • StoreLoad屏障:防止volatile寫和下面有可能出現的volatile讀發生重排序

JSR-133中,對於volatile變量讀的內存屏障插入策略
  • volatile讀

  • LoadLoad屏障:禁止下面的普通讀和volatile讀重排序

  • LoadStore屏障:禁止下面的普通寫和volatile讀重排序

內存屏障對同步的做用

從上面咱們能夠看到,經過volatile關鍵字,構建了happens-before的關係,限制普通變量和volatile變量讀寫的操做指令重排序,有效保證了程序語義的正確性。從下面圖咱們能夠進一步分析:
volatile使線程之間的對共享變量操做的同步
[圖4.2 volatile使線程之間的對共享變量操做的同步]

因此,volatile變量的寫-讀操做語義和Lock的獲取-釋放語義相同(這是JSR-133對volatile內存語義加強後的),使用volatile咱們亦能夠靈活、輕量地實現對共享(普通)變量的同步:

volatile [volatile static int a = 1] Lock
讀volatile變量:while(a!=1); Lock acquire()
操做臨界資源(共享變量) 操做臨界資源(共享變量)
寫volatile變量[a=1] Lock release()

寫volatile變量時,會將共享變量的本地內存中的修改刷新到主存中

要想使用volatile徹底代替鎖還需謹慎,volatile比較難像鎖同樣能夠很好地保證整個臨界區域代碼的原子性

  • vloatile 保證對單個volatile變量讀/寫的原子性

  • 保證臨界區域互斥執行

可是,volatile的內存語義爲咱們提供了鎖的思路,正如上面表格中使用volatile模仿Lock進行同步,既保證了臨界區域的互斥執行,又保證了任意線程對共享變量修改及時刷新到主內存中,保證了線程間有效通訊從而避免併發操做臨界資源的一些問題。

從volatile到鎖的思考

鎖可讓臨界區域互斥執行,那麼線程之間必然存在一個同步的機制。

(1)volatile讀 -----> |屏障|--->臨界操做--->|屏障|--->volatile寫,成對的volatile構建了happens-before的關係,而且保證了普通共享變量在volatile寫以前刷新到主內存中

(2)結合線程間通訊的方式
線程間通訊的方式
[圖4.3 線程間通訊的方式]

上圖的線程A對共享變量的寫和線程B的讀共享變量,有單線程程序的順序一致性效果,此時咱們能夠想到volatile的做用。經過volatile變量,能夠實現從A線程發送通知到B線程而且可以保證happens-before的語義正確性(在併發時就很好理解爲何happens-before並不要求前一個操做必定要在後一個操做以前執行,只須要前一個操做的結果對後一個操做可見),此時咱們可推出鎖獲取和釋放的內存語義:

  • 線程A釋放一個鎖,即線程A向接下來要獲取鎖的線程B發出消息(A修改共享的變量)

  • 線程B獲取一個鎖,即線程B接收到以前某個線程發出的消息(共享變量發生變化)

從一個線程釋放鎖,到另外一個線程釋放鎖,其實是兩條線程經過主線程同步對共享變量的操做,經過主內存相互通訊。因此,在Java編碼上實現鎖的內存語義,能夠經過對一個volatile變量的讀寫,來實現線程之間相互通知,保證臨界區域代碼的互斥執行。

[以上內容參考了《Java併發編程的藝術》,可能有謬誤,歡迎指正]

相關文章
相關標籤/搜索