Java中volatile和內存模型(JMM)

volatile 應該算是Java 後端面試的必考題,因爲多線程編程基本繞不開它,很適合作爲併發編程的入門題。

開場

面試官:你先自我介紹一下吧!

安琪拉:   我是安琪拉,草叢三婊之一,最強中單(鍾馗不服)!哦,不對,串場了,我是**,目前在–公司做–系統開發。

面試官:   看你簡歷上寫熟悉併發編程,volatile 用過的吧?

安琪拉:   用過的。(還是熟悉的味道)

面試官:   那你跟我講講什麼時候會用到 volatile ?

安琪拉:   如果需要保證多線程共享變量的可見性時,可以使用volatile 來修飾變量。

面試官:   什麼是共享變量的可見性?

安琪拉:   多線程併發編程中主要圍繞着三個特性實現。可見性是其中一種!

  • 可見性:可見性是指當多個線程訪問同一個共享變量時,一個線程修改了這個變量的值,其他線程能夠立即看到修改後的值。

  • 原子性:原子性指的一個操作或一組操作要麼全部執行,要麼全部不執行。

  • 有序性:有序性是指程序執行的順序按照代碼的先後順序執行。

面試官:   volatile 除了解決共享變量的可見性,還有別的作用嗎?

安琪拉:   volatile 除了讓共享變量具有可見性,還具有有序性(禁止指令重排序)。

面試官:   你先跟我舉幾個實際volatile 實際項目中的例子?

安琪拉:   可以的。有個特別常見的例子:狀態標誌

比如我們工程中經常用一個變量標識程序是否啓動、初始化完成、是否停止等,如下:

volatile 很適合只有一個線程修改,其他線程讀取的情況。volatile 變量被修改之後,對其他線程立即可見。

面試官:  現在我們來看一下你的例子,如果不加volatile 修飾,會有什麼後果?

安琪拉:  比如這是一個帶前端交互的系統,有A、 B二個線程,用戶點了停止應用按鈕,A 線程調用shutdown() 方法,讓變量shutdown 從false 變成 true,但是因爲沒有使用volatile 修飾, B 線程可能感知不到shutdown 的變化,而繼續執行 doWork 內的循環,這樣違背了程序的意願:當shutdown 變量爲true 時,代表應用該停下了,doWork函數應該跳出循環,不再執行。

面試官:   volatile還有別的應用場景嗎?

安琪拉:   懶漢式單例模式,我們常用的 double-check 的單例模式,如下所示:

使用volatile 修飾保證 singleton 的實例化能夠對所有線程立即可見。

面試官:   我們再來看你的單例模式的例子,我有三個問題:

  1. 爲什麼使用volatile 修飾了singleton 引用還用synchronized 鎖?

  2. 第一次檢查singleton 爲空後爲什麼內部還需要進行第二次檢查?

  3. volatile 除了內存可見性,還有別的作用嗎?

安琪拉:  【心裏炸了,舉單例模式例子簡直給自己挖坑】這三個問題,我來一個個回答:

  1. 爲什麼使用volatile 修飾了singleton 引用還用synchronized 鎖?

volatile 只保證了共享變量 singleton 的可見性,但是 singleton = new Singleton(); 這個操作不是原子的,可以分爲三步:

步驟1:在堆內存申請一塊內存空間;

步驟2:初始化申請好的內存空間;

步驟3:將內存空間的地址賦值給 singleton;

所以singleton = new Singleton(); 是一個由三步操作組成的複合操作,多線程環境下A 線程執行了第一步、第二步之後發生線程切換,B 線程開始執行第一步、第二步、第三步(因爲A 線程singleton 是還沒有賦值的),所以爲了保障這三個步驟不可中斷,可以使用synchronized 在這段代碼塊上加鎖。(synchronized 原理參考《安琪拉與面試官二三事》系列第二篇文章)

  1. 第一次檢查singleton 爲空後爲什麼內部還進行第二次檢查?

A 線程進行判空檢查之後開始執行synchronized代碼塊時發生線程切換(線程切換可能發生在任何時候),B 線程也進行判空檢查,B線程檢查 singleton == null 結果爲true,也開始執行synchronized代碼塊,雖然synchronized 會讓二個線程串行執行,如果synchronized代碼塊內部不進行二次判空檢查,singleton 可能會初始化二次。

  1. volatile 除了內存可見性,還有別的作用嗎?

volatile 修飾的變量除了可見性,還能防止指令重排序。

指令重排序 是編譯器和處理器爲了優化程序執行的性能而對指令序列進行重排的一種手段。現象就是CPU 執行指令的順序可能和程序代碼的順序不一致,例如 a = 1; b = 2; 可能 CPU 先執行b=2; 後執行a=1;

singleton = new Singleton(); 由三步操作組合而成,如果不使用volatile 修飾,可能發生指令重排序。步驟3 在步驟2 之前執行,singleton 引用的是還沒有被初始化的內存空間,別的線程調用單例的方法就會引發未被初始化的錯誤。

指令重排序也遵循一定的規則:

  • 重排序不會對存在依賴關係的操作進行重排

  • 重排序目的是優化性能,不管怎樣重排,單線程下的程序執行結果不會變

因此volatile 還有禁止指令重排序的作用。

面試官:   那爲什麼不加volatile ,A 線程對共享變量的修改,其他線程不可見呢?你知道volatile的底層原理嗎?

安琪拉:   果然該來的還是來了,我要放大招了,您坐穩咯!

面試官:   我靠在椅子上,穩的很,請開始你的表演!

安琪拉:    先說結論,我們知道volatile可以實現內存的可見性和防止指令重排序,但是volatile 不保證操作的原子性。那麼volatile是怎麼實現可見性和有序性的呢?其實volatile的這些內存語意是通過內存屏障技術實現的。

面試官:   那你跟我講講內存屏障。

安琪拉:   講內存屏障的話,這塊內容會比較深,我以下面的順序講,這個整個知識成體系,不散:

  1. 現代CPU 架構的形成

  2. Java 內存模型(JMM)

  3. Java 通過 Java 內存模型(JMM )實現 volatile 平臺無關

現代CPU 架構的形成

安琪拉:  一切要從盤古開天闢地說起,女媧補天!咳咳,不好意思,扯遠了!一切從馮洛伊曼計算機體系開始說起!

面試官:  扯的是不是有點遠!

安琪拉:  你就說要不要聽?要聽別打斷我!

面試官:   得嘞!您請講!

安琪拉:  下圖就是經典的 馮洛伊曼體系結構,基本把計算機的組成模塊都定義好了,現在的計算機都是以這個體系弄的,其中最核心的就是由運算器和控制器組成的中央處理器,就是我們常說的CPU。

面試官:   這個跟 volatile 有什麼關係?

安琪拉:  不要着急嘛!理解技術不要死盯着技術的細枝末節,要思考這個技術產生的歷史背景和原因,思考發明這個技術的人當時是遇到了什麼問題?而發明這個技術的。這樣即理解深刻,也讓自己思考問題更宏觀,更有深度!這叫從歷史的角度看問題,站在巨人的肩膀上!

面試官:  來來來,今天你教我做人!

安琪拉:  剛纔說到馮洛伊曼體系中的CPU,你應該聽過摩爾定律吧!就是英特爾創始人戈登·摩爾講的:

集成電路上可容納的晶體管數目,約每隔18個月便會增加一倍,性能也將提升一倍。

面試官:  聽過的,然後呢?

安琪拉:所以你看到我們電腦CPU 的性能越來越強勁,英特爾CPU 從Intel Core 一直到 Intel Core i7,前些年單核CPU 的晶體管數量確實符合摩爾定律,看下面這張圖。

橫軸爲新CPU發明的年份,縱軸爲可容納晶體管的對數。所有的點近似成一條直線,這意味着晶體管數目隨年份呈指數變化,大概每兩年翻一番。

面試官:  後來呢?這和今天說的 volatile,以及內存屏障有什麼關係?

安琪拉:彆着急啊!後來摩爾定律越來越撐不住了,但是更新換代的程序對電腦性能的期望和要求還在不斷上漲,就出現了下面的劇情。

他爲其Pentium 4新一代芯片取消上市而道歉, 近幾年來,英特爾不斷地在增加其處理器的運行速度。當前最快的一款,其速度已達3.4GHz,雖然強化處理器的運行速度,也增強了芯片運作效能,但速度提升卻使得芯片的能源消耗量增加,並衍生出冷卻芯片的問題。

因此,英特爾摒棄將心力集中在提升運行速度的做法,在未來幾年,將其芯片轉爲以多模核心(multi-core)的方式設計等其他方式,來提升芯片的表現。多模核心的設計法是將多模核心置入單一芯片中。如此一來,這些核心芯片即能以較緩慢的速度運轉,除了可減少運轉消耗的能量,也能減少運轉生成的熱量。此外,集衆核心芯片之力,可提供較單一核心芯片更大的處理能力。 —《經濟學人》

安琪拉:當然上面貝瑞特當然只是在開玩笑,眼看摩爾定律撐不住了,後來怎麼處理的呢?一顆CPU 不行,我們多來幾顆嘛!這就是現在我們常見的多核CPU,四核8G 聽着熟悉不熟悉?當然完全依據馮洛伊曼體系設計的計算機也是有缺陷的!

面試官:   什麼缺陷?說說看。

安琪拉:CPU 運算器的運算速度遠比內存讀寫速度快,所以CPU 大部分時間都在等數據從內存讀取,運算完數據寫回內存。

面試官:   那怎麼解決?

安琪拉:因爲CPU 運行速度實在太快,主存(就是內存)的數據讀取速度和CPU 運算速度差了有幾個數量級,因此現代計算機系統通過在CPU 和主存之前加了一層讀寫速度儘可能接近CPU 運行速度的高速緩存來做數據緩衝,這樣緩存提前從主存獲取數據,CPU 不再從主存取數據,而是從緩存取數據。這樣就緩解由於主存速度太慢導致的CPU 飢餓的問題。同時CPU 內還有寄存器,一些計算的中間結果臨時放在寄存器內。

面試官:   既然你提到緩存,那我問你一個問題,CPU 從緩存讀取數據和從內存讀取數據除了讀取速度的差異?有什麼本質的區別嗎?不都是讀數據寫數據,而且加緩存會讓整個體系結構變得更加複雜。

安琪拉:緩存和主存不僅僅是讀取寫入數據速度上的差異,還有另外更大的區別:研究人員發現了程序80%的時間在運行20% 的代碼,所以緩存本質上只要把20%的常用數據和指令放進來就可以了(是不是和Redis 存放熱點數據很像),另外CPU 訪問主存數據時存在二個局部性現象:

  1. 時間局部性現象

如果一個主存數據正在被訪問,那麼在近期它被再次訪問的概率非常大。想想你程序大部分時間是不是在運行主流程。

  1. 空間局部性現象

CPU使用到某塊內存區域數據,這塊內存區域後面臨近的數據很大概率立即會被使用到。這個很好解釋,我們程序經常用的數組、集合(本質也是數組)經常會順序訪問(內存地址連續或鄰近)。

因爲這二個局部性現象的存在使得緩存的存在可以很大程度上緩解CPU 飢餓的問題。

面試官:   講的是那麼回事,那能給我畫一下現在CPU、緩存、主存的關係圖嗎?

安琪拉:可以。我們來看下現在主流的多核CPU的硬件架構,如下圖所示。

安琪拉:現代操作系統一般會有多級緩存(Cache Line),一般有L1、L2,甚至有L3,看下安琪拉的電腦緩存信息,一共4核,三級緩存,L1 緩存(在CPU核心內)這裏沒有顯示出來,這裏L2 緩存後面括號標識了是每個核都有L2 緩存,而L3 緩存沒有標識,是因爲L3 緩存是4個核共享的緩存:

面試官:   那你能跟我簡單講講程序運行時,數據是怎麼在主存、緩存、CPU寄存器之間流轉的嗎?

安琪拉:可以。比如以 i = i + 2; 爲例, 當線程執行到這條語句時,會先從主存中讀取i 的值,然後複製一份到緩存中,CPU 讀取緩存數據(取數指令),進行 i + 2 操作(中間數據放寄存器),然後把結果寫入緩存,最後將緩存中i最新的值刷新到主存當中(寫回時間不確定)。

面試官:    這個數據操作邏輯在單線程環境和多線程環境下有什麼區別?

安琪拉: 比如i 如果是共享變量(例如對象的成員變量),單線程運行沒有任何問題,但是多線程中運行就有可能出問題。例如:有A、B二個線程,在不同的CPU 上運行,因爲每個線程運行的CPU 都有自己的緩存,A 線程從內存讀取i 的值存入緩存,B 線程此時也讀取i 的值存入自己的緩存,A 線程對i 進行+1操作,i變成了1,B線程緩存中的變量 i 還是0,B線程也對i 進行+1操作,最後A、B線程先後將緩存數據寫入內存,內存預期正確的結果應該是2,但是實際是1。這個就是非常著名的緩存一致性問題

說明:單核CPU 的多線程也會出現上面的線程不安全的問題,只是產生原因不是多核CPU緩存不一致的問題導致,而是CPU調度線程切換,多線程局部變量不同步引起的。

執行過程如下圖:

面試官:   那CPU 怎麼解決緩存一致性問題呢?

安琪拉:早期的一些CPU 設計中,是通過鎖總線(總線訪問加Lock# 鎖)的方式解決的。看下CPU 體系結構圖,如下:

因爲CPU 都是通過總線來讀取主存中的數據,因此對總線加Lock# 鎖的話,其他CPU 訪問主存就被阻塞了,這樣防止了對共享變量的競爭。但是鎖總線對CPU的性能損耗非常大,把多核CPU 並行的優勢直接給乾沒了!

後面研究人員就搞出了一套協議:緩存一致性協議。協議的類型很多(MSI、MESI、MOSI、Synapse、Firefly),最常見的就是Intel 的MESI 協議。緩存一致性協議主要規範了CPU 讀寫主存、管理緩存數據的一系列規範,如下圖所示。

面試官:   那講講 **MESI **協議唄!

安琪拉:   (MESI這部分內容可以只瞭解大概思想,不用深究,因爲東西多到可以單獨成一篇文章了)

MESI 協議的核心思想:

  • 定義了緩存中的數據狀態只有四種,MESI 是四種狀態的首字母。

  • 當CPU寫數據時,如果寫的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信號通知其他CPU將該變量的緩存行置爲無效狀態;

  • 當CPU讀取共享變量時,發現自己緩存的該變量的緩存行是無效的,那麼它就會從內存中重新讀取。

緩存中數據都是以緩存行(Cache Line)爲單位存儲;MESI 各個狀態描述如下表所示:

面試官:   那我問你MESI 協議和volatile實現的內存可見性時什麼關係?

安琪拉:   volatile 和MESI 中間差了好幾層抽象,中間會經歷java編譯器,java虛擬機和JIT,操作系統,CPU核心。

volatile 是Java 中標識變量可見性的關鍵字,說直接點:使用volatile 修飾的變量是有內存可見性的,這是Java 語法定的,Java 不關心你底層操作系統、硬件CPU 是如何實現內存可見的,我的語法規定就是volatile 修飾的變量必須是具有可見性的。

CPU 有X86(複雜指令集)、ARM(精簡指令集)等體系架構,版本類型也有很多種,CPU 可能通過鎖總線、MESI 協議實現多核心緩存的一致性。因爲有硬件的差異以及編譯器和處理器的指令重排優化的存在,所以Java 需要一種協議來規避硬件平臺的差異,保障同一段代表在所有平臺運行效果一致,這個協議叫做Java 內存模型(Java Memory Model)。

 

Java 內存模型(JMM)

 

面試官:   你能詳細講講Java 內存模型嗎?

安琪拉:   JMM 全稱 Java Memory Model, 是 Java 中非常重要的一個概念,是Java 併發編程的核心和基礎。JMM 是Java 定義的一套協議,用來屏蔽各種硬件和操作系統的內存訪問差異,讓Java 程序在各種平臺都能有一致的運行效果。

協議這個詞都不會陌生,HTTP 協議、TCP 協議等。JMM 協議就是一套規範,具體的內容爲:

所有的變量都存儲在主內存中,每個線程還有自己的工作內存,線程的工作內存中保存了該線程使用到的變量(主內存的拷貝),線程對變量的所有操作(讀取、賦值)都必須在工作內存中進行,而不能直接讀寫主內存中的變量。不同線程之間無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要在主內存來完成。

面試官:   你剛纔提到每個線程都有自己的工作內存,問個深入一點的問題,線程的工作內存在主存還是緩存中

安琪拉:   這個問題非常棒!JMM 中定義的每個線程私有的工作內存是抽象的規範,實際上工作內存和真實的CPU 內存架構如下所示,Java 內存模型和真實硬件內存架構是不同的:

JMM 是內存模型,是抽象的協議。首先真實的內存架構是沒有區分堆和棧的,這個Java 的JVM 來做的劃分,另外線程私有的本地內存線程棧可能包括CPU 寄存器、緩存和主存。堆亦是如此!

面試官:  能具體講講JMM 內存模型規範嗎?

安琪拉:   可以。前面已經講了線程本地內存和物理真實內存之間的關係,說的詳細些:

  • 初始變量首先存儲在主內存中;

  • 線程操作變量需要從主內存拷貝到線程本地內存中;

  • 線程的本地工作內存是一個抽象概念,包括了緩存、store buffer(後面會講到)、寄存器等。

面試官:   那JMM 模型中多線程如何通過共享變量通信呢?

安琪拉:  線程間通信必須要經過主內存。

線程A與線程B之間要通信的話,必須要經歷下面2個步驟:

1)線程A把本地內存A中更新過的共享變量刷新到主內存中去。

2)線程B到主內存中去讀取線程A之前已更新過的共享變量。

關於主內存與工作內存之間的具體交互協議,即一個變量如何從主內存拷貝到工作內存、如何從工作內存同步到主內存之間的實現細節,Java內存模型定義了以下八種操作(單一操作都是原子的)來完成:

  • lock(鎖定):作用於主內存的變量,把一個變量標識爲一條線程獨佔狀態。

  • unlock(解鎖):作用於主內存變量,把一個處於鎖定狀態的變量解除鎖定,解除鎖定後的變量纔可以被其他線程鎖定。

  • read(讀取):作用於主內存變量,把一個變量值從主內存傳輸到線程的工作內存中,以便隨後的load動作使用

  • load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。

  • use(使用):作用於工作內存的變量,把工作內存中的一個變量值傳遞給執行引擎,每當虛擬機遇到一個需要使用變量的值的字節碼指令時將會執行這個操作。

  • assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收到的值賦值給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。

  • store(有的指令是save/存儲):作用於工作內存的變量,把工作內存中的一個變量的值傳送到主內存中,以便隨後的write的操作。

  • write(寫入):作用於主內存的變量,它把store操作從工作內存中一個變量的值傳送到主內存的變量中。

我們編譯一段Java code 看一下。

代碼和字節碼指令分別爲:

Java內存模型還規定了在執行上述八種基本操作時,必須滿足如下規則:

  • 如果要把一個變量從主內存中複製到工作內存,需要順序執行read 和load 操作, 如果把變量從工作內存中同步回主內存中,就要按順序地執行store 和write 操作。但Java內存模型只要求上述操作必須按順序執行,而沒有保證必須是連續執行,也就是操作不是原子的,一組操作可以中斷。

  • 不允許read和load、store和write操作之一單獨出現,必須成對出現。

  • 不允許一個線程丟棄它的最近assign的操作,即變量在工作內存中改變了之後必須同步到主內存中。

  • 不允許一個線程無原因地(沒有發生過任何assign操作)把數據從工作內存同步回主內存中。

  • 一個新的變量只能在主內存中誕生,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量。即就是對一個變量實施use和store操作之前,必須先執行過了assign和load操作。

  • 一個變量在同一時刻只允許一條線程對其進行lock操作,但lock操作可以被同一條線程重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變量纔會被解鎖。lock和unlock必須成對出現

  • 如果對一個變量執行lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前需要重新執行load或assign操作初始化變量的值

  • 如果一個變量事先沒有被lock操作鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被其他線程鎖定的變量。

  • 對一個變量執行unlock操作之前,必須先把此變量同步到主內存中(執行store和write操作)。

面試官:   聽下來 Java 內存模型真的內容很多,那Java 內存模型是如何保障你上面說的這些規則的呢?

安琪拉:   這就是接下來要說的底層實現原理了,上面叨逼叨說了一堆概念和規範,需要慢慢消化。

Java 通過 Java 內存模型(JMM )實現 volatile 平臺無關

安琪拉:   我們前面說 併發編程實際就是圍繞三個特性的實現展開的:

  • 可見性

  • 有序性

  • 原子性

面試官:  對的。前面已經說過了。我怎麼感覺我想是捧哏。😁

安琪拉:  前面我們已經說過共享變量不可見的問題,講完Java 內存模型,理解的應該更深刻了,如下圖所示:

1. 可見性問題:如果對象obj 沒有使用volatile 修飾,A 線程在將對象count讀取到本地內存,從1修改爲2,B 線程也把obj 讀取到本地內存,因爲A 線程的修改對B 線程不可見,這是從Java 內存模型層面看可見性問題(前面從物理內存結構分析的)。

2. 有序性問題:重排序發生的地方有很多,編譯器優化、CPU 因爲指令流水批處理而重排序、內存因爲緩存以及store buffer 而顯得亂序執行。如下圖所示:

附一張帶store buffer (寫緩衝)的CPU 架構圖,希望詳細瞭解store buffer 可以看文章最後面的擴展閱讀。

每個處理器上的Store Buffer(寫緩衝區),僅僅對它所在的處理器可見。這會導致處理器執行內存操作的順序可能會與內存實際的操作執行順序不一致。由於現代的處理器都會使用寫緩衝區,因此現代的處理器都會允許對寫-讀操作進行重排序:

下圖是各種CPU 架構允許的指令重排序的情況。

3.  原子性問題:例如多線程併發執行 i = i +1。i 是共享變量,看完Java 內存模型,知道這個操作不是原子的,可以分爲+1 操作和賦值操作。因此多線程併發訪問時,可能發生線程切換,造成不是預期結果。

針對上面的三個問題,Java 中提供了一些關鍵字來解決。

  1. 可見性 & 有序性 問題解決

    volatile 可以讓共享變量實現可見性,同時禁止共享變量的指令重排,保障可見性。從JSR-333 規範 和 實現原理講:

  • JSR-333 規範:JDK 5定義的內存模型規範,

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

    1. 如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。

    2. 兩個操作之間存在happens-before關係,並不意味着一定要按照happens-before原則制定的順序來執行。如果重排序之後的執行結果與按照happens-before關係來執行的結果一致,那麼這種重排序並不非法。

  1. 程序次序規則:一個線程內,按照代碼順序,書寫在前面的操作先行發生於書寫在後面的操作;

  2. 鎖定規則:一個unLock操作先行發生於後面對同一個鎖額lock操作;

  3. volatile變量規則:對一個變量的寫操作先行發生於後面對這個變量的讀操作;

  4. 傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C;

  5. 線程啓動規則:Thread對象的start()方法先行發生於此線程的每個一個動作;

  6. 線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生;

  7. 線程終結規則:線程中所有的操作都先行發生於線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行;

  8. 對象終結規則:一個對象的初始化完成先行發生於他的finalize()方法的開始;

實現原理:上面說的happens-before原則保障可見性,禁止指令重排保證有序性,如何實現的呢?

Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序,保證共享變量操作的有序性。

內存屏障指令:寫操作的會讓線程本地的共享內存變量寫完強制刷新到主存。讀操作讓本地線程變量無效,強制從主內存讀取,保證了共享內存變量的可見性。

JVM中提供了四類內存屏障指令:

JSR-133 定義的相應的內存屏障,在第一步操作(列)和第二步操作(行)之間需要的內存屏障指令如下:

Java  volatile 例子:

以下是區分各個CPU體系支持的內存屏障(也叫內存柵欄),由JVM 實現平臺無關(volatile所有平臺表現一致)

synchronized 也可以實現有序性和可見性,但是是通過鎖讓併發串行化實現有序,內存屏障實現可見。

  • 一個線程寫入變量a後,任何線程訪問該變量都會拿到最新值。

  • 在寫入變量a之前的寫入操作,其更新的數據對於其他線程也是可見的。因爲Memory Barrier會刷出cache中的所有先前的寫入。

擴展閱讀

Java如何實現跨平臺

作爲Java 程序員的我們只需要寫一堆 ***.java 文件,編譯器把 .java 文件編譯成 .class 字節碼文件,後面的事就都交給Java 虛擬機(JVM)做了。如下圖所示, Java虛擬機是區分平臺的,虛擬機來進行 .class 字節碼指令翻譯成平臺相關的機器碼。

所以 Java 是跨平臺的,Java 虛擬機(JVM)不是跨平臺的,JVM 是平臺相關的。大家可以看 Hostpot1.8 源碼文件夾,JVM 每個系統都有單獨的實現,如下圖所示:

As-if-serial

As-if-serial語義的意思是,所有的動作(Action)都可以爲了優化而被重排序,但是必須保證它們重排序後的結果和程序代碼本身的應有結果是一致的。Java編譯器、運行時和處理器都會保證單線程下的as-if-serial語義。

併發&並行

現代操作系統,現代操作系統都是按時間片調度執行的,最小的調度執行單元是線程,多任務和並行處理能力是衡量一臺計算機處理器的非常重要的指標。這裏有個概念要說一下:

  • 併發:多個程序可能同時運行的現象,例如刷微博和聽歌同時進行,可能你電腦只有一顆CPU,但是通過時間片輪轉的方式讓你感覺在同時進行。

  • 並行:多核CPU,每個CPU 內運行自己的線程,是真正的同時進行的,叫並行。

內存屏障

JSR-133 對應規則需要的規則

另外 final 關鍵字需要 StoreStore 屏障

x.finalField = v; StoreStore; sharedRef = x;

MESI 協議運作模式

MESI 協議運作的具體流程,舉個實例

第一列是操作序列號,第二列是執行操作的CPU,第三列是具體執行哪一種操作,第四列描述了各個cpu local cache中的cacheline的狀態(用meory address/狀態表示),最後一列描述了內存在0地址和8地址的數據內容的狀態:V表示是最新的,和cache一致,I表示不是最新的內容,最新的內容保存在cache中。

總結篇

Java內存模型

Java 內存模型(JSR-133)屏蔽了硬件、操作系統的差異,實現讓Java程序在各種平臺下都能達到一致的併發效果,規定了一個線程如何和何時可以看到由其他線程修改過後的共享變量的值,以及在必須時如何同步的訪問共享變量,JMM使用內存屏障提供了java程序運行時統一的內存模型。

volatile的實現原理

volatile可以實現內存的可見性和防止指令重排序。

通過內存屏障技術實現的。

爲了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障指令,內存屏障效果有:

  • 禁止volatile 修飾變量指令的重排序

  • 寫入數據強制刷新到主存

  • 讀取數據強制從主存讀取

volatile使用總結

volatile 是Java 提供的一種輕量級同步機制,可以保證共享變量的可見性和有序性(禁止指令重排),常用於狀態標誌、雙重檢查的單例等場景。

使用原則:

  • 對變量的寫操作不依賴於當前值。例如 i++ 這種就不適用。

  • 該變量沒有包含在具有其他變量的不變式中。

volatile的使用場景不是很多,使用時需要仔細考慮下是否適用volatile,注意滿足上面的二個原則。

單個的共享變量的讀/寫(比如a=1)具有原子性,但是像num++或者a=b+1;這種複合操作,volatile無法保證其原子性;

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章