瞭解這個三個坑,不再怕詭異的BUG了

前言

在高併發的狀況下,你的程序是否是常常出現一些詭異的BUG,每次都是花費大量時間排查,可是你有沒有思考過這一切罪惡的源頭是什麼呢?java

幕後那些事

CPU內存I/O設備的速度差別愈來愈大,這也是程序性能的瓶頸,根據木桶理論,最終決定程序的總體性能取決於最慢的操做-讀寫I/O設備,單方面的提升CPU的性能是無用的。面試

爲了平衡三者的差距,大牛前輩們不斷努力,最終作出了卓越的貢獻:編程

  1. CPU增長了緩存,平衡與內存之間的速度差別
  2. 操做系統增長了進程、線程,以分時複用 CPU,進而均衡 CPUI/O 設備的速度差別;
  3. 編譯程序優化指令執行次序,使得緩存可以獲得更加合理地利用。

注意:正是硬件前輩們作的這些貢獻,額外的後果須要軟件工程師來承擔,太坑了。緩存

坑一:CPU緩存致使的可見性問題

在單核CPU的時代,全部的線程都在單個CPU上執行,不存在CPU數據和內存的數據的一致性。markdown

一個線程對共享變量的修改,另一個線程可以馬上看到,咱們稱爲可見性。併發

由於全部的線程都是在同一個CPU緩存中讀寫數據,一個線程對緩存的寫,對於另一個線程確定是可見的。以下圖:編程語言

單核CPU與內存關係

從上圖能夠很清楚的瞭解,線程A對於變量的修改都是在同一個CPU緩存中,則線程B確定是可見的。高併發

可是多核時代的到來則意味着每一個CPU上都有一個獨立的緩存,信息再也不互通了,此時保證內存和CPU緩存的一致性就很難了。以下圖:性能

雙核CPU與內存關係

從上圖能夠很清楚的瞭解,線程A和線程B對變量A的改變是不可見的,由於是在兩個不一樣的CPU緩存中。優化

最簡單的證實方式則是在多核CPU的電腦上跑一個循環相加的方法,同時開啓兩個線程運行,最終獲得的結果確定不是正確的,以下:

public class TestThread {
    private Long total=0L;
    //循環一萬次相加
    private void add(){
        for (int i = 0; i < 10000; i++) {
            total+=1;
        }
    }

    //開啓兩個線程相加
    public static void calc() throws InterruptedException {
        TestThread thread=new TestThread();
        //建立兩個線程
        Thread thread1=new Thread(thread::add);
        Thread thread2=new Thread(thread::add);

        //啓動線程
        thread1.start();
        thread2.start();

        //阻塞主線程
        thread1.join();
        thread2.join();
        System.out.println(thread.total);
    }
複製代碼

上述代碼在單核CPU的電腦上運行的結果確定是20000,可是在多核CPU的電腦上運行的結果則是在10000~20000之間,爲何呢?

緣由很簡單,第一次在兩個線程啓動後,會將total=0讀取到各自的CPU緩存中,執行total+1=0後,各自將獲得的結果total=1寫入到內存中(理想中應該是total=2),因爲各自的CPU緩存中都有了值,所以每一個線程都是基於各自CPU緩存中的值來計算,所以最終致使了寫入內存中的值是在10000~20000之間。

注意:若是循環的次數不多,這種狀況不是很明顯,若是次數設置的越大,則結果越明顯,由於兩個線程不是同時啓動的。

坑二:線程切換致使的原子性問題

早期的操做系統是基於進程調度CPU,不一樣進程間是共享內存空間的,好比你在IDEA寫代碼的同時,可以打開QQ音樂,這個就是多進程。

操做系統容許某個進程執行一段時間,好比40毫秒,過了這個時間則會選擇另一個進程,這個過程稱之爲任務切換,這個40毫秒稱之爲時間片,以下圖:

任務切換

在一個時間片內,若是一個進程進行IO操做,好比讀文件,這個時候該進程能夠把本身標記爲休眠狀態並讓出CPU的使用權,待文件讀進內存,操做系統會將這個休眠的進程喚醒,喚醒後的進程就有機會從新得到CPU的使用權。

現代的操做系統更加輕量級了,都是基於線程調度,如今提到的任務切換大都指示線程切換

注意:操做系統進行任務切換是基於CPU指令

基於CPU指令是什麼意思呢?Java做爲高級編程語言,一條簡單的語句可能底層就須要多條CPU指令,例如total+=1這條語句,至少須要三條CPU指令,以下:

  1. 指令1:將total從內存讀到CPU寄存器中
  2. 指令2:在寄存器中執行+1
  3. 指令3:將結果寫入內存(緩存機制可能致使寫入的是CPU緩存而不是內存)

基於CPU指令是什麼意思呢?簡單的說就是任務切換的時機多是上面的任何一條指令完成以後。

咱們假設在線程A執行了指令1後作了任務切換,此時線程B執行,雖然執行了total+1=1,可是最終的結果卻不是2,以下圖:

非原子操做

咱們把一個或者多個操做在CPU執行過程當中不被中斷的特性稱之爲原子性。

注意:CPU僅僅能保證CPU指令執行的原子性,並不能保證高級語言的單條語句的原子性。

此處分享一道經典的面試題:Long類型的數據在32位操做系統中加減是否存在併發問題?答案:是,由於Long類型是64位,在32位的操做系統中執行加減確定是要拆分紅多個CPU指令,所以沒法保證加減的原子性。

坑三:編譯優化帶來的有序性問題

編譯優化算是最詭異的一個難題了,雖然高級語言規定了代碼的執行順序,可是編譯器有時爲了優化性能,則會改變代碼執行的順序,好比a=4;b=3;,在代碼中可能給人直觀的感覺是a=4先執行,b=3後執行,可是編譯器可能爲了優化性能,先執行了b=3,這種對於咱們肉眼是不可見的,上面例子中雖然不影響結果,可是有時候編譯器的優化可能致使意想不到的BUG。

雙重校驗鎖實現單例不知你們有沒有據說過,代碼以下:

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}
複製代碼

這裏我去掉了volatile關鍵字,那麼此時這個代碼在併發的狀況下有問題嗎?

上述代碼看上去很完美,可是最大的問題就在new Singleton();這行代碼上,預期中的new操做順序以下:

  1. 分配一塊內存N
  2. 在內存N上初始化Singleton對象
  3. 將內存N的地址賦值給instance變量

可是實際上編譯優化後的執行順序以下:

  1. 分配一塊內存N
  2. 將內存N的地址賦值給instance變量
  3. 在內存N上初始化Singleton對象

不少人問了,優化後影響了什麼?

將內存N的地址提早賦值給instance變量意味着instance!=null是成立的,一旦是高併發的狀況下,線程A執行第二步發生了任務切換,則線程B執行到了 if (instance == null)這個判斷,此時不成立,則直接返回了instance,可是此時的instance並無初始化過,若是此時訪問其中的成員變量則會發生空指針異常,執行流程以下圖:

單例NPE

總結

併發編程是區分高低手的門檻,只有深入理解三大特性:可見性原子性有序性才能解決詭異的BUG

本文分析了帶來這三大特性源頭,以下:

  1. CPU緩存致使的可見性問題
  2. 線程切換帶來的原子性問題
  3. 編譯優化帶來的有序性問題

另外,做者已經完成了兩個專欄的文章Mybatis進階Spring Boot 進階 ,已經將專欄文章整理成書,有須要的公衆號回覆關鍵詞Mybatis 進階Spring Boot 進階免費獲取。

相關文章
相關標籤/搜索