99%的人沒弄懂Volatile的設計原理,更別說靈活運用了
本文轉載自微信公眾號「程序新視界」,作者二師兄。轉載本文請聯(lián)系程序新視界公眾號。
寫volatile的文章非常多,本人也看過許多相關文章,但始終感覺有哪里不太明白,但又說不上來說為什么??赡苁沁^于追求底層實現(xiàn)原理,老想問一個為什么吧。
而寫這篇文章的目的很簡單,就是突然之間明白了volatile為什么要這樣設計了。好東西當然要拿出來分享了,于是就有了這篇文章。
我們就從硬件到軟件,再到具體的案例來聊聊volatile的底層原理,文章比較長,可收藏之后閱讀。
CPU緩存的出現(xiàn)
最初的CPU是沒有緩存區(qū)的,CPU直接讀寫內存。但這就存在一個問題,CPU的運行效率與讀寫內存的效率差距百倍以上??偛荒蹸PU執(zhí)行1個寫操作耗時1個時鐘周期,然后再等待內存執(zhí)行一百多個時鐘周期吧。
于是在CPU和內存之間添加了緩存(CPU緩存:Cache Memory),它是位于CPU和內存之間的臨時存儲器。這就像當Mysql出現(xiàn)瓶頸時,我們會考慮通過緩存數(shù)據(jù)來提升性能一樣??傊珻PU緩存的出現(xiàn)就是為了解決CPU和內存之間處理速度不匹配的問題而誕生的。
這時,我們有一個粗略的圖:
CPU-CPU緩存-內存
但考慮到進一步優(yōu)化數(shù)據(jù)的調度,CPU緩存又分為一級緩存、二級緩存、三級緩存等。它們主要用于優(yōu)化數(shù)據(jù)的吞吐和暫存,提高執(zhí)行效率。
目前主流CPU通常采用三層緩存:
- 一級緩存(L1 Cache):主要用于緩存指令(L1P)和緩存數(shù)據(jù)(L1D),指令和數(shù)據(jù)是分開存儲的。一級緩存屬于核心獨享,比如4核電腦,則有4個L1。
- 二級緩存(L2 Cache):二級緩存的指令和數(shù)據(jù)是共享的,二級緩存的容量會直接影響CPU的性能,越大越好。二級緩存同樣屬于核心獨享,4核心電腦,則有4個L2。
- 三級緩存(L3 Cache):作用是進一步降低內存的延遲,同時提升海量數(shù)據(jù)計算的性能。三級緩存屬于核心共享的,因此只有1個。
經過上述細分,可以將上圖進一步細化:
CPU三級緩存
這里再補充一個概念:緩存行(Cache-line),它是CPU緩存存儲數(shù)據(jù)的最小單位,后面會用到。上面的CPU緩存,也稱作高速緩存。
引入緩存之后,每個CPU的處理過程為:先將計算所需數(shù)據(jù)緩存在高速緩存中,當CPU進行計算時,直接從高速緩存讀取數(shù)據(jù),計算完成再寫入緩存中。當整個運算過程完成之后,再把緩存中的數(shù)據(jù)同步到主內存中。
如果是單核CPU這樣處理沒有什么問題。但在多核系統(tǒng)中,每個CPU都可能將同一份數(shù)據(jù)緩存到自己的高速緩存中,這就出現(xiàn)了緩存數(shù)據(jù)一致性問題了。
CPU層提供了兩種解決方案:總線鎖和緩存一致性。
總線鎖
前端總線(也叫CPU總線)是所有CPU與芯片組連接的主干道,負責CPU與外界所有部件的通信,包括高速緩存、內存、北橋,其控制總線向各個部件發(fā)送控制信號、通過地址總線發(fā)送地址信號指定其要訪問的部件、通過數(shù)據(jù)總線雙向傳輸。
比如CPU1要操作共享內存數(shù)據(jù)時,先在總線上發(fā)出一個LOCK#信號,其他處理器就不能操作緩存了該共享變量內存地址的緩存,也就是阻塞了其他CPU,使該處理器可以獨享此共享內存。
很顯然,這樣的做法代價十分昂貴,于是為了降低鎖粒度,CPU引入了緩存鎖。
緩存一致性協(xié)議
緩存一致性:緩存一致性機制整體來說,就是當某塊CPU對緩存中的數(shù)據(jù)進行操作了之后,會通知其他CPU放棄儲存在它們內部的緩存,或者從主內存中重新讀取。
緩存鎖的核心機制就是基于緩存一致性協(xié)議來實現(xiàn)的,即一個處理器的緩存回寫到內存會導致其他處理器的緩存無效,IA-32處理器和Intel 64處理器使用MESI實現(xiàn)緩存一致性協(xié)議。
緩存一致性是一個協(xié)議,不同處理器的具體實現(xiàn)會有所不同,MESI是一種比較常見的緩存一致性協(xié)議實現(xiàn)。
MESI協(xié)議
MESI協(xié)議是以緩存行的幾個狀態(tài)來命名的(全名是Modified、Exclusive、Share or Invalid)。該協(xié)議要求在每個緩存行上維護兩個狀態(tài)位,每個數(shù)據(jù)單位可能處于M、E、S和I這四種狀態(tài)之一,各種狀態(tài)含義如下:
- M(Modified):被修改的。該狀態(tài)的數(shù)據(jù),只在本CPU緩存中存在,其他CPU沒有。同時,對于內存中的值來說,是已經被修改了,但還沒更新到內存中去。也就是說緩存中的數(shù)據(jù)和內存中的數(shù)據(jù)不一致。
- E(Exclusive):獨占的。該狀態(tài)的數(shù)據(jù),只在本CPU緩存中存在,且并沒有被修改,與內存數(shù)據(jù)一致。
- S(Share):共享的。該狀態(tài)的數(shù)據(jù),在多個CPU緩存中同時存在,且與內存數(shù)據(jù)一致。
- I(Invalid):無效的。本CPU中的這份緩存數(shù)據(jù)已經失效。
其中上述狀態(tài)隨著不同CPU的操作還會進行不停的變更:
一個處于M狀態(tài)的緩存行,必須時刻監(jiān)聽所有試圖讀取該緩存行對應的主存地址的操作,如果監(jiān)聽到,則必須在此操作執(zhí)行前把其緩存行中的數(shù)據(jù)寫回CPU。
一個處于S狀態(tài)的緩存行,必須時刻監(jiān)聽使該緩存行無效或者獨享該緩存行的請求,如果監(jiān)聽到,則必須把其緩存行狀態(tài)設置為I。
一個處于E狀態(tài)的緩存行,必須時刻監(jiān)聽其他試圖讀取該緩存行對應的主存地址的操作,如果監(jiān)聽到,則必須把其緩存行狀態(tài)設置為S。
對于MESI協(xié)議,從CPU讀寫角度來說會遵循以下原則:
CPU讀數(shù)據(jù):當CPU需要讀取數(shù)據(jù)時,如果其緩存行的狀態(tài)是I的,則需要從內存中讀取,并把自己狀態(tài)變成S,如果不是I,則可以直接讀取緩存中的值,但在此之前,必須要等待其他CPU的監(jiān)聽結果,如其他CPU也有該數(shù)據(jù)的緩存且狀態(tài)是M,則需要等待其把緩存更新到內存之后,再讀取。
CPU寫數(shù)據(jù):當CPU需要寫數(shù)據(jù)時,只有在其緩存行是M或者E的時候才能執(zhí)行,否則需要發(fā)出特殊的RFO指令(Read Or Ownership,這是一種總線事務),通知其他CPU設置緩存無效(I),這種情況下性能開銷是相對較大的。在寫入完成后,修改其緩存狀態(tài)為M。
當引入總線鎖或緩存一致性協(xié)議之后,CPU、緩存、內存的結構變?yōu)橄聢D:
CPU-緩存-總線-內存
MESI協(xié)議帶來的問題
在上述MESI協(xié)議的交互過程中,我們已經可以看到在各個CPU之間存在大量的消息傳遞(監(jiān)聽處理)。而緩存的一致性消息傳遞是需要時間的,這就使得切換時會產生延遲。一個CPU對緩存中數(shù)據(jù)的改變,可能需要獲得其他CPU的回執(zhí)之后才能繼續(xù)進行,在這期間處于阻塞狀態(tài)。
Store Bufferes
等待確認的過程會阻塞處理器,降低處理器的性能。而且這個等待遠遠比一個指令的執(zhí)行時間長的多。為了避免資源浪費,CPU又引入了存儲緩存(Store Bufferes)。
基于存儲緩存,CPU將要寫入內存數(shù)據(jù)先寫入Store Bufferes中,同時發(fā)送消息,然后就可以繼續(xù)處理其他指令了。當收到所有其他CPU的失效確認(Invalidate Acknowledge)時,數(shù)據(jù)才會最終被提交。
舉例說明一下Store Bufferes的執(zhí)行流程:比如將內存中共享變量a的值由1修改為66。
第一步,CPU-0把a=66寫入Store Bufferes中,然后發(fā)送Invalid消息給其他CPU,無需等待其他CPU相應,便可繼續(xù)執(zhí)行其他指令了。
store bufferes
第二步,當CPU-0收到其他所有CPU對Invalid通知的相應之后,再把Store Bufferes中的共享變量同步到緩存和主內存中。
store Bufferes
Store Forward(存儲轉發(fā))
Store Bufferes的引入提升了CPU的利用效率,但又帶來了新的問題。在上述第一步中,Store Bufferes中的數(shù)據(jù)還未同步到CPU-0自己的緩存中,如果此時CPU-0需要讀取該變量a,緩存中的數(shù)據(jù)并不是最新的,所以CPU需要先讀取Store Bufferes中是否有值。如果有則直接讀取,如果沒有再到自己緩存中讀取,這就是所謂的”Store Forward“。
失效隊列
CPU將數(shù)據(jù)寫入Store Bufferes的同時還會發(fā)消息給其他CPU,由于Store Bufferes空間較小,且其他CPU可能正在處理其他事情,沒辦法及時回復,這個消息就會陷入等待。
為了避免接收消息的CPU無法及時處理Invalid失效數(shù)據(jù)的消息,造成CPU指令等待,就在接收CPU中添加了一個異步消息隊列。消息發(fā)送方將數(shù)據(jù)失效消息發(fā)送到這個隊列中,接收CPU返回已接收,發(fā)送方CPU就可以繼續(xù)執(zhí)行后續(xù)操作了。而接收方CPU再慢慢處理”失效隊列“中的消息。
內存屏障
CPU經過上述的一系列優(yōu)化,既保證了效率又確保了緩存的一致性,大多數(shù)情況下也是可以接受CPU基于Store Bufferes和失效隊列異步處理的短暫延遲的。
但在多線程的極端情況下,還是會產生緩存數(shù)據(jù)不一致的情況的。比如上述實例中,CPU-0修改數(shù)據(jù),發(fā)消息給其他CPU,其他CPU消息隊列接收成功并返回。這時CPU-1正忙著處理其他業(yè)務,沒來得及處理消息隊列,而CPU-1處理的業(yè)務中恰好又用到了變量a,此時就會造成讀取到的a值為舊值。
這種因為CPU緩存優(yōu)化導致后面的指令無法感知到前面指令的執(zhí)行結果,看起來就像指令之間的執(zhí)行順序錯亂了一樣,對于這種現(xiàn)象我們俗稱“CPU亂序執(zhí)行”。
亂序執(zhí)行是導致多線程下程序Bug的原因,解決方案很簡單:禁用CPU緩存優(yōu)化。但大多數(shù)情況下的數(shù)據(jù)并不存在共享問題,直接禁用會導致整體性能下降,得不償失。于是就提供了針對多線程共享場景的解決機制:內存屏障機制。
使用內存屏障后,寫入數(shù)據(jù)時會保證所有指令都執(zhí)行完畢,這樣就能保證修改過的數(shù)據(jù)能夠即時暴露給其他CPU。而讀取數(shù)據(jù)時,能夠保證所有“失效隊列”消息都消費完畢。然后,CPU根據(jù)Invalid消息判斷自己緩存狀態(tài),正確讀寫數(shù)據(jù)。
CPU層面的內存屏障
CPU層面提供了三類內存屏障:
- 寫屏障(Store Memory Barrier):告訴處理器在寫屏障之前將所有存儲在存儲緩存(store bufferes)中的數(shù)據(jù)同步到主內存。也就是說當看到Store Barrier指令,就必須把該指令之前所有寫入指令執(zhí)行完畢才能繼續(xù)往下執(zhí)行。
- 讀屏障(Load Memory Barrier):處理器在讀屏障之后的讀操作,都在讀屏障之后執(zhí)行。也就是說在Load屏障指令之后就能夠保證后面的讀取數(shù)據(jù)指令一定能夠讀取到最新的數(shù)據(jù)。
- 全屏障(Full Memory Barrier):兼具寫屏障和讀屏障的功能。確保屏障前的內存讀寫操作的結果提交到內存之后,再執(zhí)行屏障后的讀寫操作。
下面通過一段偽代碼來進行說明:
- public class Demo {
- int value;
- boolean isFinish;
- void cpu0(){
- value = 10; // S->I狀態(tài),將value寫入store bufferes,通知其他CPU value緩存失效
- storeMemoryBarrier(); // 插入寫屏障,將value=10強制寫入主內存
- isFinish = true; // E狀態(tài)
- }
- void cpu1(){
- if (isFinish){ // true
- loadMemoryBarrier(); //插入讀屏障,強制cpu1從主內存中獲取最新數(shù)據(jù)
- System.out.println(value == 10); // true
- }
- }
- void storeMemoryBarrier(){//寫屏障
- }
- void loadMemoryBarrier(){//讀屏障
- }
- }
上述實例中通過內存屏障防止了指令重排,能夠得到預期的結果。
總之,內存屏障的作用可以通過防止CPU亂序執(zhí)行來保證共享數(shù)據(jù)在多線程下的可見性。那么,在JVM中是如何解決該問題的呢?也就是編程人員如何進行控制呢?這就涉及到我們要講的volatile關鍵字了。
Java內存模型
內存屏障解決了CPU緩存優(yōu)化導致的指令執(zhí)行的順序性和可見性問題,但不同的硬件系統(tǒng)提供的“內存屏障”指令又有所不同,作為開發(fā)人員也沒必要熟悉所有的內存屏障指令。而Java將不同的內存屏障指令進行了統(tǒng)一封裝,開發(fā)人員只需關注程序邏輯開發(fā)和內存屏障規(guī)范即可。
這套封裝解決方案的模型就是我們常說的Java內存模型(Java Memory Model),簡稱JMM。JMM最核心的價值便在于解決可見性和有序性,它是對硬件模型的抽象,定義了共享內存中多線程程序讀寫操作的行為規(guī)范。
這套規(guī)范通過限定對內存的讀寫操作從而保證指令的正確性,解決了CPU多級緩存、處理器優(yōu)化、指令重排序導致的內存訪問問題,保證了并發(fā)場景下的可見性。
本質上,JMM是把硬件底層的問題抽象到了JVM層面,屏蔽了各個平臺的硬件差異,然后再基于CPU層面提供的內存屏障指令以及限制編譯器的重排序來解決并發(fā)問題的。
JMM抽象模型結構
JMM抽象模型中將內存分為主內存和工作內存:
- 主內存:所有線程共享,存儲實例對象、靜態(tài)字段、數(shù)組對象等存儲在堆中的變量。
- 工作內存:每個線程獨享,線程對變量的所有操作都必須在工作內存中進行。
線程是CPU調度的最小單位,線程之間的共享變量值的傳遞都必須通過主內存來完成。
JMM抽象模型結構圖如下:
JMM抽象模型
JMM內存模型簡單概述就是:
- 所有變量存儲在主內存;
- 每條線程擁有自己的工作內存,其中保存了主內存中線程使用到的變量的副本;
- 線程不能直接讀寫主內存中的變量,所有操作均在工作內存中完成;
如果線程A需要與線程B進行通信,則線程A先把本地緩存中的數(shù)據(jù)更新到主內存,再由線程B從主內存中進行獲取。JMM通過控制主內存與每個線程的本地內存之間的交互,來為Java程序提供內存可見性保證。
編譯器指令重排
除了硬件層面的指令重排,Java編譯器為了提升性能,也會對指令進行重排。Java規(guī)范規(guī)定JVM線程內部維持順序化語義,即只要程序的最終結果與它順序化執(zhí)行的結果相等,那么指令的執(zhí)行順序可以與代碼順序不一致,此過程叫指令的重排序。
JVM能根據(jù)處理器特性(CPU多級緩存系統(tǒng)、多核處理器等)適當?shù)膶C器指令進行重排序,使機器指令能更符合CPU的執(zhí)行特性,最大限度的發(fā)揮機器性能。
從源碼到最終執(zhí)行示例圖:
指令重排序
其中2和3屬于CPU執(zhí)行階段的重排序,1屬于編譯器階段的重排序。編譯器會遵守happens-before規(guī)則和as-if-serial語義的前提下進行指令重排。
happens-before規(guī)則:如果A happens-before B,且B happens-before C,則需要保證A happens-before C。
as-if-serial語義:不管怎么重排序(編譯器和處理器為了提高并行度),(單線程)程序的執(zhí)行結果不能被改變。編譯器、Runtime和處理器都必須遵守as-if-serial語義。
對于處理器重排序,JMM要求Java編譯器在生成指令序列時,插入特定類型的內存屏障指令,來禁止特定類型的處理重排序。
JMM的內存屏障
上面了解了CPU的內存屏障分類,在JMM中把內存屏障分為四類:
- LoadLoad Barriers:示例,Load1;LoadLoad;Load2,確保Load1數(shù)據(jù)的裝載先于Load2及所有后續(xù)指令的裝載;
- StoreStore Barriers:示例,Store1;StoreStore;Store2,確保Store1數(shù)據(jù)對其他處理器可見(刷新到內存)先于Store2及所有后續(xù)存儲指令的存儲;
- LoadStore Barriers:示例,Load1;LoadStore;Store2,確保Load1數(shù)據(jù)裝載先于Store2及所有后續(xù)存儲指令刷新到內存;
- StoreLoad Barriers:示例,Store1;StoreLoad;Load2,確保Store1數(shù)據(jù)對其他處理器變得可見(刷新到內存)先于Load2及所有后續(xù)裝載指令的裝載。StoreLoad Barriers會使該屏障之前的所有內存訪問指令(存儲和裝載指令)完成之后,才執(zhí)行該屏障之后的內存訪問指令。
其中,StoreLoad Barriers同時具有前3個的屏障的效果,但性能開銷很大。
為了實現(xiàn)volatile內存語義,JMM會分別限制這兩種類型的重排序類型。下圖是JMM針對編譯器制定的volatile重排序規(guī)則表。
JMM重排序
從圖中可以得出一個基本規(guī)則:
- 當?shù)诙€操作是volatile寫時,不管第一個操作是什么,都不能重排序。這個規(guī)則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之后。
- 當?shù)谝粋€操作是volatile讀時,不管第二個操作是什么,都不能重排序。這個規(guī)則確保volatile讀之后的操作不會被編譯器重排序到volatile讀之前。
- 當?shù)谝粋€操作是volatile寫,第二個操作是volatile讀時,不能重排序。
為了實現(xiàn)volatile的內存語義,編譯器在生成字節(jié)碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。對于編譯器來說,發(fā)現(xiàn)一個最優(yōu)布置來最小化插入屏障的總數(shù)幾乎不可能。為此,JMM采取保守策略。下面是基于保守策略的JMM內存屏障插入策略:
- 在每個volatile寫操作的前面插入一個StoreStore屏障。
- 在每個volatile寫操作的后面插入一個StoreLoad屏障。
- 在每個volatile讀操作的后面插入一個LoadLoad屏障。
- 在每個volatile讀操作的后面插入一個LoadStore屏障。
保守策略下volatile寫插入內存屏障后生成的指令序列示意圖:
volatile寫屏障
保守策略下volatile讀插入內存屏障后生成的指令序列示意圖:
volatile讀內存屏障
JMM對volatile的特殊規(guī)則定義
JVM內存指令與volatile相關的操作有:
- read(讀取):作用于主內存變量,把一個變量值從主內存?zhèn)鬏數(shù)骄€程的工作內存中,以便隨后的load動作使用;
- load(載入):作用于工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中;
- use(使用):作用于工作內存的變量,把工作內存中的一個變量值傳遞給執(zhí)行引擎,每當虛擬機遇到一個需要使用變量的值的字節(jié)碼指令時將會執(zhí)行這個操作;
- assign(賦值):作用于工作內存的變量,它把一個從執(zhí)行引擎接收到的值賦值給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節(jié)碼指令時執(zhí)行這個操作;
- store(存儲):作用于工作內存的變量,把工作內存中的一個變量的值傳送到主內存中,以便隨后的write的操作;
- write(寫入):作用于主內存的變量,它把store操作從工作內存中一個變量的值傳送到主內存的變量中;
在對volatile修飾的變量進行操作時,需滿足以下規(guī)則:
- 規(guī)則1:線程對變量執(zhí)行的前一個動作是load時才能執(zhí)行use,反之只有后一個動作是use時才能執(zhí)行l(wèi)oad。線程對變量的read,load,use動作關聯(lián),必須連續(xù)一起出現(xiàn)。這保證了線程每次使用變量時都需要從主存拿到最新的值,保證了其他線程修改的變量本線程能看到。
- 規(guī)則2:線程對變量執(zhí)行的前一個動作是assign時才能執(zhí)行store,反之只有后一個動作是store時才能執(zhí)行assign。線程對變量的assign,store,write動作關聯(lián),必須連續(xù)一起出現(xiàn)。這保證了線程每次修改變量后都會立即同步回主內存,保證了本線程修改的變量其他線程能看到。
- 規(guī)則3:有線程T,變量V、變量W。假設動作A是T對V的use或assign動作,P是根據(jù)規(guī)則2、3與A關聯(lián)的read或write動作;動作B是T對W的use或assign動作,Q是根據(jù)規(guī)則2、3與B關聯(lián)的read或write動作。如果A先與B,那么P先與Q。這保證了volatile修飾的變量不會被指令重排序優(yōu)化,代碼的執(zhí)行順序與程序的順序相同。
volatile實例及分析
通過上面的分析關于volatile關鍵詞的來源,以及被它修飾的變量的可見性和有序性都從理論層面講解清楚了。下面看一個可見性的實例。
示例代碼如下:
- public class VolatileTest {
- private boolean initFlag = false;
- public static void main(String[] args) throws InterruptedException {
- VolatileTest sample = new VolatileTest();
- Thread threadA = new Thread(sample::refresh, "threadA");
- Thread threadB = new Thread(sample::load, "threadB");
- threadB.start();
- Thread.sleep(2000);
- threadA.start();
- }
- public void refresh() {
- this.initFlag = true;
- System.out.println("線程:" + Thread.currentThread().getName() + ":修改共享變量initFlag");
- }
- public void load() {
- int i = 0;
- while (!initFlag) {
- }
- System.out.println("線程:" + Thread.currentThread().getName() + "當前線程嗅探到initFlag的狀態(tài)的改變" + i);
- }
- }
根據(jù)上面的理論知識,先猜測一下線程先后打印出的內容是什么?先打印”線程threadA修改共享變量initFlag“,然后打印”線程threadB當前線程嗅探到initFlag的狀態(tài)的改變0“?
當真正執(zhí)行程序時,會發(fā)現(xiàn)整個線程阻塞在while循環(huán)處,并未打印出第2條內容。此時JMM操作如下圖:
thread-without-volatile
雖然線程A中將initFlag改為了true并且最終會同步回主內存,但是線程B中循環(huán)讀取的initFlag一直都是從工作內存讀取的,所以會一直進行死循環(huán)無法退出。
當對變量initFlag添加了volatile修飾之后:
- public class VolatileTest {
- private volatile boolean initFlag = false;
- //...
- }
JMM操作如下圖:
thread-with-volatile
添加了volatile修飾之后,兩句日志都會被打印出來。這是因為添加volatile關鍵字后,就會有l(wèi)ock指令,使用緩存一致性協(xié)議,線程B中會一直嗅探initFlag是否被改變,線程A修改initFlag后會立即同步回主內存,同時通知線程B將緩存行狀態(tài)改為I(無效狀態(tài)),重新從主內存讀取。
volatile無法保證原子性
volatile雖然保證了共享變量的可見性和有序性,但并不能夠保證原子性。
以常見的自增操作(count++)為例來進行說明,通常自增操作底層是分三步的:
- 第一步:獲取變量count;
- 第二步:count加1;
- 第三步:回寫count。
我們來分析一下在這個過程中會有的線程安全問題:
第一步,線程A和B同時獲得count的初始值,這一步沒什么問題;
第二步,線程A自增count并回寫,但線程B此時也已經拿到count,不會再去拿線程A回寫的值,因此對原始值進行自增并回寫,這就導致了線程安全的問題。有人可能要問了,線程A自增之后不是應該通知其他CPU緩存失效嗎,并重新load嗎?我們要知道,重新獲取的前提操作是讀,在線程A回寫時,線程B已經拿到了count的值,并不存在再次讀的場景。也就是說,線程B的緩存行的確會失效,但線程B中count值已經運行在加法指令中,不存在需要再次從緩存行讀的場景。
volatile關鍵字只保證可見性,所以在以下情況中,需要使用鎖來保證原子性:
- 運算結果依賴變量的當前值,并且有不止一個線程在修改變量的值。
- 變量需要與其他狀態(tài)變量共同參與不變約束
所以,想要使用volatile變量提供理想的線程安全,必須同時滿足兩個條件:
- 對變量的寫操作不依賴于當前值。
- 該變量沒有包含在具有其他變量的不變式中。
也就是說被修飾的變量值獨立于任何程序的狀態(tài),包括變量的當前狀態(tài)。
volatile適用場景
狀態(tài)標志
使用一個布爾狀態(tài)標志,用于指示發(fā)生了一個重要的一次性事件,例如完成初始化或請求停機。
- volatile boolean shutdownRequested;
- ...
- public void shutdown() {
- shutdownRequested = true;
- }
- public void doWork() {
- while (!shutdownRequested) {
- // do stuff
- }
- }
線程1執(zhí)行doWork()的過程中,線程2可能調用了shutdown,所以boolean變量必須是volatile。
這種狀態(tài)標記的一個公共特性是:通常只有一種狀態(tài)轉換;shutdownRequested 標志從false 轉換為true,然后程序停止。這種模式可以擴展到來回轉換的狀態(tài)標志,但是只有在轉換周期不被察覺的情況下才能擴展(從false 到true,再轉換到false)。此外,還需要某些原子狀態(tài)轉換機制,例如原子變量。
一次性安全發(fā)布
在缺乏同步的情況下,可能會遇到某個對象引用的更新值(由另一個線程寫入)和該對象狀態(tài)的舊值同時存在。
這種場景在著名的雙重檢查鎖定(double-checked-locking)中會出現(xiàn):
- //注意volatile!
- private volatile static Singleton instace;
- public static Singleton getInstance(){
- //第一次null檢查
- if(instance == null){
- synchronized(Singleton.class) { //1
- //第二次null檢查
- if(instance == null){ //2
- instance = new Singleton();//3
- }
- }
- }
- return instance;
- }
其中第3步中實例化Singleton分多步執(zhí)行(分配內存空間、初始化對象、將對象指向分配的內存空間),某些編譯器為了性能原因,會將第二步和第三步進行重排序(分配內存空間、將對象指向分配的內存空間、初始化對象)。這樣,某個線程可能會獲得一個未完全初始化的實例。
獨立觀察(independent observation)
場景:定期 “發(fā)布” 觀察結果供程序內部使用。比如,傳感器感知溫度,一個線程每隔幾秒讀取一次傳感器,并更新當前的volatile修飾變量。其他線程可以讀取這個變量,隨時看到最新溫度。
另一種場景就是應用程序搜集統(tǒng)計信息。比如記錄最后一次登錄的用戶名,反復使用lastUser引用來發(fā)布值,以供其他程序使用。
- public class UserManager {
- public volatile String lastUser; //發(fā)布的信息
- public boolean authenticate(String user, String password) {
- boolean valid = passwordIsValid(user, password);
- if (valid) {
- User u = new User();
- activeUsers.add(u);
- lastUser = user;
- }
- return valid;
- }
- }
“volatile bean” 模式
volatile bean 模式的基本原理是:很多框架為易變數(shù)據(jù)的持有者(例如 HttpSession)提供了容器,但是放入這些容器中的對象必須是線程安全的。在 volatile bean 模式中,JavaBean 的所有數(shù)據(jù)成員都是 volatile 類型的,并且 getter 和 setter 方法必須非常普通——即不包含約束。
- @ThreadSafe
- public class Person {
- private volatile String firstName;
- private volatile String lastName;
- private volatile int age;
- public String getFirstName() { return firstName; }
- public String getLastName() { return lastName; }
- public int getAge() { return age; }
- public void setFirstName(String firstName) {
- this.firstName = firstName;
- }
- public void setLastName(String lastName) {
- this.lastName = lastName;
- }
- public void setAge(int age) {
- this.age = age;
- }
- }
開銷較低的“讀-寫鎖”策略
如果讀操作遠遠超過寫操作,可以結合使用內部鎖和 volatile 變量來減少公共代碼路徑的開銷。
如下線程安全的計數(shù)器代碼,使用 synchronized 確保增量操作是原子的,并使用 volatile 保證當前結果的可見性。如果更新不頻繁的話,該方法可實現(xiàn)更好的性能,因為讀路徑的開銷僅僅涉及 volatile 讀操作,這通常要優(yōu)于一個無競爭的鎖獲取的開銷。
- @ThreadSafe
- public class CheesyCounter {
- // Employs the cheap read-write lock trick
- // All mutative operations MUST be done with the 'this' lock held
- @GuardedBy("this") private volatile int value;
- //讀操作,沒有synchronized,提高性能
- public int getValue() {
- return value;
- }
- //寫操作,必須synchronized。因為x++不是原子操作
- public synchronized int increment() {
- return value++;
- }
使用鎖進行有變化的操作,使用volatile進行只讀操作。volatile允許多個線程同時執(zhí)行讀操作。
小結
本文先從硬件層面分析CPU的處理機制,為了優(yōu)化CPU引入了緩存,為了更進一步優(yōu)化引入了Store Bufferes,而Store Bufferes導致了緩存一致性問題。于是有了總線鎖和緩存一致性協(xié)議(EMSI實現(xiàn)),從而有了CPU的內存屏障機制。
而CPU的內存屏障反映在Java編程語言中,有了Java內存模型(JMM),JMM屏蔽了底層硬件的不同,提供了統(tǒng)一的操作,進而編程人員可以通過volatile關鍵字來解決共享變量的可見性和順序性。
緊接著,通過實例演示了volatile的作用以及它不具有線程安全保證的反面案例。最后,舉例說明volatile的運用場景。
想必通過這篇文章,你已經徹底弄懂了volatile相關的知識了吧?來,關注一波。