1 來源
- 來源:《Java高併發編程詳解 多線程與架構設計》,汪文君著
- 章節:第十二、十三章
本文是兩章的筆記整理。
2 CPU
緩存
2.1 緩存模型
計算機中的所有運算操作都是由CPU
完成的,CPU
指令執行過程需要涉及數據讀取和寫入操作,但是CPU
只能訪問處於內存中的數據,而內存的速度和CPU
的速度是遠遠不對等的,因此就出現了緩存模型,也就是在CPU
和內存之間加入了緩存層。一般現代的CPU
緩存層分爲三級,分別叫L1
緩存、L2
緩存和L3
緩存,簡略圖如下:
L1
緩存:三級緩存中訪問速度最快,但是容量最小,另外L1
緩存還被劃分成了數據緩存(L1d
,data
首字母)和指令緩存(L1i
,instruction
首字母)L2
緩存:速度比L1
慢,但是容量比L1
大,在現代的多核CPU
中,L2
一般被單個核獨佔L3
緩存:三級緩存中速度最慢,但是容量最大,現代CPU
中也有L3
是多核共享的設計,比如zen3
架構的設計
緩存的出現,是爲了解決CPU
直接訪問內存效率低下的問題,CPU
進行運算的時候,將需要的數據從主存複製一份到緩存中,因爲緩存的訪問速度快於內存,在計算的時候只需要讀取緩存並將結果更新到緩存,運算結束再將結果刷新到主存,這樣就大大提高了計算效率,整體交互圖簡略如下:
2.2 緩存一致性問題
雖然緩存的出現,大大提高了吞吐能力,但是,也引入了一個新的問題,就是緩存不一致。比如,最簡單的一個i++
操作,需要將內存數據複製一份到緩存中,CPU
讀取緩存值並進行更新,先寫入緩存,運算結束後再將緩存中新的刷新到內存,具體過程如下:
- 讀取內存中的
i
到緩存中 CPU
讀取緩存i
中的值- 對
i
進行加1操作 - 將結果寫回緩存
- 再將數據刷新到主存
這樣的i++
操作在單線程不會出現問題,但在多線程中,因爲每個線程都有自己的工作內存(也叫本地內存,是線程自己的緩存),變量i
在多個線程的本地內存中都存在一個副本,如果有兩個線程執行i++
操作:
- 假設兩個線程爲A、B,同時假設
i
初始值爲0 - 線程A從內存中讀取
i
的值放入緩存中,此時i
的值爲0,線程B也同理,放入緩存中的值也是0 - 兩個線程同時進行自增操作,此時A、B線程的緩存中,
i
的值都是1 - 兩個線程將
i
寫入主內存,相當於i
被兩次賦值爲1 - 最終結果是
i
的值爲1
這個就是典型的緩存不一致問題,主流的解決辦法有:
- 總線加鎖
- 緩存一致性協議
2.2.1 總線加鎖
這是一種悲觀的實現方式,具體來說,就是通過處理器發出lock
指令,鎖住總線,總線收到指令後,會阻塞其他處理器的請求,直到佔用鎖的處理器完成操作。特點是隻有一個搶到總線鎖的處理器運行,但是這種方式效率低下,一旦某個處理器獲取到鎖其他處理器只能阻塞等待,會影響多核處理器的性能。
2.2.2 緩存一致性協議
圖示如下:
緩存一致性協議中最出名的就是MESI
協議,MESI
保證了每一個緩存中使用的共享變量的副本都是一致的。大致思想是,CPU
操作緩存中的數據時,如果發現該變量是一個共享變量,操作如下:
- 讀取:不做其他處理,只是將緩存中數據讀取到寄存器中
- 寫入:發出信號通知其他
CPU
將該變量的緩存行設置爲無效狀態(Invalid
),其他CPU
進行該變量的讀取時需要到主存中再次獲取
具體來說,MESI
中規定了緩存行使用4種狀態標記:
M
:Modified
,被修改E
:Exclusive
,獨享的S
:Shared
,共享的I
:Invalid
,無效的
有關MESI
詳細的實現超出了本文的範圍,想要詳細瞭解可以參考此處或此處。
3 JMM
看完了CPU
緩存再來看一下JMM
,也就是Java
內存模型,指定了JVM
如何與計算機的主存進行工作,同時也決定了一個線程對共享變量的寫入何時對其他線程可見,JMM
定義了線程和主內存之間的抽象關係,具體如下:
- 共享變量存儲於主內存中,每個線程都可以訪問
- 每個線程都有私有的工作內存或者叫本地內存
- 工作內存只存儲該線程對共享變量的副本
- 線程不能直接操作主內存,只有先操作了工作內存之後才能寫入主內存
- 工作內存和
JMM
內存模型一樣也是一個抽象概念,其實並不存在,涵蓋了緩存、寄存器、編譯期優化以及硬件等
簡略圖如下:
與MESI
類似,如果一個線程修改了共享變量,刷新到主內存後,其他線程讀取工作內存的時候發現緩存失效,會從主內存再次讀取到工作內存中。
而下圖表示了JVM
與計算機硬件分配的關係:
4 併發編程的三個特性
文章都看了大半了還沒到volatile
?別急別急,先來看看併發編程中的三個重要特性,這對正確理解volatile
有很大的幫助。
4.1 原子性
原子性就是在一次或多次操作中:
- 要麼所有的操作全部都得到了執行,且不會受到任何因素的干擾而中斷
- 要麼所有的操作都不執行
一個典型的例子就是兩個人轉賬,比如A向B轉賬1000元,那麼這包含兩個基本的操作:
- A的賬戶扣除1000元
- B的賬戶增加1000元
這兩個操作,要麼都成功,要麼都失敗,也就是不能出現A賬戶扣除1000但是B賬戶金額不變的情況,也不能出現A賬戶金額不變B賬戶增加1000的情況。
需要注意的是兩個原子性操作結合在一起未必是原子性的,比如i++
。本質上來說,i++
涉及到了三個操作:
get i
i+1
set i
這三個操作都是原子性的,但是組合在一起(i++
)就不是原子性的。
4.2 可見性
另一個重要的特性是可見性,可見性是指,一個線程對共享變量進行了修改,那麼另外的線程可以立即看到修改後的最新值。
一個簡單的例子如下:
public class Main {
private int x = 0;
private static final int MAX = 100000;
public static void main(String[] args) throws InterruptedException {
Main m = new Main();
Thread thread0 = new Thread(()->{
while(m.x < MAX) {
++m.x;
}
});
Thread thread1 = new Thread(()->{
while(m.x < MAX){
}
System.out.println("finish");
});
thread1.start();
TimeUnit.MILLISECONDS.sleep(1);
thread0.start();
}
}
線程thread1
會一直運行,因爲thread1
把x
讀入工作內存後,會一直判斷工作內存中的值,由於thread0
改變的是thread0
工作內存的值,並沒有對thread1
可見,因此永遠也不會輸出finish
,使用jstack
也可以看到結果:
4.3 有序性
有序性是指代碼在執行過程中的先後順序,由於JVM
的優化,導致了代碼的編寫順序未必是代碼的運行順序,比如下面的四條語句:
int x = 10;
int y = 0;
x++;
y = 20;
有可能y=20
在x++
前執行,這就是指令重排序。一般來說,處理器爲了提高程序的效率,可能會對輸入的代碼指令做一定的優化,不會嚴格按照編寫順序去執行代碼,但可以保證最終運算結果是編碼時的期望結果,當然,重排序也有一定的規則,需要嚴格遵守指令之間的數據依賴關係,並不是可以任意重排序,比如:
int x = 10;
int y = 0;
x++;
y = x+1;
y=x+1
就不能先優於x++
執行。
在單線程下重排序不會導致預期值的改變,但在多線程下,如果有序性得不到保證,那麼將可能出現很大的問題:
private boolean initialized = false;
private Context context;
public Context load(){
if(!initialized){
context = loadContext();
initialized = true;
}
return context;
}
如果發生了重排序,initialized=true
排序到了context=loadContext()
的前面,假設兩個線程A、B同時訪問,且loadContext()
需要一定耗時,那麼:
- 線程A通過判斷後,先設置布爾變量的值爲
true
,再進行loadContext()
操作 - 線程B中由於布爾變量被設置爲
true
,會直接返回一個未加載完成的context
5 volatile
好了終於到了volatile
了,前面說了這麼多,目的就是爲了能徹底理解和明白volatile
。這部分分爲四個小節:
volatile
的語義- 如何保證有序性以及可見性
- 實現原理
- 使用場景
- 與
synchronized
區別
先來介紹一下volatile
的語義。
5.1 語義
被volatile
修飾的實例變量或者類變量具有兩層語義:
- 保證了不同線程之間對共享變量操作時的可見性
- 禁止對指令進行重排序操作
5.2 如何保證可見性以及有序性
先說結論:
volatile
能保證可見性volatile
能保證有序性volatile
不能保證原子性
下面分別進行介紹。
5.2.1 可見性
Java
中保證可見性有如下方式:
volatile
:當一個變量被volatile
修飾時,對共享資源的讀操作會直接在主內存中進行(準確來說也會讀取到工作內存中,但是如果其他線程進行了修改就必須從主內存重新讀取),寫操作是先修改工作內存,但是修改結束後立即刷新到主內存中synchronized
:synchronized
一樣能保證可見性,能夠保證同一時刻只有一個線程獲取到鎖,然後執行同步方法,並且確保鎖釋放之前,變量的修改被刷新到主內存中- 使用顯式鎖
Lock
:Lock
的lock
方法能保證同一時刻只有一個線程能夠獲取到鎖然後執行同步方法,並且確保鎖釋放之前能夠將對變量的修改刷新到主內存中
具體來說,可以看一下之前的例子:
public class Main {
private int x = 0;
private static final int MAX = 100000;
public static void main(String[] args) throws InterruptedException {
Main m = new Main();
Thread thread0 = new Thread(()->{
while(m.x < MAX) {
++m.x;
}
});
Thread thread1 = new Thread(()->{
while(m.x < MAX){
}
System.out.println("finish");
});
thread1.start();
TimeUnit.MILLISECONDS.sleep(1);
thread0.start();
}
}
上面說過這段代碼會不斷運行,一直沒有輸出,就是因爲修改後的x
對線程thread1
不可見,如果在x
的定義中加上了volatile
,就不會出現沒有輸出的情況了,因爲此時對x
的修改是線程thread1
可見的。
5.2.2 有序性
JMM
中允許編譯期和處理器對指令進行重排序,在多線程的情況下有可能會出現問題,爲此,Java
同樣提供了三種機制去保證有序性:
volatile
synchronized
- 顯式鎖
Lock
另外,關於有序性不得不提的就是Happens-before
原則。Happends-before
原則說的就是如果兩個操作的執行次序無法從該原則推導出來,那麼就無法保證有序性,JVM
或處理器可以任意重排序。這麼做的目的是爲了儘可能提高程序的並行度,具體規則如下:
- 程序次序規則:在一個線程內,代碼按照編寫時的次序執行,編寫在後面的操作發生與編寫在前面的操作之後
- 鎖定規則:如果一個鎖處於鎖定狀態,則
unlock
操作要先行發生於對同一個鎖的lock
操作 volatile
變量規則:對一個變量的寫操作要早於對這個變量之後的讀操作- 傳遞規則:如果操作A先於操作B,操作B先於操作C,那麼操作A先於操作C
- 線程啓動規則:
Thread
對象的start()
方法先行發生於對該線程的任何動作 - 線程中斷規則:對線程執行
interrupt()
方法肯定要優於捕獲到中斷信號,換句話說,如果收到了中斷信號,那麼在此之前必定調用了interrupt()
- 線程終結規則:線程中所有操作都要先行發生於線程的終止檢測,也就是邏輯單元的執行肯定要發生於線程終止之前
- 對象終結規則:一個對象初始化的完成先行發生於
finalize()
之前
對於volatile
,會直接禁止對指令重排,但是對於volatile
前後無依賴關係的指令可以隨意重排,比如:
int x = 0;
int y = 1;
//private volatile int z;
z = 20;
x++;
y--;
在z=20
之前,先定義x
或先定義y
並沒有要求,只需要在執行z=20
的時候,可以保證x=0,y=1
即可,同理,x++
或y--
具體先執行哪一個並沒有要求,只需要保證兩者執行在z=20
之後即可。
5.2.3 原子性
在Java
中,所有對基本數據類型變量的讀取賦值操作都是原子性的,對引用類型的變量讀取和賦值也是原子性的,但是:
- 將一個變量賦值給另一個變量的操作不是原子性的,因爲涉及到了一個變量的讀取以及一個變量的寫入,兩個原子性操作結合在一起就不是原子性操作
- 多個原子性操作在一起就不是原子性操作,比如
i++
JMM
只保證基本讀取和賦值的原子性操作,其他的均不保證,如果需要具備原子性,那麼可以使用synchronized
或Lock
,或者JUC
包下的原子操作類
也就是說,volatile
並不能保證原子性,例子如下:
public class Main {
private volatile int x = 0;
private static final CountDownLatch latch = new CountDownLatch(10);
public void inc() {
++x;
}
public static void main(String[] args) throws InterruptedException {
Main m = new Main();
IntStream.range(0, 10).forEach(i -> {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
m.inc();
}
latch.countDown();
}).start();
});
latch.await();
System.out.println(m.x);
}
}
最後輸出的x
的值會少於10000
,而且每次運行的結果也並不相同,至於原因,可以從兩個線程A、B開始分析,圖示如下:
0-t1
:線程A將x
讀入工作內存,此時x=0
t1-t2
:線程A時間片完,CPU
調度線程B,線程B將x
讀入工作內存,此時x=0
t2-t3
:線程B對工作內存中的x
進行自增操作,並更新到工作內存中t3-t4
:線程B時間片完,CPU
調度線程A,同理線程A對工作內存中的x
自增t4-t5
:線程A將工作內存中的值寫回主內存,此時主內存中的值爲x=1
t5
以後:線程A時間片完,CPU
調度線程B,線程B也將自己的工作內存寫回主內存,再次將主內存中的x
賦值爲1
也就是說,多線程操作的話,會出現兩次自增但是實際上只進行一次數值修改的操作。想要x
的值變爲10000
也很簡單,加上synchronized
即可:
new Thread(() -> {
synchronized (m) {
for (int j = 0; j < 1000; j++) {
m.inc();
}
}
latch.countDown();
}).start();
5.3 實現原理
前面已經知道,volatile
可以保證有序性以及可見性,那麼,具體是如何操作的呢?
答案就是一個lock;
前綴,該前綴實際上相當於一個內存屏障,該內存屏障會爲指令的執行提供如下幾個保障:
- 確保指令重排序時不會將其後面的代碼排到內存屏障之前
- 確保指令重排序時不會將其前面的代碼排到內存屏障之後
- 確保執行到內存屏障修飾的指令時前面的代碼全部執行完成
- 強制將線程工作內存中的值修改刷新到主存中
- 如果是寫操作,會導致其他線程工作內存中的緩存數據失效
5.4 使用場景
一個典型的使用場景是利用開關進行線程的關閉操作,例子如下:
public class ThreadTest extends Thread{
private volatile boolean started = true;
@Override
public void run() {
while (started){
}
}
public void shutdown(){
this.started = false;
}
}
如果布爾變量沒有被volatile
修飾,那麼很可能新的布爾值刷新不到主內存中,導致線程不會結束。
5.5 與synchronized
的區別
- 使用上的區別:
volatile
只能用於修飾實例變量或者類變量,但是不能用於修飾方法、方法參數、局部變量等,另外可以修飾的變量爲null
。但synchronized
不能用於對變量的修飾,只能修飾方法或語句塊,而且monitor
對象不能爲null
- 對原子性的保證:
volatile
無法保證原子性,但是synchronized
可以保證 - 對可見性的保證:
volatile
與synchronized
都能保證可見性,但是synchronized
是藉助於JVM
指令monitor enter
/monitor exit
保證的,在monitor exit
的時候所有共享資源都被刷新到主內存中,而volatile
是通過lock;
機器指令實現的,迫使其他線程工作內存失效,需要到主內存加載 - 對有序性的保證:
volatile
能夠禁止JVM
以及處理器對其進行重排序,而synchronized
保證的有序性是通過程序串行化執行換來的,並且在synchronized
代碼塊中的代碼也會發生指令重排的情況 - 其他區別:
volatile
不會使線程陷入阻塞,但synchronized
會