編寫正確的程序難,編寫正確的併發程序則是難上加難。既然這麼難爲何還要併發,單線程執行很差嗎?爲了快呀,點個連接你願意等1分鐘嗎?,別說等一分鐘了,要是有個網頁讓我等超過10秒鐘,我就立刻要關掉了。java
咱們編寫的代碼在計算機中運行,那麼它確定會用到計算機中的資源,通常都逃不過cpu、內存以及I/O(文件I/O或者網絡I/O等)。可是這三者速度上有極大的差別。web
CPU的速度遠遠快於內存,而內存的速度又遠遠遠快於I/O。數據庫
❝比喻: CPU速度至關於 火箭,內存速度至關於 高鐵,I/O速度至關於 步行。編程
❞
而咱們的程序運行的快慢其實是取決於最慢的那個操做--I/O操做,彷彿在這個時候CPU再快都沒啥做用。緩存
❝咱們通常都說盡量少的查詢數據庫(batch的方式更好),就是爲了較少I/O操做網絡
❞
爲了合理使用CPU性能,平衡這三者間的速度差。計算機體系結果、操做系統、編譯程序都作出了貢獻,主要體如今:多線程
單核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 指令。
操做系統作任務切換,能夠發生在任何一條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分配地址的多條指令組成。
爲了顯示實際發生的狀況,我使用一些僞代碼擴展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併發編程實戰>」
你的關注是對我最大的顧慮,是兄弟就關注我(狗頭保命)