從 java 內存模型到 volatile 的簡單理解

前言

在開始進入正題學習之前, 覺得有必要先來了解一下什麼是計算機內存模型, 然後再回頭看 java 內存模型.

1. 計算機內存模型

爲什麼要有內存模型呢? 我們知道在計算機執行程序的時候, 每條執行都是在 CPU 中執行的, 而執行的時候, 又無法避免的和數據打交道. 而計算機上的數據是放在主內存中的, 也可以理解爲計算機的物理內存. 隨着現代 CPU 技術的發展, CPU 的執行速度越來越快, 而由於內存的技術並沒有太大的變化, 所以從內存中讀取和寫入數據的過程與 CPU 的執行速度比起來差距就會越來越大, 這就導致 CPU 每次操作內存都要消耗很多等待時間. 所以現代計算機系統不得不加入一層讀寫速度儘可能接近 CPU 運算速度的高速緩存(Cache)來作爲內存與 CPU 之間的緩衝.

那麼程序的執行過程就變成了: 程序在運行過程中, 將運算需要的數據從內存中複製一份到 CPU 的高速緩存當中, 那麼 CPU 進行計算時就可以直接從它的高速緩存讀取數據和向其中寫入數據, 當運算結束之後, 再將高速緩存中的數據刷新到主內存中.

而隨着 CPU 能力不斷提升, 一層緩存已經慢慢的變的無法滿足要求了, 於是就逐漸的衍生出多級緩存.

按照數據讀取順序和 CPU 結合的緊密程度, CPU 緩存可以分爲一級緩存 L1, 二級緩存 L2, 三級緩存 L3, L0 爲寄存器, 接下來是內存, 本地磁盤, 遠程存儲. 越向上緩存的存儲空間越小, 速度越快, 成本也就更高. 從上至下, 每一層都可以看做是更下一層的緩存, 即 L0 寄存器是 L1 一級緩存的緩存. 依次類推, 每一層的數據都是來自它的下一層. 所以每一層的數據是下一層數據的子集.


在現代 CPU 上, 一般來說 L0, L1, L2, L3 都繼承在 CPU 內部, 同時 L1 還分爲一級數據緩存和一級指令緩存, 分別用於存放數據和執行數據的指令解碼. 每個核心擁有獨立的運算處理單元, 控制器, 寄存器, L1, L2 緩存, 然後一個 CPU 的多個核心共享最後一層 CPU 緩存 L3.

 
那麼現在就會出現第一個問題: 緩存一致性問題.
在 CPU 和內存之間增加緩存, 在多線程場景下會出現緩存一致性問題, 也就是說, 多個線程訪問進程中的某個共享內存, 且這多個線程分別在不同的核心上執行, 那麼每個核心都會在各自的高速緩存中保留一份共享內存的緩存. 由於多核是可以並行的, 可能會出現多個線程同時寫各自緩存的情況, 那麼就會造成同一個數據的緩存內容可能不一致.

 
除了這種情況, 還有一種硬件問題, 這是出現的第二個問題: 指令重排問題.
爲了使得 CPU 內部的運算單元能儘量被充分利用, CPU 可能會對輸入代碼進行亂序執行優化, 處理器會在計算之後將亂序執行的結果重組. 保證該結果與順序執行的結果是一致的, 但並不保證程序中各個語句計算的先後順序與輸入代碼中的順序一致.
因此, 如果存在一個計算任務依賴另一個計算任務的中間結果, 那麼其順序性並不能靠代碼的先後順序來保證。與處理器的亂序執行優化類似, Java 虛擬機的即時編譯器中也有類似的指令重排序優化.

 
我們知道, 併發編程爲了保證數據的安全, 需要滿足以下三個特性

  • 原子性: 指在一個操作中, CPU 不可以在中途暫停突然再調度, 即不會被中斷操作, 要不執行完成, 要不就不執行.
  • 可見性: 指當多個線程訪問同一個變量時, 一個線程修改了這個變量的值, 其他線程能夠立即看得到修改的值.
  • 有序性: 即程序執行的順序按照代碼的先後順序執行.

其實緩存一致性問題其實就是可見性問題. 而處理器優化是可以導致原子性問題的, 指令重排即會導致有序性問題. 所以, 爲了保證併發編程中可以滿足原子性, 可見性以及有序性. 就有了一個重要的概念, 那就是內存模型.

爲了保證共享內存的正確性. 內存模型定義了共享內存系統中多線程程序讀寫操作的行爲規範. 通過這些規則來規範對內存的讀寫操作, 從而保證指令執行的正確性. 它與處理器有關, 與緩存有關, 與併發有關, 與編譯器也有關. 它解決了 CPU 多級緩存, 處理器優化, 指令重排等導致的內存訪問問題, 保證了併發場景下的一致性, 原子性與有序性.

所以在 CPU 層面, 內存模型定義了一個充分必要條件, 就是可見性條件.

  • 有些 CPU 提供了強內存模型, 所有 CPU 在任何時候都能看到內存中任意位置相同的值, 這種完全是硬件提供的支持.
  • 其他CPU 提供了弱內存模型, 需要執行一些特殊的指令( memory barriers 內存屏障). 刷新 CPU 緩存的數據到內存中. 保證這個寫的操作能夠被其他 CPU 可見. 或者將 CPU 緩存的數據設置爲無效狀態, 保證其他 CPU 的寫操作對本 CPU 可見. 通常這些內存屏障的行爲由底層實現, 對於上層語言的程序員來說是透明的.
     

2. Java 內存模型是什麼

上面介紹了計算機內存模型, 這是解決多線程場景下併發問題的一個重要規範, 那麼不同的編程語言, 在實現上也有所不同.

我們都知道 Java 程序是需要運行在 Java 虛擬機上面的, Java 內存模型 (Java Memory Model,JMM) 就是一種符合內存模型規範的, 屏蔽了各種硬件和操作系統的訪問差異的, 保證了Java 程序在各種平臺下對內存的訪問都能保證效果一致的機制及規範.

提到 Java 內存模型, 一般指的是 JDK 5 開始使用的新內存模型,主要由 JSR-133:JavaTM Memory Model and Thread Specification 描述.

Java 內存模型規定了所有的變量都存儲在主內存中, 每個線程都有一個私有的工作內存. 線程的工作內存中保存了該線程中用到變量的主內存副本拷貝, 線程對變量的所有操作都必須在工作內存中進行, 而不能直接讀寫主內存. 不同線程之間也無法直接訪問對方工作內存中的變量, 線程間變量的傳遞均需要自己的工作內存與主內存之間進行數據同步.而 JMM 就作用於工作內存與主內存之間數據同步過程, 它規定了如何做數據同步以及什麼時候做數據同步.

簡單來說: JMM 是一種規範, 是解決由於多線程通過共享數據進行通信時, 存在本地內存數據不一致, 編譯器會對代碼指令重排序, 處理器對代碼亂序執行等帶來的問題. 目的是保證併發編程場景中的原子性, 可見性和有序性.

關於主內存與工作內存之間的具體交互協議, JMM 定義了以下八種操作來完成. 即一個變量如何從主內存 copy 到工作內存, 如何從工作內存同步到主內存之間的細節實現.

  • lock    鎖定:  作用於主內存的變量, 把一個變量標識爲一條線程獨佔狀態.
  • unlock 解鎖:  作用於主內存的變量, 把一個處於鎖定狀態的變量釋放, 釋放後纔可被其他線程鎖定.
  • read    讀取:  作用於主內存的變量, 把一個變量值從主內存傳輸到工作內存中, 以便稅後的 load 動作使用.
  • load    載入:  作用於工作內存變量, 它把 load 操作從主內存中得到的變量放入工作內存的副本中.
  • use     使用:   作用於工作內存變量, 把工作內存中的一個變量值傳遞給執行引起, 每當虛擬機遇到一個需要使用變量值的字節碼指令時執行這個操作.
  • assign 賦值:   作用於工作內存變量, 它把一個執行引起接收到的值賦值給工作內存的變量, 每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作.
  • store  存儲:    作用於工作內存變量, 把工作內存中的一個變量的值傳送到主內存中, 以便隨後的 write 操作.
  • write  寫入:    作用於主內存的變量, 它把 store 操作從工作內存中一個變量的值傳送到主內存的變量中.

readload 從主內存複製變量到工作內存
useassign 執行代碼, 修改共享變量值
storewrite 用工作內存變量值刷新主內存相關內存.

 

3. volatile

對於這個關鍵字, 相信大家都不陌生. 它就解決了上述問題中的可見性與指令重排序功能. 它可以被看做是 Java 中一種 "程度較輕的 synchronized"

volatile 關鍵字可以保證直接從主內存中讀取一個變量, 如果這個變量被修改後, 會被強制寫回到主內存.
 

3.1 volatile 解決的可見性問題原理

如果對聲明瞭 volatile 的變量進行寫操作, JVM 就會像處理器發送一條 Lock 前綴的指令, 將這個變量所在緩存行的數據立即寫回到主內存中. 但是就算寫回到內存, 其他處理器的緩存的值還是舊的. 所以在多處理器下, 爲了保證各個處理器的緩存是一致的, 就會實現緩存一致性協議. 也就每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存的值是不是過期了, 當處理器發現自己緩存行對應的內存地址被修改, 就會將當前處理器的緩存行設置爲無效狀態, 當處理器對這個數據進行修改操作的時候, 會重新從主內存中把數據讀到處理器緩存裏.

Lock 前綴的指令在多核處理器下會引發兩件事情

  • 將當前處理器緩存行的數據寫回到主內存
  • 一個處理器的緩存回寫內存會導致其他處理器的緩存無效. 需要數據操作的時候需要再次去主內存中讀取.

對於 volatile 的緩存一致性協議 MESI, 需要不斷的從主內存嗅探和 cas 不斷循環, 無效交互會導致總線帶寬達到峯值, 所以儘量不要大量的使用 volatile.

 

3.2 volatile 的內存語義
  • volatile 寫的內存語義: 當寫一個 volatile修飾的變量時, JMM 會把該線程對應的工作內存中的共享變量的副本值刷新到主內存.
  • volatile 讀的內存語義:當讀一個 volatile修飾的變量時, JMM 會把該線程對應的工作內存中的共享變量副本置爲無效. 線程接下來將從主內存中重新讀取共享變量.
  • 線程 A 寫一個 volatile 修飾的變量, 實質上是線程 A 向接下來將要讀這個 volatile修飾變量的某個線程發出了(對其共享變量所做修改)消息.
  • 線程 B 讀一個 volatile 修飾變量, 實質上是線程 B 接收了某個線程發出的(在寫這個 volatile 變量之前對共享變量所做修改)消息

線程 A 寫這個變量, 隨後線程 B 讀取這個變量, 這個過程實際上是線程 A 通過主內存向 B 線程發送消息.
 

3.3 volatile 使用內存屏障解決重排序問題

volatile 關鍵字本身就包含了禁止指令重排序的語義. 那麼它是如何實現的呢? 就是根據我們上面說過的內存屏障(memory barriers). 不同的硬件平臺實現內存屏障的方式也是不一樣的, Java 通過屏蔽這些差異, 統一由 JVM 來生成內存屏障的指令.

重排序也可能會導致多線程程序出現的內存可見性問題, 對於處理器重排序, JMM 的處理器重排序規則會要求 Java 編譯器在生成指令序列時插入特定類型的內存屏障指令. 通過內存屏障指令來禁止特定類型的處理器重排序. 通過禁止特定類型的編譯器重排序和處理器重排序, 爲程序員提供一致的內存可見性保證.

StoreLoad Barriers 是一個 全能型 的屏障, 它同時具有其他三個屏障的效果. 現代的多處理器大多都支持該屏障. 但是執行該屏障開銷會很昂貴, 因爲當前處理器通常要把寫緩衝區中的數據全部刷新到內存中.

JMM 針對編譯器制定 volatile 重排序規則表如下

  • 當第一個操作是 volatile 讀時, 不管第二個操作是什麼, 都不能重排序. 這個規則確保 volatile 讀之後的操作不會被編譯器重新排序到 volatile 讀之前.
  • 當第一個操作是 volatile 寫時, 第二個操作是 volatile 讀時, 不能重排序.
  • 當第二個操作是 volatile寫時, 無論第一個操作是什麼, 都不能重排序. 這個規則確保 volatile 寫之前的操作不會被編譯器重排序到 volatile 寫之後.

需要注意的是:volatile 寫是在前面和後面分別插入內存屏障, 而 volatile 讀操作是在後面插入兩個內存屏障.
下面是基於保守策略的JMM內存屏障插入策略:

  • 在每個 volatile 寫操作的前面插入一個 StoreStore 屏障.
  • 在每個 volatile 寫操作的後面插入一個 StoreLoad 屏障.
  • 在每個 volatile 讀操作的後面插入一個 LoadLoad 屏障.
  • 在每個 volatile 讀操作的後面插入一個 LoadStore 屏障.

從編譯器重排序規則和處理器內存屏障插入策略來看, 只要 volatile 修飾的變量與普通變量之間的重排序可能會破壞 volatile 的內存語義(內存可見性), 這種重排序就會被編譯器重排序規則和處理器的內存屏障插入策略禁止.

 

3.4 volatile 爲什麼只能保證單次讀寫的原子性

對於單個 volatile 變量的讀/寫具有原子性, 但是類似於複合操作, 類似於 volatile i, i++ 這種就不具有原子性, 因爲本質上 i++ 是三次操作. (實際上對應的機器碼步驟更多,但是這裏分解爲三步已經足夠說明問題)

int temp = i;
temp = temp + 1;
i = temp;

多線程環境, 比如 A, B 兩個線程同時執行 i++ 操作. 都執行到了第2步, B 線程先執行結束 i = 1, 因爲變量 ivolatile 修飾, 所以 B 線程執行結束馬上刷新工作內存中的 i = 1 到主內存. 並且通知其他 CPU 中的線程: 主內存中 i 的值更新了. 使 A 線程中工作內存的 i 失效. 如果 A 線程這時候使用到變量 i, 就需要去主內存重新 copy 一份到自己的工作內存.

但是這時候 A 線程執行到了 temp = temp +1, 已經用臨時變量 temp 記錄了之前 i 的值, 不需要再讀去 i 的值了.

所以雖然變量 i 的值 0 在 A 線程的工作內存中確實失效了, 但是 temp 仍然是有效的, 既然有效, 那麼 A 線程將繼續將第 3 步的結果 i=1 再次寫入主內存覆蓋了之前 B 線程寫入的值. 這就是爲什麼 volatile 無法保證共享變量 i++ 線程安全的原因.

對於複合操作,可以使用同步塊技術和 Java concurrent 包下的原子操作類等.

 

3.5 volatile 總結
  • 通過使用 Lock 前綴的指令禁止變量在線程工作內存中緩存來保證 volatile 變量的內存可見性.
  • 通過插入內存屏障指令來禁止會影響變量可見性的指令重排序.
  • 對任意單次 volatile 讀/寫都具有原子性, 但是對於符合操作不具有原子性.

本章到這裏就結束了, 如果看完覺得對你有幫助, 還請隨手點一個贊. 謝謝大家. 你們的鼓勵就是我的動力.

下一章將會簡單學習理解 synchronized.

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