小白也能看懂的Java內存模型

前言

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

Java內存模型(Java Memory Model)簡稱J M M,做爲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緩存

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

C P U如何與內存交互

C P U運行時,會將指令與數據從主存複製到緩存層,後續的讀寫與運算都是基於緩存層的指令與數據,運算結束後,再將結果從緩存層寫回主存。性能優化

上圖能夠看出,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在各平臺下對計算機內存的交互都能保證效果一致的機制及規範併發

抽象結構

J M M抽象結構劃分爲線程本地緩存與主存,每一個線程均有本身的本地緩存,本地緩存是線程私有的,主存則是計算機內存,它是共享的。分佈式

不難發現J M M與硬件內存模型差異不大,能夠簡單的把線程類比成Core核心線程本地緩存類比成緩存層,以下圖所示

雖然內存交互規範好了,可是多線程場景必然存在線程安全問題(競爭共享資源),爲了使多線程能正確的同步執行,就須要保證併發的三大特性可見性、原子性、有序性

可見性

當一個線程修改了共享變量的值,其餘線程可以當即得知這個修改,這就是可見性,若是沒法保證,就會出現緩存一致性的問題J M M規定,全部的變量都放在主存中,當線程使用變量時,先從緩存中獲取,緩存未命中,再從主存複製到緩存,最終致使線程操做的都是本身緩存中的變量。

線程A執行流程

  • <span style="color: Blue;">線程A從緩存獲取變量a
  • <span style="color: Blue;">緩存未命中,從主存複製到緩存,此時a0
  • <span style="color: Blue;">線程A獲取變量a,執行計算
  • <span style="color: Blue;">計算結果1,寫入緩存
  • <span style="color: Blue;">計算結果1,寫入主存

線程B執行流程

  • <span style="color: Blue;">線程B從緩存獲取變量a
  • <span style="color: Blue;">緩存未命中,從主存複製到緩存,此時a1
  • <span style="color: Blue;">線程B獲取變量a,執行計算
  • <span style="color: Blue;">計算結果2,寫入緩存
  • <span style="color: Blue;">計算結果2,寫入主存

AB兩個線程執行完後,線程A與線程B緩存數據不一致,這就是緩存一致性問題,一個是1,另外一個是2,若是線程A再進行一次+1操做,寫入主存的仍是2,也就是說兩個線程對a共進行了3+1,指望的結果是3,最終獲得的結果倒是2

解決緩存一致性問題,就要保證可見性,思路也很簡單,變量寫入主存後,把其餘線程緩存的該變量清空,這樣其餘線程緩存未命中,就會去主存加載。

線程A執行流程

  • <span style="color: Blue;">線程A從緩存獲取變量a
  • <span style="color: Blue;">緩存未命中,從主存複製到緩存,此時a0
  • <span style="color: Blue;">線程A獲取變量a,執行計算
  • <span style="color: Blue;">計算結果1,寫入緩存
  • <span style="color: Blue;">計算結果1,寫入主存,並清空線程B緩存a變量

線程B執行流程

  • <span style="color: Blue;">線程B從緩存獲取變量a
  • <span style="color: Blue;">緩存未命中,從主存複製到緩存,此時a1
  • <span style="color: Blue;">線程B獲取變量a,執行計算
  • <span style="color: Blue;">計算結果2,寫入緩存
  • <span style="color: Blue;">計算結果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++操做,僅保證可見性,沒有保證原子性,一樣會出現問題。

併發場景(線程交替執行)

  • <span style="color: Blue;">線程A讀取變量a到緩存,a0
  • <span style="color: Blue;">進行+1運算獲得結果1
  • <span style="color: Blue;">切換到B線程
  • <span style="color: Blue;">B線程執行完整個流程,a=1寫入主存
  • <span style="color: Blue;">線程A恢復執行,把結果a=1寫入緩存與主存
  • <span style="color: Blue;">最終結果錯誤

並行場(線程同時執行)

  • <span style="color: Blue;">線程A與線程B同時執行,可能線程A執行運算+1的時候,線程B就已經所有執行完成,也可能兩個線程同時計算完,同時寫入,不論是那種,結果都是錯誤的。

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

Java中提供了synchronized同時知足有序性、原子性、可見性)能夠保證結果的原子性(注意這裏的描述),synchronized保證原子性的原理很簡單,由於synchronized能夠對代碼片斷上鎖,防止多個線程併發執行同一段代碼(本文重點是J M M,因此不會對synchronized作過多的解讀)。

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

  • <span style="color: Blue;">線程A獲取鎖成功
  • <span style="color: Blue;">線程A讀取變量a到緩存,進行+1運算獲得結果1
  • <span style="color: Blue;">此時切換到了B線程
  • <span style="color: Blue;">線程B獲取鎖失敗,阻塞等待
  • <span style="color: Blue;">切換回線程A
  • <span style="color: Blue;">線程A執行完全部流程,主存a=1
  • <span style="color: Blue;">線程A釋放鎖成功,通知線程B獲取鎖
  • <span style="color: Blue;">線程B獲取鎖成功,讀取變量a到緩存,此時a=1
  • <span style="color: Blue;">線程B執行完全部流程,主存a=2
  • <span style="color: Blue;">線程B釋放鎖成功

並行場景

  • <span style="color: Blue;">線程A獲取鎖成功
  • <span style="color: Blue;">線程B獲取鎖失敗,阻塞等待
  • <span style="color: Blue;">線程A讀取變量a到緩存,進行+1運算獲得結果1
  • <span style="color: Blue;">線程A執行完全部流程,主存a=1
  • <span style="color: Blue;">線程A釋放鎖成功,通知線程B獲取鎖
  • <span style="color: Blue;">線程B獲取鎖成功,讀取變量a到緩存,此時a=1
  • <span style="color: Blue;">線程B執行完全部流程,主存a=2
  • <span style="color: Blue;">線程B釋放鎖成功

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

雖然在並行場A線程仍是被中斷了,切換到了B線程,但它依然須要等待A線程執行完畢,才能繼續,因此結果的原子性獲得了保證。

有序性

在平常搬磚寫代碼時,可能你們都覺得,程序運行時就是按照編寫順序執行的,但實際上不是這樣,編譯器和處理器爲了優化性能,會對代碼作重排,因此語句實際執行的前後順序與輸入的代碼順序可能一致,這就是指令重排序

可能讀者們會有疑問「指令重排爲何能優化性能?」,其實C P U會對重排後的指令作並行執行,達到優化性能的效果。

重排序前的指令

重排序後的指令

重排序後,對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的結果,以下圖(假設保證了可見性

禁止重排場景(i默認0)

  • <span style="color: Blue;">線程A執行i = 10
  • <span style="color: Blue;">線程A執行b = true
  • <span style="color: Blue;">線程B執行if( b )經過驗證
  • <span style="color: Blue;">線程B執行i = i + 10
  • <span style="color: Blue;">最終結果i20

重排場景(i默認0)

  • <span style="color: Blue;">線程A執行b = true
  • <span style="color: Blue;">線程B執行if( b )經過驗證
  • <span style="color: Blue;">線程B執行i = i + 10
  • <span style="color: Blue;">線程A執行i = 10
  • <span style="color: Blue;">最終結果i10

爲解決重排序,使用Java提供的volatile修飾變量同時保證可見性、有序性,被volatile修飾的變量會加上內存屏障禁止排序(本文重點是J M M,因此不會對volatile作過多的解讀)。

三大特性的保證

特性 volatile synchronized Lock Atomic
可見性 能夠保證 能夠保證 能夠保證 能夠保證
原子性 沒法保證 能夠保證 能夠保證 能夠保證
有序性 必定程度保證 能夠保證 能夠保證 沒法保證

關於我

這裏是阿星,一個熱愛技術的Java程序猿,公衆號 「程序猿阿星」 裏將會按期分享操做系統、計算機網絡、Java、分佈式、數據庫等精品原創文章,2021,與您在 Be Better 的路上共同成長!。

很是感謝各位小哥哥小姐姐們能 看到這裏,原創不易,文章有幫助能夠「點個贊」或「分享與評論」,都是支持(莫要白嫖)!

願你我都能奔赴在各自想去的路上,咱們下篇文章見!

交個朋友
4fa53e41c65053b48969ff3641196e8.jpg

相關文章
相關標籤/搜索