1. 程式人生 > >面試官沒想到一個Volatile,我都能跟他扯半小時

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

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

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

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

先來跟著丙丙來看一段demo的程式碼

你會發現,永遠都不會輸出有點東西這一段程式碼,按道理執行緒改了flag變數,主執行緒也能訪問到的呀?

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

JMM(JavaMemoryModel)

JMM:Java記憶體模型,是java虛擬機器規範中所定義的一種記憶體模型,Java記憶體模型是標準化的,遮蔽掉了底層不同計算機的區別(注意這個跟JVM完全不是一個東西,只有還有小夥伴搞錯的

)。

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

現代計算機的記憶體模型

其實早期計算機中cpu和記憶體的速度是差不多的,但在現代計算機中,cpu的指令速度遠超記憶體的存取速度,由於計算機的儲存裝置與處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的快取記憶體(Cache)來作為記憶體與處理器之間的緩衝。

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

基於快取記憶體的儲存互動很好地解決了處理器與記憶體的速度矛盾,但是也為計算機系統帶來更高的複雜度,因為它引入了一個新的問題:快取一致性(CacheCoherence)

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

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

JMM

Java記憶體模型(JavaMemoryModel)描述了Java程式中各種變數(執行緒共享變數)的訪問規則,以及在JVM中將變數,儲存到記憶體和從記憶體中讀取變數這樣的底層細節。

JMM有以下規定:

所有的共享變數都儲存於主記憶體,這裡所說的變數指的是例項變數和類變數,不包含區域性變數,因為區域性變數是執行緒私有的,因此不存在競爭問題。

每一個執行緒還存在自己的工作記憶體,執行緒的工作記憶體,保留了被執行緒使用的變數的工作副本。

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

不同執行緒之間也不能直接訪問對方工作記憶體中的變數,執行緒間變數的值的傳遞需要通過主記憶體中轉來完成。

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

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

可見性的解決方案

加鎖

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

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

而獲取不到鎖的執行緒會阻塞等待,所以變數的值肯定一直都是最新的。

Volatile修飾共享變數

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

Volatile做了啥?

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

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

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

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

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

為了解決一致性的問題,需要各個處理器訪問快取時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等。

聊一下Intel的MESI吧

MESI(快取一致性協議)

當CPU寫資料時,如果發現操作的變數是共享變數,即在其他CPU中也存在該變數的副本,會發出訊號通知其他CPU將該變數的快取行置為無效狀態,因此當其他CPU需要讀取這個變數時,發現自己快取中快取該變數的快取行是無效的,那麼它就會從記憶體重新讀取。

至於是怎麼發現數據是否失效呢?

嗅探

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

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

匯流排風暴

由於Volatile的MESI快取一致性協議,需要不斷的從主記憶體嗅探和cas不斷迴圈,無效互動會導致匯流排頻寬達到峰值。

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

我們再來聊一下指令重排序的問題

禁止指令重排序

什麼是重排序?

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

重排序的型別有哪些呢?原始碼到最終執行會經過哪些重排序呢?

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

JMM對底層儘量減少約束,使其能夠發揮自身優勢。

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

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

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

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

  • 記憶體系統的重排序。由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行的。

這裡還得提一個概念,as-if-serial

as-if-serial

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

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

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

記憶體屏障

java編譯器會在生成指令系列時在適當的位置會插入記憶體屏障指令來禁止特定型別的處理器重排序。

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

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

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

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

從JDK5開始,提出了happens-before的概念,通過這個概念來闡述操作之間的記憶體可見性。

happens-before

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

volatile域規則:對一個volatile域的寫操作,happens-before於任意執行緒後續對這個volatile域的讀。

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

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

無法保證原子性

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

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

要解決也簡單,要麼用原子類,比如AtomicInteger,要麼加鎖(記得關注Atomic的底層)。

應用

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

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

我先講一下禁止指令重排序的好處。

物件實際上建立物件要進過如下幾個步驟:

  • 分配記憶體空間。
  • 呼叫構造器,初始化例項。
  • 返回地址給引用

上面我不是說了嘛,是可能發生指令重排序的,那有可能建構函式在物件初始化完成前就賦值完成了,在記憶體裡面開闢了一片儲存區域後直接返回記憶體的引用,這個時候還沒真正的初始化完物件。

但是別的執行緒去判斷instance!=null,直接拿去用了,其實這個物件是個半成品,那就有空指標異常了。

可見性怎麼保證的?

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

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

volatile與synchronized的區別

volatile只能修飾例項變數和類變數,而synchronized可以修飾方法,以及程式碼塊。

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

volatile可以看做是輕量版的synchronized,volatile不保證原子性,但是如果是對一個共享變數進行多個執行緒的賦值,而沒有其他的操作,那麼就可以用volatile來代替synchronized,因為賦值本身是有原子性的,而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在面試官那是很加分了,但是我還沒講到很多關於計算機記憶體那一塊的底層,那大家就需要後面去補課了,如果等得及,也可以等到我寫計算機基礎章節。

絮叨

img

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

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

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

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

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


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