本文目錄
- 從多線程交替打印A和B開始
- Java 內存模型中的可見性、原子性和有序性
- Volatile原理
- volatile的特性
- volatile happens-before規則
- volatile 內存語義
- volatile 內存語義的實現
- CPU對於Volatile的支持
- 緩存一致性協議
- 工作內存(本地內存)並不存在
- 總結
- 參考資料
從多線程交替打印A和B開始
面試中經常會有一道多線程交替打印A和B的問題,可以通過使用Lock
和一個共享變量來完成這一操作,代碼如下,其中使用num
來決定當前線程是否打印
public class ABTread {
private static int num=0;
private static Lock lock=new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
Thread A=new Thread(new Runnable() {
@Override
public void run() {
while (true){
lock.lock();
if (num==0){
System.out.println("A");
num=1;
}
lock.unlock();
}
}
},"A");
Thread B=new Thread(new Runnable() {
@Override
public void run() {
while (true){
lock.lock();
if (num==1){
System.out.println("B");
num=0;
}
lock.unlock();
}
}
},"B");
A.start();
B.start();
}
}
這一過程使用了一個可重入鎖,在以前可重入鎖的獲取流程中有分析到,當鎖被一個線程持有時,后繼的線程想要再獲取鎖就需要進入同步隊列還有可能會被阻塞。
現在假設當A
線程獲取了鎖,B
線程再來獲取鎖且B
線程獲取失敗則會調用LockSupport.park()
導致線程B
阻塞,線程A
釋放鎖時再還行線程B
。
是否會經常存在阻塞線程和還行線程的操作呢,阻塞和喚醒的操作是比較費時間的。是否存在一個線程剛釋放鎖之后這一個線程又再一次獲取鎖,由於共享變量的存在,
則獲取鎖的線程一直在做着毫無意義的事情。
可以使用volatile
關鍵字來修飾共享變量來解決,代碼如下:
public class ABTread {
private static volatile int num=0;
public static void main(String[] args) throws InterruptedException {
Thread A=new Thread(new Runnable() {
@Override
public void run() {
while (true){
if (num==0){ //讀取num過程記作1
System.out.println("A");
num=1; //寫入num記位2
}
}
}
},"A");
Thread B=new Thread(new Runnable() {
@Override
public void run() {
while (true){
if (num==1){ //讀取num過程記作3
System.out.println("B");
num=0; ////寫入num記位4
}
}
}
},"B");
A.start();
B.start();
}
}
Lock
可以通過阻止同時訪問來完成對共享變量的同時訪問和修改,必要的時候阻塞其他嘗試獲取鎖的線程,那么volatile
關鍵字又是如何工作,
在這個例子中,是否效果會優於Lock
呢。
Java 內存模型中的可見性、原子性和有序性
-
可見性:指線程之間的可見性,一個線程對於狀態的修改對另一個線程是可見的,也就是說一個線程修改的結果對於其他線程是實時可見的。
可見性是一個復雜的屬性,因為可見性中的錯誤總是會違背我們的直覺(JMM決定),通常情況下,我們無法保證執行讀操作的線程能實時的看到其他線程的寫入的值。
為了保證線程的可見性必須使用同步機制。退一步說,最少應該保證當一個線程修改某個狀態時,而這個修改時程序員希望能被其他線程實時可見的,
那么應該保證這個狀態實時可見,而不需要保證所有狀態的可見。在Java
中volatile
、synchronized
和final
實現可見性。 -
原子性:如果一個操作是不可以再被分割的,那么我們說這個操作是一個原子操作,即具有原子性。但是例如
i++
實際上是i=i+1
這個操作是可分割的,他不是一個原子操作。
非原子操作在多線程的情況下會存在線程安全性問題,需要是我們使用同步技術將其變為一個原子操作。java
的concurrent
包下提供了一些原子類,
我們可以通過閱讀API來了解這些原子類的用法。比如:AtomicInteger
、AtomicLong
、AtomicReference
等。在Java
中synchronized
和在lock
、unlock
中操作保證原子性 -
有序性:一系列操作是按照規定的順序發生的。如果在本線程之內觀察,所有的操作都是有序的,如果在其他線程觀察,所有的操作都是無序的;前半句指“線程內表現為串行語義”后半句指“指令重排序”和“工作內存和主存同步延遲”
Java
語言提供了volatile
和synchronized
兩個關鍵字來保證線程之間操作的有序性。volatile
是因為其本身包含“禁止指令重排序”的語義,
synchronized
是由“一個變量在同一個時刻只允許一條線程對其進行lock
操作”這條規則獲得的,此規則決定了持有同一個對象鎖的兩個同步塊只能串行執行。
Volatile原理
volatile
定義:Java編程語言允許線程訪問共享變量,為了確保共享變量能被准確和一致的更新,線程應該通過獲取排他鎖單獨獲取這個變量;
java提供了volatile
關鍵字在某些情況下比鎖更好用。
-
Java
語言提供了volatile
了關鍵字來提供一種稍弱的同步機制,他能保證操作的可見性和有序性。當把變量聲明為volatile
類型后,
編譯器與運行時都會注意到這個變量是一個共享變量,並且這個變量的操作禁止與其他的變量的操作重排序。 -
訪問
volatile
變量時不會執行加鎖操作。因此也不會存在阻塞競爭的線程,因此volatile變量是一種比sychronized關鍵字更輕量級的同步機制。
volatile的特性
volatile
具有以下特性:
- 可見性:對於一個
volatile
的讀總能看到最后一次對於這個volatile
變量的寫 - 原子性:對任意單個
volatile
變量的讀/寫具有原子性,但對於類似於i++
這種復合操作不具有原子性。 - 有序性:
volatile happens-before規則
根據JMM
要求,共享變量存儲在共享內存當中,工作內存存儲一個共享變量的副本,
線程對於共享變量的修改其實是對於工作內存中變量的修改,如下圖所示:
以從多線程交替打印A和B開始章節中使用volatile
關鍵字的實現為例來研究volatile
關鍵字實現了什么:
假設線程A
在執行num=1
之后B
線程讀取num
指,則存在以下happens-before
關系
1) 1 happens-before 2,3 happens-before 4
2) 根據volatile規則有:2 happens-before 3
3) 根據heppens-before傳遞規則有: 1 happens-before 4
至此線程的執行順序是符合我們的期望的,那么volatile
是如何保證一個線程對於共享變量的修改對於其他線程可見的呢?
volatile 內存語義
根據JMM
要求,對於一個變量的獨寫存在8個原子操作。對於一個共享變量的獨寫過程如下圖所示:
對於一個沒有進行同步的共享變量,對其的使用過程分為read
、load
、use
、assign
以及不確定的store
、write
過程。
整個過程的語言描述如下:
- 第一步:從共享內存中讀取變量放入工作內存中(`read`、`load`)
- 第二步:當執行引擎需要使用這個共享變量時從本地內存中加載至**CPU**中(`use`)
- 第三步:值被更改后使用(`assign`)寫回工作內存。
- 第四步:若之后執行引擎還需要這個值,那么就會直接從工作內存中讀取這個值,不會再去共享內存讀取,除非工作內存中的值出於某些原因丟失。
- 第五步:在不確定的某個時間使用`store`、`write`將工作內存中的值回寫至共享內存。
由於沒有使用鎖操作,兩個線程可能同時讀取或者向共享內存中寫入同一個變量。或者在一個線程使用這個變量的過程中另一個線程讀取或者寫入變量。
即上圖中1和6兩個操作可能會同時執行,或者在線程1使用num過程中6過程執行,那么就會有很嚴重的線程安全問題,
一個線程可能會讀取到一個並不是我們期望的值。
那么如果希望一個線程的修改對后續線程的讀立刻可見,那么只需要將修改后存儲在本地內存中的值回寫到共享內存
並且在另一個線程讀的時候從共享內存重新讀取而不是從本地內存中直接讀取即可;事實上
當寫一個volatile
變量時,JMM會把該線程對應的本地內存中共享變量值刷新會共享內存;
而當讀取一個volatile
變量時,JMM會從主存中讀取共享變量,這也就是volatile
的寫-讀內存語義。
volatile
的寫-讀內存語義:
volatile
寫的內存語義:當寫一個volatile
變量時,JMM會把該線程對應的本地內存中共享變量值刷新會共享內存volatile
讀的內存語義:當讀一個volatile
變量時,JMM會把該線程對應的本地內存置為無效,線程接下來將從主內存中讀取共享變量。
如果將這兩個步驟綜合起來,那么線程3讀取一個volatile
變量后,寫線程1在寫這個volatile
變量之前所有可見的共享變量的值都將樂客變得對線程3可見。
volatile
變量的讀寫過程如下圖:
需要注意的是:在各個線程的工作內存中是存在volatile
變量的值不一致的情況的,只是每次使用都會從共享內存讀取並刷新,執行引擎看不到不一致的情況,
所以認為volatile
變量在本地內存中不存在不一致問題。
volatile 內存語義的實現
在前文Java內存模型中有提到重排序。為了實現volatile
的內存語義,JMM會限制重排序的行為,具體限制如下表:
是否可以重排序 | 第二個操作 | 第二個操作 | 第二個操作 |
---|---|---|---|
第一個操作 | 普通讀/寫 | volatile 讀 |
volatile 寫 |
普通讀/寫 | NO | ||
volatile 讀 |
NO | NO | NO |
volatile 寫 |
NO | NO |
說明:
- 若第一個操作時普通變量的讀寫,第二個操作時volatile變量的寫操作,則編譯器不能重排序這兩個操作
- 若第一個操作是volatile變量的讀操作,不論第二個變量是什么操作不餓能重排序這兩個操作
- 若第一個操作時volatile變量的寫操作,除非第二個操作是普通變量的獨寫,否則不能重排序這兩個操作
為了實現volatile
變量的內存語義,編譯器生成字節碼文件時會在指令序列中插入內存屏障來禁止特定類型的處理器排序。
為了實現volatile
變量的內存語義,插入了以下內存屏障,並且在實際執行過程中,只要不改變volatile
的內存語義,
編譯器可以根據實際情況省略部分不必要的內存屏障
- 在每個volatile寫操作前面插入StoreStore屏障
- 在每個volatile寫操作后面插入StoreLoad屏障
- 在每個volatile讀操作后面插入LoadLoad屏障
- 在每個volatile讀操作后面插入LoadStore屏障
插入內存屏障后volatile
寫操作過程如下圖:
插入內存屏障后volatile
讀操作過程如下圖:
至此在共享內存和工作內存中的volatile
的寫-讀的工作過程全部完成
但是現在的CPU中存在一個緩存,CPU讀取或者修改數據的時候是從緩存中獲取並修改數據,那么如何保證CPU緩存中的數據與共享內存中的一致,並且修改后寫回共享內存呢?
CPU對於Volatile的支持
緩存行:cpu緩存存儲數據的基本單位,cpu不能使數據失效,但是可以使緩存行失效。
對於CPU來說,CPU直接操作的內存時高速緩存,而每一個CPU都有自己L1、L2以及共享的L3級緩存,如下圖:
那么當CPU修改自身緩存中的被volatile
修飾的共享變量時,如何保證對其他CPU的可見性。
緩存一致性協議
在多處理器的情況下,每個處理器總是嗅探總線上傳播的數據來檢查自己的緩存是否過期,當處理器發現自己對應的緩存對應的地址被修改,
就會將當前處理器的緩存行設置為無效狀態,當處理器對這個數據進行操作的時候,會重新從系統中把數據督導處理器的緩存里。這個協議被稱之為緩存一致性協議。
緩存一致性協議的實現又MEI
、MESI
、MOSI
等等。
MESI
協議緩存狀態
狀態 | 描述 |
---|---|
M(modified)修改 | 該緩存指被緩存在該CPU 的緩存中並且是被修改過的,即與主存中的數據不一致,該緩存行中的數據需要在未來的某個時間點寫回主存,當寫回注冊年之后,該緩存行的狀態會變成E(獨享) |
E(exclusive)獨享 | 該緩存行只被緩存在該CPU 的緩存中,他是未被修改過的,與主存中數據一致,該狀態可以在任何時候,當其他的CPU讀取該內存時編程共享狀態,同樣的,當CPU修改該緩存行中的內容時,該狀態可以變為M(修改) |
S(share)共享 | 該狀態意味着該緩存行可能被多個CPU 緩存,並且各個緩存中的數據與主存中的數據一致,當有一個CPU修改自身對應的緩存的數據,其它CPU中該數據對應的緩存行被作廢 |
I(Invalid)無效 | 該緩存行無效 |
MESI
協議可以防止緩存不一致的情況,但是當一個CPU
修改了緩存中的數據,但是沒有寫入主存,也會存在問題,那么如何保證CPU
修改共享被volatile
修飾的共享變量后立刻寫回主存呢。
在有volatile
修飾的共享變量進行寫操作的時候會多出一條帶有lock
前綴的匯編代碼,而這個lock
操作會做兩件事:
- 將當前處理器的緩存行的數據協會到系統內存。
lock
信號確保聲言該信號期間CPU
可以獨占共享內存。在之前通過鎖總線的方式,現在采用鎖緩存的方式。 - 這個寫回操作會使其他處理器的緩存中緩存了該地址的緩存行無效。在下一次這些
CPU
需要使用這些地址的值時,強制要求去共享內存中讀取。
如果對聲明了volatile
的共享變量進行寫,JVM
會向CPU
發送一條lock
指令,使得將這個變量所在的緩存行緩存的數據寫回到內存中。而其他CPU
通過嗅探總線上傳播的數據,
使得自身緩存行失效,下一次使用時會從主存中獲取對應的變量。
工作內存(本地內存)並不存在
根據JAVA
內存模型描述,各個線程使用自身的工作內存來保存共享變量,那么是不是每個CPU
緩存的數據就是從工作內存中獲取的。這樣的話,在CPU
緩存寫回主存時,
協會的是自己的工作內存地址,而各個線程的工作內存地址並不一樣。CPU
嗅探總線時就嗅探不到自身的緩存中緩存有對應的共享變量,從而導致錯誤?
事實上,工作內存並不真實存在,只是JMM
為了便於理解抽象出來的概念,它涵蓋了緩存,寫緩沖區、寄存器及其他的硬件編譯器優化。所以緩存是直接和共享內存交互的。
每個CPU
緩存的共享數據的地址是一致的。
總結
-
volatile
提供了一種輕量級同步機制來完成同步,它可以保操作的可見性、有序性以及對於單個volatile
變量的讀/寫具有原子性,對於符合操作等非原子操作不具有原子性。 -
volatile
通過添加內存屏障及緩存一致性協議來完成對可見性的保證。
最后Lock#lock()
是如何保證可見性的呢??
Lock#lock()
使用了AQS
的state
來標識鎖狀態,而state
是volatile
標記的,由於對於volatile
的獨寫操作時添加了內存屏障的,所以在修改鎖狀態之前,
一定會將之前的修改寫回共享內存。