小白也能看懂的Java記憶體模型

ITPUB社群發表於2022-11-30

前言

Java併發程式設計可以說是中高階研發工程師的必備素養,也是面試必問,本文就是為了帶讀者們系統的一步一步擊破Java併發程式設計各個難點,打破屏障,在面試中所向披靡,拿到心儀的offer,Java併發程式設計系列文章依然採用圖文並茂的風格,讓小白也能秒懂。

Java記憶體模型(Java Memory Model)簡稱J M M,作為Java併發程式設計系列的開篇,它是Java併發程式設計的基礎知識,理解它能讓你更好的明白執行緒安全到底是怎麼一回事

內容大綱

小白也能看懂的Java記憶體模型

硬體記憶體模型

程式是指令與資料的集合,計算機執行程式時,是C P U在執行每條指令,因為C P U要從記憶體讀指令,又要根據指令指示去記憶體讀寫資料做運算,所以執行指令就免不了與記憶體打交道,早期記憶體讀寫速度與C P U處理速度差距不大,倒沒什麼問題。

C P U快取

隨著C P U技術快速發展,C P U的速度越來越快,記憶體卻沒有太大的變化,導致記憶體的讀寫(IO)速度與C P U的處理速度差距越來越大,為了解決這個問題,引入了快取(Cache)的設計,在C P U與記憶體之間加上快取層,這裡的快取層就是指C P U內的暫存器與快取記憶體L1,L2,L3

小白也能看懂的Java記憶體模型

從上圖中可以看出,暫存器最快,主內最慢,越快的儲存空間越小,離C P U越近,相反儲存空間越大速度越慢,離C P U越遠。

C P U如何與記憶體互動

C P U執行時,會將指令與資料從主存複製到快取層,後續的讀寫與運算都是基於快取層的指令與資料,運算結束後,再將結果從快取層寫回主存。

小白也能看懂的Java記憶體模型

上圖可以看出,C P U基本都是在和快取層打交道,採用快取設計彌補主存與C P U處理速度的差距,這種設計不僅僅體現在硬體層面,在日常開發中,那些併發量高的業務場景都能看到,但是凡事都有利弊,快取雖然加快了速度,同樣也帶來了在多執行緒場景存在的快取一致性問題,關於快取一致性問題後面會說,這裡大家留個印象。

Java記憶體模型

Java記憶體模型(Java Memory Model,J M M),後續都以J M M簡稱,J M M 是建立在硬體記憶體模型基礎上的抽象模型,並不是物理上的記憶體劃分,簡單說,為了使Java虛擬機器(Java Virtual Machine,J V M)在各平臺下達到一致的記憶體互動效果,需要遮蔽下游不同硬體模型的互動差異,統一規範,為上游提供統一的使用介面。

J M M是保證J V M在各平臺下對計算機記憶體的互動都能保證效果一致的機制及規範

小白也能看懂的Java記憶體模型

抽象結構

J M M抽象結構劃分為執行緒本地快取與主存,每個執行緒均有自己的本地快取,本地快取是執行緒私有的,主存則是計算機記憶體,它是共享的。

小白也能看懂的Java記憶體模型

不難發現J M M與硬體記憶體模型差別不大,可以簡單的把執行緒類比成Core核心執行緒本地快取類比成快取層,如下圖所示

小白也能看懂的Java記憶體模型

雖然記憶體互動規範好了,但是多執行緒場景必然存線上程安全問題(競爭共享資源),為了使多執行緒能正確的同步執行,就需要保證併發的三大特性可見性、原子性、有序性

可見性

當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改,這就是可見性,如果無法保證,就會出現快取一致性的問題J M M規定,所有的變數都放在主存中,當執行緒使用變數時,先從快取中獲取,快取未命中,再從主存複製到快取,最終導致執行緒操作的都是自己快取中的變數。

小白也能看懂的Java記憶體模型

執行緒A執行流程

  • 執行緒A從快取獲取變數a
  • 快取未命中,從主存複製到快取,此時a0
  • 執行緒A獲取變數a,執行計算
  • 計算結果1,寫入快取
  • 計算結果1,寫入主存

執行緒B執行流程

  • 執行緒B從快取獲取變數a
  • 快取未命中,從主存複製到快取,此時a1
  • 執行緒B獲取變數a,執行計算
  • 計算結果2,寫入快取
  • 計算結果2,寫入主存

AB兩個執行緒執行完後,執行緒A與執行緒B快取資料不一致,這就是快取一致性問題,一個是1,另一個是2,如果執行緒A再進行一次+1操作,寫入主存的還是2,也就是說兩個執行緒對a共進行了3+1,期望的結果是3,最終得到的結果卻是2

解決快取一致性問題,就要保證可見性,思路也很簡單,變數寫入主存後,把其他執行緒快取的該變數清空,這樣其他執行緒快取未命中,就會去主存載入。

小白也能看懂的Java記憶體模型

執行緒A執行流程

  • 執行緒A從快取獲取變數a
  • 快取未命中,從主存複製到快取,此時a0
  • 執行緒A獲取變數a,執行計算
  • 計算結果1,寫入快取
  • 計算結果1,寫入主存,並清空執行緒B快取a變數

執行緒B執行流程

  • 執行緒B從快取獲取變數a
  • 快取未命中,從主存複製到快取,此時a1
  • 執行緒B獲取變數a,執行計算
  • 計算結果2,寫入快取
  • 計算結果2,寫入主存,並清空執行緒A快取a變數

AB兩個執行緒執行完後,執行緒A快取是空的,此時執行緒A再進行一次+1操作,會從主存載入(先從快取中獲取,快取未命中,再從主存複製到快取)得到2,最後寫入主存的是3Java中提供了volatile修飾變數保證可見性(本文重點是J M M,所以不會對volatile做過多的解讀)。

看似問題都解決了,然而上面描述的場景是建立在理想情況(執行緒有序的執行),實際中執行緒可能是併發(交替執行),也可能是並行,只保證可見性仍然會有問題,所以還需要保證原子性

原子性

原子性是指一個或者多個操作在C P U執行的過程中不被中斷的特性,要麼執行,要不執行,不能執行到一半,為了直觀的瞭解什麼是原子性,看看下面這段程式碼

int a=0;
a++;
  • 原子性操作:int a=0只有一步操作,就是賦值
  • 非原子操作:a++有三步操作,讀取值、計算、賦值

如果多執行緒場景進行a++操作,僅保證可見性,沒有保證原子性,同樣會出現問題。

小白也能看懂的Java記憶體模型

併發場景(執行緒交替執行)

  • 執行緒A讀取變數a到快取,a0
  • 進行+1運算得到結果1
  • 切換到B執行緒
  • B執行緒執行完整個流程,a=1寫入主存
  • 執行緒A恢復執行,把結果a=1寫入快取與主存
  • 最終結果錯誤

並行場(執行緒同時執行)

  • 執行緒A與執行緒B同時執行,可能執行緒A執行運算+1的時候,執行緒B就已經全部執行完成,也可能兩個執行緒同時計算完,同時寫入,不管是那種,結果都是錯誤的。

為了解決此問題,只要把多個操作變成一步操作,即保證原子性

小白也能看懂的Java記憶體模型

Java中提供了synchronized同時滿足有序性、原子性、可見性)可以保證結果的原子性(注意這裡的描述),synchronized保證原子性的原理很簡單,因為synchronized可以對程式碼片段上鎖,防止多個執行緒併發執行同一段程式碼(本文重點是J M M,所以不會對synchronized做過多的解讀)。

小白也能看懂的Java記憶體模型

併發場景(執行緒A與執行緒B交替執行)

  • 執行緒A獲取鎖成功
  • 執行緒A讀取變數a到快取,進行+1運算得到結果1
  • 此時切換到了B執行緒
  • 執行緒B獲取鎖失敗,阻塞等待
  • 切換回執行緒A
  • 執行緒A執行完所有流程,主存a=1
  • 執行緒A釋放鎖成功,通知執行緒B獲取鎖
  • 執行緒B獲取鎖成功,讀取變數a到快取,此時a=1
  • 執行緒B執行完所有流程,主存a=2
  • 執行緒B釋放鎖成功

並行場景

  • 執行緒A獲取鎖成功
  • 執行緒B獲取鎖失敗,阻塞等待
  • 執行緒A讀取變數a到快取,進行+1運算得到結果1
  • 執行緒A執行完所有流程,主存a=1
  • 執行緒A釋放鎖成功,通知執行緒B獲取鎖
  • 執行緒B獲取鎖成功,讀取變數a到快取,此時a=1
  • 執行緒B執行完所有流程,主存a=2
  • 執行緒B釋放鎖成功

synchronized對共享資原始碼段上鎖,達到互斥效果,天然的解決了無法保證原子性、可見性、有序性帶來的問題。

雖然在並行場A執行緒還是被中斷了,切換到了B執行緒,但它依然需要等待A執行緒執行完畢,才能繼續,所以結果的原子性得到了保證。

有序性

在日常搬磚寫程式碼時,可能大家都以為,程式執行時就是按照編寫順序執行的,但實際上不是這樣,編譯器和處理器為了最佳化效能,會對程式碼做重排,所以語句實際執行的先後順序與輸入的程式碼順序可能一致,這就是指令重排序

可能讀者們會有疑問“指令重排為什麼能最佳化效能?”,其實C P U會對重排後的指令做並行執行,達到最佳化效能的效果。

重排序前的指令

小白也能看懂的Java記憶體模型

重排序後的指令

小白也能看懂的Java記憶體模型

重排序後,對a操作的指令發生了改變,節省了一次Load aStore a,達到效能最佳化效果,這就是重排序帶來的好處。

重排遵循as-if-serial原則,編譯器和處理器不會對存在資料依賴關係的操作做重排序,因為這種重排序會改變執行結果(即不管怎麼重排序,單執行緒程式的執行結果不能被改變),下面這種情況,就屬於資料依賴。

int i = 10
int j = 10
//這就是資料依賴,int i 與 int j 不能排到 int c下面去
int c = i + j

但也僅僅只是針對單執行緒,多執行緒場景可沒這種保證,假設A、B兩個執行緒,執行緒A程式碼段無資料依賴,執行緒B依賴執行緒A的結果,如下圖(假設保證了可見性

小白也能看懂的Java記憶體模型

禁止重排場景(i預設0)

  • 執行緒A執行i = 10
  • 執行緒A執行b = true
  • 執行緒B執行if( b )透過驗證
  • 執行緒B執行i = i + 10
  • 最終結果i20

重排場景(i預設0)

  • 執行緒A執行b = true
  • 執行緒B執行if( b )透過驗證
  • 執行緒B執行i = i + 10
  • 執行緒A執行i = 10
  • 最終結果i10

為解決重排序,使用Java提供的volatile修飾變數同時保證可見性、有序性,被volatile修飾的變數會加上記憶體屏障禁止排序(本文重點是J M M,所以不會對volatile做過多的解讀)。

三大特性的保證

特性volatilesynchronizedLockAtomic
可見性可以保證可以保證可以保證可以保證
原子性無法保證可以保證可以保證可以保證
有序性一定程度保證可以保證可以保證無法保證


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024420/viewspace-2925891/,如需轉載,請註明出處,否則將追究法律責任。

相關文章