編寫正確的程序難,編寫正確的併發程序則是難上加難。既然這麼難爲什麼還要併發,單線程執行不好嗎?爲了快呀,點個鏈接你願意等1分鐘嗎?,別說等一分鐘了,要是有個網頁讓我等超過10秒鐘,我就馬上要關掉了。
我們編寫的代碼在計算機中運行,那麼它肯定會用到計算機中的資源,一般都逃不過cpu、內存以及I/O(文件I/O或者網絡I/O等)。但是這三者速度上有極大的差異。
CPU的速度遠遠快於內存,而內存的速度又遠遠遠快於I/O。
比喻: CPU速度相當於 火箭,內存速度相當於 高鐵,I/O速度相當於 步行。
而我們的程序運行的快慢實際上是取決於最慢的那個操作–I/O操作,彷彿在這個時候CPU再快都沒啥作用。
我們一般都說盡可能少的查詢數據庫(batch的方式更好),就是爲了較少I/O操作
爲了合理使用CPU性能,平衡這三者間的速度差。計算機體系結果、操作系統、編譯程序都做出了貢獻,主要體現在:
- CPU 增加了緩存,以均衡與內存的速度差異
- 操作系統增加了進程、線程,以分時複用 CPU,進而均衡 CPU 與 I/O 設備的速度差異
- 編譯程序優化指令執行次序,使得緩存能夠得到更加合理地利用
緩存導致的可見性問題
單核CPU的時候,所有線程操作的都是同一個CPU的緩存,一個線程對另緩存的寫,對另一個線程來說一定是可見的。例如在下面的圖中,線程 A 和線程 B 都是操作同一個 CPU 裏面的緩
存,所以線程 A 更新了變量 V 的值,那麼線程 B 之後再訪問變量 V,得到的一定是 V 的最新值(線程 A 寫過的值)。
一個線程對共享變量的修改,另外一個線程能夠立刻看到,我們稱爲可見性。
但是隨着多核時代的來臨,每顆 CPU 都有自己的緩存,這時 CPU 緩存與內存的數據一致性就沒那麼容易解決了,當多個線程在不同的 CPU 上執行時,這些線程操作的是不同的 CPU 緩存。比如
下圖中,線程 A 操作的是 CPU1 上的緩存,而線程 B 操作的是 CPU2 上的緩存,很明顯,這個時候線程 A 對變量 V 的操作對於線程 B 而言就不具備可見性了
public class Counter {
int v = 0;
public void add() {
for(int i = 0; i < 10000; i++) {
v += 1;
}
}
public static void main(String[] args) throws InterruptedException {
Counter c = new Counter();
Thread t1 = new Thread(() -> {
c.add();
});
Thread t2 = new Thread(() -> {
c.add();
});
// 啓動線程
t1.start();
t2.start();
// 等待兩個線程執行結束
t1.join();
t2.join();
System.out.println(c.v);
}
}
比如上面的代碼,每次執行的結果都不一樣,執行結果也是介於10000和20000之間。
CPU cache中的值什麼時候刷新到內存(主存)中是不確定的,所以有可能某個後啓動的線程讀取到的值不一定是1,而是其他值(代碼所示的兩個線程啓動是存在時間差的)。
線程切換帶來的原子性問題
你可知道電腦中的進程是交替運行的,你能一邊聽歌一邊看電影都歸功於這個進程切換。操作系統允許某個進程執行一小段時間,例如 50 毫秒,過了 50 毫秒操作系統就會重新選
擇一個進程來執行(我們稱爲“任務切換”),這個 50 毫秒稱爲“時間片”。
Java 併發程序都是基於多線程的,自然也會涉及到任務切換,也許你想不到,任務切換竟然也是併發編程裏詭異 Bug 的源頭之一。任務切換的時機大多數是在時間片結束的時候,
我們現在基本都使用高級語言編程,高級語言裏一條語句往往需要多條 CPU 指令完成,例如上面代碼中的v += 1,至少需要三條 CPU 指令。
- 指令 1:首先,需要把變量 v 從內存加載到 CPU 的寄存器;
- 指令 2:之後,在寄存器中執行 +1 操作;
- 指令 3:最後,將結果寫入內存(緩存機制導致可能寫入的是 CPU 緩存而不是內存)。
操作系統做任務切換,可以發生在任何一條CPU 指令執行完,是的,是 CPU 指令,而不是高級語言裏的一條語句。對於上面的三條指令來說,我們假設 v=0,如果線程 A 在
指令 1 執行完後做線程切換,線程 A 和線程 B 按照下圖的序列執行,那麼我們會發現兩個線程都執行了 v+=1 的操作,但是得到的結果不是我們期望的 2,而是 1。
編譯優化(指令重排)帶來的有序性問題
我們都知道編譯器爲了優化性能,是會調整語句順序的。比如下面的代碼
int a = 1;
long b = 2L;
編譯器優化之後可能會變成
long b = 2L;
int a = 1;
雖然優化後不影響執行結果,不過有時候編譯器以及解釋器的優化會帶來意想不到的結果。
還記得java中獲取單例對象的雙重檢查嗎?
public class Singleton {
static Singleton instance;
static Singleton getInstance() {
if(instance == null) {
synchronized(Singleton.class) {
if(instance == null)
instance = new Singleton();
}
}
return instance;
}
}
實際上不能保證上面的代碼有效,當我們通過返回的Singleton對象訪問其成員變量,就有可能觸發空指針異常。
instance = new Singleton();
不是原子操作,它由分配空間,初始化對象的字段以及爲instance分配地址的多條指令組成。
- 分配一塊內存 M
- 在內存 M 上初始化 Singleton 對象
- 然後 M 的地址賦值給 instance 變量。
爲了顯示實際發生的情況,我使用一些僞代碼擴展instance = new Singleton();
並內聯對象初始化代碼。
public class Singleton {
static Singleton instance;
static Singleton getInstance() {
if(instance == null) {
synchronized(Singleton.class) {
if(instance == null)
pointer = allocate();
pointer.field1 = initField1();
pointer.field2 = initField2();
instance = pointer;
}
}
return instance;
}
}
爲了提高整體性能,某些編譯器,內存系統或處理器可能會對指令進行重新排序,例如在初始化對象的字段之前移動 instance = pointer。那麼代碼就會變成下面這樣
public class Singleton {
static Singleton instance;
static Singleton getInstance() {
if(instance == null) {
synchronized(Singleton.class) {
if (instance == null)
pointer = allocate();
instance = pointer;
pointer.field1 = initField1();
pointer.field2 = initField2();
}
}
return instance;
}
}
這種重新排序是合法的,因爲instance = pointer;與初始化字段的指令之間沒有數據依賴性。
但是,這種重新排序(以某些執行順序)可能導致其他線程看到instance的非null值,但訪問了該對象的未初始化字段就會出錯。
寫到最後
只要在寫代碼的時候充分考慮上面說的三種情況,那麼一定可以幫助你抽絲剝繭的排查多線程下遇到的問題。
巨人肩膀: 極客時間–<java併發編程實戰>
關注我不迷路
你的關注是對我最大的顧慮,是兄弟就關注我(狗頭保命)