前言
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 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執行流程
- 線程
從緩存擷取變量A
a
- 緩存未命中,從主存複制到緩存,此時
是a
-
擷取變量A
,執行計算a
- 計算結果
,寫入緩存1
-
,寫入主存1
線程B執行流程
-
B
a
-
a
1
-
擷取變量a,執行計算B
-
2
-
2
A
、
B
兩個線程執行完後,線程
A
與線程
B
緩存資料不一緻,這就是緩存一緻性問題,一個是
1
,另一個是
2
,如果線程
A
再進行一次
+1
操作,寫入主存的還是
2
,也就是說兩個線程對
a
共進行了
3
次
+1
,期望的結果是
3
,最終得到的結果卻是
2
。
解決緩存一緻性問題,就要保證可見性,思路也很簡單,變量寫入主存後,把其他線程緩存的該變量清空,這樣其他線程緩存未命中,就會去主存加載。
-
A
a
-
a
-
A
a
-
1
-
,寫入主存,并清空線程1
緩存B
變量a
-
B
a
-
a
1
-
B
-
2
-
2
A
a
A
B
A
緩存是空的,此時線程A再進行一次
+1
操作,會從主存加載(先從緩存中擷取,緩存未命中,再從主存複制到緩存)得到
2
,最後寫入主存的是
3
,
Java
中提供了
volatile
修飾變量保證可見性(本文重點是
J M M
,是以不會對
volatile
做過多的解讀)。
看似問題都解決了,然而上面描述的場景是建立在理想情況(線程有序的執行),實際中線程可能是并發(交替執行),也可能是并行,隻保證可見性仍然會有問題,是以還需要保證原子性。
原子性
原子性是指一個或者多個操作在
C P U
執行的過程中不被中斷的特性,要麼執行,要不執行,不能執行到一半,為了直覺的了解什麼是原子性,看看下面這段代碼
int a=0;
a++;
- 原子性操作:
隻有一步操作,就是指派int a=0
- 非原子操作:
有三步操作,讀取值、計算、指派a++
如果多線程場景進行
a++
操作,僅保證可見性,沒有保證原子性,同樣會出現問題。
并發場景(線程交替執行)
-
讀取變量A
到緩存,a
a
- 進行
運算得到結果+1
1
- 切換到
B
-
線程執行完整個流程,B
寫入主存a=1
-
恢複執行,把結果A
寫入緩存與主存a=1
- 最終結果錯誤
并行場(線程同時執行)
-
A
同時執行,可能線程B
執行運算A
的時候,線程+1
就已經全部執行完成,也可能兩個線程同時計算完,同時寫入,不管是那種,結果都是錯誤的。B
為了解決此問題,隻要把多個操作變成一步操作,即保證原子性。
Java
synchronized
(同時滿足有序性、原子性、可見性)可以保證結果的原子性(注意這裡的描述),
synchronized
保證原子性的原理很簡單,因為
synchronized
可以對代碼片段上鎖,防止多個線程并發執行同一段代碼(本文重點是
J M M
synchronized
并發場景(線程
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
會對重排後的指令做并行執行,達到優化性能的效果。
重排序前的指令
重排序後的指令
重排序後,對
a
操作的指令發生了改變,節省了一次
Load a
和
Store 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)
-
執行A
i = 10
-
A
b = true
-
B
通過驗證if( b )
-
B
i = i + 10
- 最終結果
i
20
重排場景(i預設0)
-
A
b = true
-
B
if( b )
-
B
i = i + 10
-
A
i = 10
-
i
10
為解決重排序,使用Java提供的
volatile
修飾變量同時保證可見性、有序性,被
volatile
修飾的變量會加上記憶體屏障禁止排序(本文重點是
J M M
volatile
三大特性的保證
特性 | volatile | synchronized | Lock | Atomic |
---|---|---|---|---|
可以保證 | ||||
無法保證 | ||||
一定程度保證 |
關于我
這裡是阿星,一個熱愛技術的Java程式猿,公衆号 「程式猿阿星」 裡将會定期分享作業系統、計算機網絡、Java、分布式、資料庫等精品原創文章,2021,與您在 Be Better 的路上共同成長!。