深入JAVA執行緒安全問題

eacape發表於2022-03-13

執行緒安全問題

執行緒不安全問題指的是一個類在多執行緒情況下執行會出現一些未知結果.
執行緒安全問題主要有:原子性 可見性 有序性

原子性

對於涉及共享變數訪問的操作,在除執行本操作的執行緒外的執行緒看來都是不可分割的,那麼這個操作就叫做原子性操作,我們稱之為該操作具有原子性.

  1. 出現原子性問題的兩大要素(共享變數 + 多執行緒)

    • 原子性操作是針對共享變數的操作而言的,區域性變數無所謂是否原子操作(因為區域性變數位於棧幀處於執行緒內部,不存在多執行緒問題).
    • 原子性操作是針對多執行緒環境的,在單執行緒不存線上程安全問題.
  2. 原子性操作"不可分割"描述的含義

    • 對於共享變數的操作,對於除操作執行緒外的其它執行緒來說要麼尚未發生要麼已經結束,它們無法看到操作的中間結果.
    • 訪問同一組共享變數是不能交錯進行的.
  3. 實現原子性操作的兩種方式:1.鎖 2.處理器的CAS指令
    鎖一般是在軟體層面實現的,CAS通常是在硬體層面實現
  4. 在Java語言中long/double兩種基本型別的寫操作不具有原子性,其它六種基本型別是具有寫原子性的.使用volatile關鍵字修飾 long/double型別可以使其具有寫原子性.

可見性

在多執行緒環境下,一個執行緒對共享變數做出更新,後續訪問這個共享變數的執行緒無法立即獲取到這個更新後的結果,甚至永遠也獲取不到這個結果,這個現象就被稱之為可見性問題.

  1. 處理器與記憶體的讀寫操作並不是直接進行的,而是要通過 暫存器 寫緩衝器 快取記憶體無效化佇列等部件來進行記憶體的讀寫操作的.

    cpu ==> 寫快取器 ==> 快取記憶體 ==> 無效化佇列
    ||        ||          ||
    ===========       快取一致性協議
  2. 快取同步:雖然一個處理器的快取記憶體的內容不能被另外的處理器讀取,但是一個處理器可以通過快取一致性協議(MESI)來讀取其它處理器的快取記憶體,並將讀取到的內容更新到自己的快取記憶體當中去,這個過程我們稱之為快取同步.
  3. 可見性問題產生的原因

    • 程式中的共享變數可能被分配到處理器的暫存器中儲存,每個處理器都有自己的暫存器,而且暫存器中的內容是不能被其它處理器訪問的.所以當兩個執行緒被分配到不同的處理器且共享變數被儲存在各自的暫存器當中,就會導致一個執行緒永遠訪問不到另一個執行緒對共享變數的更新,就產生了可見性問題.
    • 即使共享變數被分配到主記憶體中儲存,處理器讀取主記憶體是通過快取記憶體進行的,當處理器A操作完共享變數將結果更新到快取記憶體先要通過寫緩衝器,在操作結果只更新到寫緩衝器的時候,處理器B來訪問共享變數,一樣會出現可見性問題(寫快取器不能被其它處理器訪問).
    • 共享變數的操作結果從快取記憶體更新到另一個處理器的快取記憶體中後,但是卻被這個處理器放進了無效化佇列當中,導致處理器讀取的共享變數內容仍然是過時的,這也就出現了可見性問題.
  4. 可見性保障的實現方式

    • 沖刷處理器快取:當一個處理器對共享變數進行更新後,必須讓它的更新最終被寫入到高速緩衝或者主記憶體中.
    • 重新整理處理器快取:當處理器操作一個共享變數的時候,其它處理器在此之前已經對這個共享變數進行了更新,那麼必須要對快取記憶體或者主記憶體進行快取同步.
  5. volatile的作用

    • 提示JIT編譯器,這個volatile修飾的變數可能被多個執行緒共享,避免JIT編譯器對其進行可能導致程式不正常執行的優化.
    • 在讀取volatile修飾的變數的時候先進行重新整理處理器快取操作,在更新volatile修飾的變數後進行沖刷處理器快取.
  6. 單處理器會不會出現可見性問題
    單處理器實現多執行緒操作時通過上下文切換實現的,當發生切換的時候暫存器中的資料也會被儲存起來不被"下文"所訪問,所以當共享變數儲存在暫存器當中時也會出現可見性問題.

有序性

  1. 重排序的概念:處理器執行操作的順序與我們目的碼指定的順序不一致
    重排序有以下幾種情況

    • 編譯器編譯出的位元組碼順序與目的碼不一致
    • 位元組碼指令執行順序與目的碼不一致
    • 目的碼正確執行,但是其它處理器對目的碼的執行順序感知發生錯誤
      比如:處理器A先執行了a操作再執行了b操作,但是在處理器B看來處理器A先執行的是b操作,這就是一種感知錯誤.
      從重排序的的來源一般將重排序分為:指令重排序儲存子系統重排序

      重排序是對記憶體訪問操作的一種優化,它並不影響單執行緒下程式執行的正確性,但是會影響多執行緒下程式執行的正確性.
  2. 指令重排序
    編譯器出於效能考慮,在不影響程式正確性的情況下對指令的執行順序做出相應的調動,從而造成執行順序與原始碼順序不一致.
    java平臺有兩種編譯器:

    • 靜態編譯器(javac),將java原始碼翻譯成位元組碼檔案(.class),在這個時期基本不會發生指令重排序.
    • 動態編譯器(JIT),將java位元組碼動態編譯成機器碼,指令重排序經常發生在這個時期.
      現代處理器為了執行效率往往不是按照程式順序執行指令,而是動態調整指令執行順序,做到哪條指令先就緒就先執行哪條指令,這被稱為亂序執行.這些指令的執行結果會在寫入暫存器或者主記憶體之前,會被先存入到重排序緩衝區中,然後重排序緩衝區會按照程式順序將指令執行結果提交給暫存器或者是主記憶體,所以亂序執行不會影響單執行緒的執行結果的正確性,但是在多執行緒環境中會出現非預期的結果.
  3. 儲存子系統重排序(記憶體重排序)

    Processor-0Processor-1
    data=1; //S1
    ready=true; //S2
    while(! ready){ }//L3
    System.out.println(data); //L4

    當Processor-0和Processor-1都沒有發生指令重排序的情況下,Processor-0按照S1-S2的順序來執行程式,但是Processor-1先感知到了S2執行了,所以Processor-1有可能在沒有感知到S1的情況下會執行完L3-L4那麼此時程式會列印出data=0,這就出現了執行緒安全問題.
    以上情況就是S1和S2發生了記憶體重排序.

  4. 貌似序列語義
    重排序並非編譯器、處理器對指令、記憶體操作的結果進行隨意的調整順序,而是遵循一定的規則.
    編譯器、處理器遵循這種規則會給單執行緒程式帶來一種順序執行的"假象",這種假象被稱作為貌似序列語義.
    為了保證貌似序列語義,有資料依賴關係的語句不會被重排序,沒有資料依賴關係的語句可能被重排序.
    以下面為例:語句③依賴於語句①和語句②所以它們之間不能發生重排序,但是語句①和語句②沒有資料依賴關係所以語句①和語句②可以重排序.

    float price = 59.0f; // 語句①
    short quantity = 5; // 語句②
    float subTotal = price * quantity; // 語句③

    存在控制依賴關係的語句是可以允許被重排序的,如下:
    flag和count存在控制依賴關係,可以被重排序,即,在不知道 flag 的值的情況下,為了追求效率可能先執行count++.

    if(flag){
      count++;
    }
    
  5. 單處理器系統是否會受重排序的影響
    1.靜態編譯期的重排序會影響單處理器系統的處理結果

    Processor-0Processor-1
    data=1; //S1
    ready=true; //S2
    while(! ready){ }//L3
    System.out.println(data); //L4

    如上圖在編譯期S1和S2衝排序後

    Processor-0Processor-1
    ready=true;//S2
    data=1; //S1
    while(! ready){ }//L3
    System.out.println(data); //L4

    當在執行完S2,程式進行上下文切換由Processor-0切換至Processor-1那麼顯然這一次重排序造成了未預期的結果,造成了執行緒安全問題.
    2.執行期重排序(JIT動態編譯、記憶體重排序)不會影響單處理系統的處理結果.

    當發生這些重排序的時候,相關指令還沒有完全執行完畢,系統不會進行上下文切換,會等到發生重排序的指令執行完畢提交後,再進行切換上下文.所以一個執行緒中的重排序對於切換後的另一個執行緒是沒有任何影響的.

上下文切換

上下文切換所需要的開銷

直接開銷包括:

  • 作業系統儲存和恢復上下文所需的開銷,這主要是處理器時間開銷.
  • 執行緒排程器進行執行緒排程的開銷(比如,按照一定的規則決定哪個執行緒會佔用處理器執行).

間接開銷包括:

  • 處理器快取記憶體重新載入的開銷。一個被切出的執行緒可能稍後在另外一個處理器上被切入繼續執行。由於這個處理器之前可能未執行過該執行緒,那麼這個執行緒在其繼續執行過程中需訪問的變數仍然需要被該處理器重新從主記憶體或者通過快取一致性協議從其他處理器載入到快取記憶體之中。這是有一定時間消耗的.
  • 上下文切換也可能導致整個一級快取記憶體中的內容被沖刷(Flush),即一級快取記憶體中的內容會被寫入下一級快取記憶體*(如二級快取記憶體)或者主記憶體(RAM)中.

相關文章