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