天天看點

阿裡面試官沒想到,一個Volatile,我都能跟他吹半小時

點贊再看,養成習慣,微信搜尋【三太子敖丙】關注這個網際網路苟且偷生的工具人。

本文 GitHub https://github.com/JavaFamily 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。

Volatile可能是面試裡面必問的一個話題吧,對他的認知很多朋友也僅限于會用階段,今天我們換個角度去看看。

先來跟着丙丙來看一段demo的代碼

阿裡面試官沒想到,一個Volatile,我都能跟他吹半小時

你會發現,永遠都不會輸出有點東西這一段代碼,按道理線程改了flag變量,主線程也能通路到的呀?

為會出現這個情況呢?那我們就需要聊一下另外一個東西了。

JMM(JavaMemoryModel)

JMM

:Java記憶體模型,是java虛拟機規範中所定義的一種記憶體模型,Java記憶體模型是标準化的,屏蔽掉了底層不同計算機的差別(

注意這個跟JVM完全不是一個東西,隻有還有小夥伴搞錯的

)。

那正式聊之前,丙丙先大概科普一下現代計算機的記憶體模型吧。

現代計算機的記憶體模型

其實早期計算機中cpu和記憶體的速度是差不多的,但在現代計算機中,

cpu的指令速度遠超記憶體的存取速度

,由于計算機的儲存設備與處理器的運算速度有幾個數量級的差距,是以現代計算機系統都不得不加入一層讀寫速度盡可能接近處理器運算速度的

高速緩存(Cache)

來作為記憶體與處理器之間的緩沖。

将運算需要使用到的資料複制到緩存中,讓運算能快速進行,當運算結束後再從緩存同步回記憶體之中,這樣處理器就無須等待緩慢的記憶體讀寫了。

基于高速緩存的存儲互動很好地解決了處理器與記憶體的速度沖突,但是也為計算機系統帶來更高的複雜度,因為它引入了一個新的問題:

緩存一緻性(CacheCoherence)

在多處理器系統中,每個處理器都有自己的高速緩存,而它們又共享同一主記憶體(MainMemory)。

阿裡面試官沒想到,一個Volatile,我都能跟他吹半小時

然後我們可以聊一下JMM了。

JMM

Java記憶體模型(JavaMemoryModel)

描述了Java程式中各種變量(線程共享變量)的通路規則,以及在JVM中将變量,存儲到記憶體和從記憶體中讀取變量這樣的底層細節。

JMM有以下規定:

所有的共享變量都存儲于主記憶體,這裡所說的變量指的是執行個體變量和類變量,不包含局部變量,因為局部變量是線程私有的,是以不存在競争問題。

每一個線程還存在自己的工作記憶體,線程的工作記憶體,保留了被線程使用的變量的工作副本。

線程對變量的所有的操作(讀,取)都必須在工作記憶體中完成,而不能直接讀寫主記憶體中的變量

不同線程之間也不能直接通路對方工作記憶體中的變量,線程間變量的值的傳遞需要通過主記憶體中轉來完成。

本地記憶體和主記憶體的關系:

阿裡面試官沒想到,一個Volatile,我都能跟他吹半小時

正是因為這樣的機制,才導緻了可見性問題的存在,那我們就讨論下可見性的解決方案。

可見性的解決方案

加鎖

阿裡面試官沒想到,一個Volatile,我都能跟他吹半小時

為啥加鎖可以解決可見性問題呢?

因為某一個線程進入synchronized代碼塊前後,線程會獲得鎖,清空工作記憶體,從主記憶體拷貝共享變量最新的值到工作記憶體成為副本,執行代碼,将修改後的副本的值重新整理回主記憶體中,線程釋放鎖。

而擷取不到鎖的線程會阻塞等待,是以變量的值肯定一直都是最新的。

Volatile修飾共享變量

阿裡面試官沒想到,一個Volatile,我都能跟他吹半小時

開頭的代碼優化完之後應該是這樣的:

阿裡面試官沒想到,一個Volatile,我都能跟他吹半小時

Volatile做了啥?

每個線程操作資料的時候會把資料從主記憶體讀取到自己的工作記憶體,如果他操作了資料并且寫會了,他其他已經讀取的線程的變量副本就會失效了,需要都資料進行操作又要再次去主記憶體中讀取了。

volatile保證不同線程對共享變量操作的可見性,也就是說一個線程修改了volatile修飾的變量,當修改寫回主記憶體時,另外一個線程立即看到最新的值。

是不是看着加一個關鍵字很簡單,但實際上他在背後含辛茹苦默默付出了不少,我從計算機層面的緩存一緻性協定解釋一下這些名詞的意義。

阿裡面試官沒想到,一個Volatile,我都能跟他吹半小時

之前我們說過當多個處理器的運算任務都涉及同一塊主記憶體區域時,将可能導緻各自的緩存資料不一緻,舉例說明變量在多個CPU之間的共享。

如果真的發生這種情況,那同步回到主記憶體時以誰的緩存資料為準呢?

為了解決一緻性的問題,需要各個處理器通路緩存時都遵循一些協定,在讀寫時要根據協定來進行操作,這類協定有MSI、

MESI(IllinoisProtocol)

、MOSI、Synapse、Firefly及DragonProtocol等。

聊一下Intel的MESI吧

MESI(緩存一緻性協定)

當CPU寫資料時,如果發現操作的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信号通知其他CPU将該變量的緩存行置為無效狀态,是以當其他CPU需要讀取這個變量時,發現自己緩存中緩存該變量的緩存行是無效的,那麼它就會從記憶體重新讀取。

至于是怎麼發現資料是否失效呢?

嗅探

每個處理器通過嗅探在總線上傳播的資料來檢查自己緩存的值是不是過期了,當處理器發現自己緩存行對應的記憶體位址被修改,就會将目前處理器的緩存行設定成無效狀态,當處理器對這個資料進行修改操作的時候,會重新從系統記憶體中把資料讀到處理器緩存裡。

阿裡面試官沒想到,一個Volatile,我都能跟他吹半小時

嗅探的缺點不知道大家發現了沒有?

總線風暴

由于Volatile的MESI緩存一緻性協定,需要不斷的從主記憶體嗅探和cas不斷循環,無效互動會導緻總線帶寬達到峰值。

是以不要大量使用Volatile,至于什麼時候去使用Volatile什麼時候使用鎖,根據場景區分。

我們再來聊一下

指令重排序

的問題

禁止指令重排序

什麼是重排序?

為了提高性能,編譯器和處理器常常會對既定的代碼執行順序進行指令重排序。

重排序的類型有哪些呢?源碼到最終執行會經過哪些重排序呢?

阿裡面試官沒想到,一個Volatile,我都能跟他吹半小時

一個好的記憶體模型實際上會放松對處理器和編譯器規則的束縛,也就是說軟體技術和硬體技術都為同一個目标,而進行奮鬥:在不改變程式執行結果的前提下,盡可能提高執行效率。

JMM對底層盡量減少限制,使其能夠發揮自身優勢。

是以,在執行程式時,為了提高性能,編譯器和處理器常常會對指令進行重排序。

一般重排序可以分為如下三種:

  • 編譯器優化的重排序。編譯器在不改變單線程程式語義的前提下,可以重新安排語句的執行順序;
  • 指令級并行的重排序。現代處理器采用了指令級并行技術來将多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序;
  • 記憶體系統的重排序。由于處理器使用緩存和讀/寫緩沖區,這使得加載和存儲操作看上去可能是在亂序執行的。

這裡還得提一個概念,

as-if-serial

as-if-serial

不管怎麼重排序,單線程下的執行結果不能被改變。

編譯器、runtime和處理器都必須遵守as-if-serial語義。

那Volatile是怎麼保證不會被執行重排序的呢?

記憶體屏障

java編譯器會在生成指令系列時在适當的位置會插入

記憶體屏障

指令來禁止特定類型的處理器重排序。

為了實作volatile的記憶體語義,JMM會限制特定類型的編譯器和處理器重排序,JMM會針對編譯器制定volatile重排序規則表:

阿裡面試官沒想到,一個Volatile,我都能跟他吹半小時

需要注意的是:volatile寫是在前面和後面分别插入記憶體屏障,而volatile讀操作是在後面插入兩個記憶體屏障。

阿裡面試官沒想到,一個Volatile,我都能跟他吹半小時

阿裡面試官沒想到,一個Volatile,我都能跟他吹半小時

上面的我提過重排序原則,為了提高處理速度,JVM會對代碼進行編譯優化,也就是指令重排序優化,并發程式設計下指令重排序會帶來一些安全隐患:如指令重排序導緻的多個線程操作之間的不可見性。

如果讓程式員再去了解這些底層的實作以及具體規則,那麼程式員的負擔就太重了,嚴重影響了并發程式設計的效率。

從JDK5開始,提出了

happens-before

的概念,通過這個概念來闡述操作之間的記憶體可見性。

happens-before

如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須存在happens-before關系。

volatile域規則:對一個volatile域的寫操作,happens-before于任意線程後續對這個volatile域的讀。

如果現在我的變了falg變成了false,那麼後面的那個操作,一定要知道我變了。

聊了這麼多,我們要知道Volatile是沒辦法保證原子性的,一定要保證原子性,可以使用其他方法。

無法保證原子性

就是一次操作,要麼完全成功,要麼完全失敗。

假設現在有N個線程對同一個變量進行累加也是沒辦法保證結果是對的,因為讀寫這個過程并不是原子性的。

要解決也簡單,要麼用原子類,比如AtomicInteger,要麼加鎖(

記得關注Atomic的底層

)。

應用

阿裡面試官沒想到,一個Volatile,我都能跟他吹半小時

單例有8種寫法,我說一下裡面比較特殊的一種,涉及Volatile的。

大家可能好奇為啥要雙重檢查?如果不用Volatile會怎麼樣?

我先講一下

禁止指令重排序

的好處。

對象實際上建立對象要進過如下幾個步驟:

  • 配置設定記憶體空間。
  • 調用構造器,初始化執行個體。
  • 傳回位址給引用

上面我不是說了嘛,是可能發生指令重排序的,那有可能構造函數在對象初始化完成前就指派完成了,在記憶體裡面開辟了一片存儲區域後直接傳回記憶體的引用,這個時候還沒真正的初始化完對象。

但是别的線程去判斷instance!=null,直接拿去用了,其實這個對象是個半成品,那就有空指針異常了。

可見性怎麼保證的?

因為可見性,線程A在自己的記憶體初始化了對象,還沒來得及寫回主記憶體,B線程也這麼做了,那就建立了多個對象,不是真正意義上的單例了。

上面提到了volatile與synchronized,那我聊一下他們的差別。

volatile與synchronized的差別

volatile隻能修飾執行個體變量和類變量,而synchronized可以修飾方法,以及代碼塊。

volatile保證資料的可見性,但是不保證原子性(多線程進行寫操作,不保證線程安全);而synchronized是一種排他(互斥)的機制。 volatile用于禁止指令重排序:可以解決單例雙重檢查對象初始化代碼執行亂序問題。

volatile可以看做是輕量版的synchronized,volatile不保證原子性,但是如果是對一個共享變量進行多個線程的指派,而沒有其他的操作,那麼就可以用volatile來代替synchronized,因為指派本身是有原子性的,而volatile又保證了可見性,是以就可以保證線程安全了。

阿裡面試官沒想到,一個Volatile,我都能跟他吹半小時

總結

  1. volatile修飾符适用于以下場景:某個屬性被多個線程共享,其中有一個線程修改了此屬性,其他線程可以立即得到修改後的值,比如booleanflag;或者作為觸發器,實作輕量級同步。
  2. volatile屬性的讀寫操作都是無鎖的,它不能替代synchronized,因為它沒有提供原子性和互斥性。因為無鎖,不需要花費時間在擷取鎖和釋放鎖_上,是以說它是低成本的。
  3. volatile隻能作用于屬性,我們用volatile修飾屬性,這樣compilers就不會對這個屬性做指令重排序。
  4. volatile提供了可見性,任何一個線程對其的修改将立馬對其他線程可見,volatile屬性不會被線程緩存,始終從主 存中讀取。
  5. volatile提供了happens-before保證,對volatile變量v的寫入happens-before所有其他線程後續對v的讀操作。
  6. volatile可以使得long和double的指派是原子的。
  7. volatile可以在單例雙重檢查中實作可見性和禁止指令重排序,進而保證安全性。

注:以上所有的内容如果能全部掌握我想Volatile在面試官那是很加分了,但是我還沒講到很多關于計算機記憶體那一塊的底層,那大家就需要後面去補課了,如果等得及,也可以等到我寫計算機基礎章節。

絮叨

阿裡面試官沒想到,一個Volatile,我都能跟他吹半小時

img

因為更新文章和視訊,丙丙已經半年多的周末沒休息了,都是在公司那個工位沖沖沖,一直想找時間出去玩,想着年假一天沒用,就請了兩天出去玩一下。

這樣五一就可以早點回來,準備恢複視訊的更新,你在看的時候呢,敖丙應該在出遊的列車上了,是的我就背了這個包,到寫完的時候,我還沒确定去哪裡,提前祝大家節日愉快。

我是敖丙,一個在網際網路苟且偷生的工具人。

你知道的越多,你不知道的越多,人才們的 【三連】 就是丙丙創作的最大動力,我們下期見!

注:如果本篇部落格有任何錯誤和建議,歡迎人才們留言,你快說句話啊!

文章持續更新,可以微信搜尋「 三太子敖丙 」第一時間閱讀,回複【資料】【面試】【履歷】有我準備的一線大廠面試資料和履歷模闆,本文 GitHub https://github.com/JavaFamily 已經收錄,有大廠面試完整考點,歡迎Star。