文章目錄
- 1. 并發程式的幕後故事
- 2. 問題1:緩存導緻的可見性問題
- 3. 問題2:線程切換帶來的原子性問題
- 4. 問題3:編譯優化帶來的有序性問題
- 5.總結
- 6. 問題
1. 并發程式的幕後故事
核心沖突: CPU,記憶體,I/O裝置三者的速度差異.
形象比喻:
項目 | CPU | 記憶體 | I/O裝置 |
---|---|---|---|
速度比較 | 一天 | 一年 | 幾千年 |
為了提高CPU使用率,平衡三者的速度差異,計算機體系機構,作業系統,編譯程式分别做以下貢獻:
- CPU增加CPU緩存(不同于記憶體,以下簡稱緩存),以均衡和記憶體的速度差異;
- 作業系統增加程序和線程,分時複用CPU,均衡CPU和I/O裝置速度差異;
- 編譯程式優化指令執行次序,更加合理利用緩存。
有利就有弊,由此帶來可見性、原子性和有序性問題。
2. 問題1:緩存導緻的可見性問題
可見性: 一個線程對共享變量的修改,另外一個線程能立刻看到。
- 單核時代,不存在可見性問題
所有線程都在單個CPU裡面執行,一個線程對緩存的改寫,另外一個線程是一定可見的。線程A改寫了緩存的V,線程B可以獲得V的最新值。

2. 多核時代,可見性問題
多個線程在不同的CPU執行,緩存變量V分散在不同的CPU緩存上,存在可見性問題。
如下圖,線程 A 操作的是 CPU-1 上的緩存,而線程 B 操作的是 CPU-2 上的緩存,很明顯,這個時候線程 A 對變量 V 的操作對于線程 B 而言就不具備可見性了。
3. 驗證可見性問題
直覺感覺count是20000,實際是介于10000到20000. 假設線程A和B同時執行,第一次把count= 0 讀取到各自緩存,count+=1之後,同時寫到記憶體,發現count是1,而不是2, 之後兩個線程在各自緩存計算count,是以最終結果不是20000. 循環一億次,資料更接近一億,不是兩億。
public class TestVisibility {
private static long count = 0;
private void add10k() {
int idx = 0;
while (idx++ < 10000) {
count += 1;
}
}
private static long calc() throws InterruptedException {
final TestVisibility testVisibility = new TestVisibility();
// 建立兩個線程,執行add10()
Thread thread1 = new Thread(() -> {
testVisibility.add10k();
});
Thread thread2 = new Thread(() -> {
testVisibility.add10k();
});
// 啟動線程
thread1.start();
thread2.start();
// 等待線程結束
thread1.join();
thread2.join();
return count;
}
public static void main(String[] args) throws InterruptedException {
long count = calc();
System.out.println(count);
}
}
3. 問題2:線程切換帶來的原子性問題
原子性: 一個或多個操作在CPU執行過程中不被中斷的特性。
- 線程切換
1. 可見性、原子性和有序性問題:并發程式設計bug源頭 - 理論基礎1. 并發程式的幕後故事2. 問題1:緩存導緻的可見性問題3. 問題2:線程切換帶來的原子性問題4. 問題3:編譯優化帶來的有序性問題5.總結6. 問題 - 多線程的任務切換帶來原子性問題的原因。
首先, 進階語言的一條語句往往需要多條CPU指令完成,例如count += 1,至少需要三條CPU指令:
- 指令1,需要把變量count從記憶體加載到CPU寄存器上;
- 指令2,在寄存器執行+1操作;
- 指令3,将結果寫入記憶體(緩存機制導緻可能寫到CPU緩存而不是記憶體)。
其次, 作業系統做任務切換,可以發現在任務一條CPU指令,注意是CPU指令,不是進階語言的一條語句。
例如下圖,線程A在指令1之後就切換到線程B,等線程B執行上述三條指令,再回來執行指令。我們最後得到的結果是1,而不是2。
CPU 能保證的原子操作是 CPU 指令級别的,而不是進階語言的操作符,這是違背我們直
覺的地方。是以,很多時候我們需要在進階語言層面保證操作的原子性。
4. 問題3:編譯優化帶來的有序性問題
有序性: 程式按照代碼的先後次序執行。
經典案例:雙重檢查建立單利對象
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
想象中的流程是這樣的:
- 假設線程A、B同時調用getInstance(), 他們同時發現instance==null,他們同時加鎖,而JVM隻能保證一個加鎖成功,假設A成功加鎖,B在等待;
- 線程A建立一個執行個體後,釋放鎖;
- 鎖釋放後,B喚醒,加鎖成功,此時B檢查instance==null,已經有執行個體了,B不建立新的執行個體。
看似完美,問題出在 new Singleton() 身上:
我們認為new操作如下:
- 1.配置設定一塊記憶體M;
- 2.在記憶體M初始化Singleton對象;
- 3.然後M的位址指派給instance對象。
實際上優化後的執行路徑是這樣的:
- 1.配置設定一塊記憶體M;
- 2.M的位址指派給instance對象;
- 3.在記憶體M初始化Singleton對象。
我們假設線程A執行到步驟2時,任務切換到線程B,B檢查intance!=null,就傳回執行個體,此時調用intance的成員變量可能出發空指針異常。
問題:線程A在對象還沒有初始化之前怎麼就釋放鎖了?B怎麼可能拿到鎖?
解答:實際上線程B是拿不到鎖的,問題在于時間切片後,線程B進入第一個if後,此時instance不為空,線程B拿到還沒有初始化的instance,調用其成員變量可能會報異常。
5.總結
cpu緩存帶來可見性問題,線程切換帶來原子性問題,編譯優化帶來有序性問題。原本緩存,線程切換,編譯優化是為了提高性能,一個技術解決一個問題,可能帶來其他問題,要懂得如何規避。
6. 問題
在 32 位的機器上對 long 型變量進行加減操作存在并發隐患,到底是不是這樣呢?