線程:併發中可能存在的問題

1. 併發編程三大特性

併發編程的三大特性實際上是併發編程中所有問題的根源,

  1. 原子性
    所有操作要麼全部成功,要麼全不成功
  2. 可見性
    一個線程對變量的修改,其餘的線程能夠立刻讀取到變量的最新值
  3. 有序性
    代碼在執行階段的執行順序與程序中編寫順序可能不一致

2. 原子性

原子性是三大特性中最好理解的,此處需要引入競態條件的概念。

競態條件

競態條件是指,在多線程的情況下,由於多個線程執行的時序不同,而出現不正確的結果。

以抄寫單詞爲例,多個人抄寫100遍,

  1. 查詢剩餘抄寫次數
  2. 如果剩餘次數大於1,則把次數減1
  3. 抄寫單詞

這三個操作是原子的,在執行區間不能有其他線程讀取剩餘次數。

上例也是最常見的競態條件類型。上面例子的問題出現在第 2、3 步操作依賴於第1步的檢查,而第一步的檢查結果並不能保證在執行 2、3 步的時候依舊有效。這是因爲其它線程可能在在執行完第一步時已經改變了剩餘次數。此時 2,3 步依舊會按照已經失效的檢查結果繼續執行,那麼線程安全問題就出現了。

單例模式在併發問題中就很容易出現這種錯誤,

public class Singleton {
    private static Singleton singleton = null;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if(singleton==null){
            singleton = new Singleton();
        }
        return singleton;
    }
}

這段代碼在非併發的情況下沒有任何問題。但是在併發的情況下,因爲競態條件有可能引發錯誤。如果線程 A 判斷 singleton 爲空後準備創建 singleton 對象。此時線程切換到線程B,B也開始執行這段代碼,它同樣會判斷 singleton 爲空去創建 singleton,這樣本來的單例卻變成了雙例,和期望的正確結果不一致。

3. 可見性

緩存不一致性

不可見性是由於緩存不一致性導致的,緩存一致性一直是編程領域的難題之一。變量被修改後,當前線程中是能夠立刻被看到的,但是並不保證別的線程會立刻看到。

public class visibility {
    private static class ShowVisibility implements Runnable{
        public static Object o = new Object();
        private Boolean flag = false; 
        @Override
        public void run() {
            while (true) {
                if (flag) {
                    System.out.println(Thread.currentThread().getName()+":"+flag);
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ShowVisibility showVisibility = new ShowVisibility();
        Thread blindThread = new Thread(showVisibility);
         blindThread.start();
        //給線程啓動的時間
        Thread.sleep(500);
        //更新flag
        showVisibility.flag=true;
        System.out.println("flag is true, thread should print");
        Thread.sleep(1000);
        System.out.println("I have slept 1 seconds. I guess there was nothing printed ");
    }
}

理論上主線程將子線程的flag改爲 true後,blindThread開始打印。但是實際執行情況如下,

flag is true, thread should print
I have slept 1 seconds. I guess there was nothing printed

實際上,blindThread會一直保持不打印的狀態,因爲進入 while循環後,循環過程使用的flag變量不是內存中的flag變量,而是子線程緩存中的變量。while 循環不斷獲取該變量值,不斷從緩存中讀取。即使內存更新的情況下,子線程依舊無法知道該變量的實際值。

CPU緩存模型

摩爾定律表明CPU的運算速度每 18 個月速度將會翻一番。CPU 的計算速度提升了,但是內存的訪問速度卻沒有什麼大幅度的提升。這就好比一個腦瓜很聰明程序員,很快就想好程序怎麼寫,但是電腦性能很差,每敲一行代碼都要反應好久,導致完成編碼的時間依舊很長。CPU 計算的瓶頸出現在對內存的訪問上,所以CPU使用了 L1、L2、L3,一共三級緩存。其中 L1 緩存根據用途不同,還分爲 L1i 和 L1d 兩種緩存。如下圖,
image

緩存的訪問速度是主存的幾分之一,甚至幾十分之一。通過緩存極大的提高了 CPU 計算速度。CPU 會先從主存中複製數據到緩存,CPU 在計算的時候就可以從緩存讀取數據了,在計算完成後再把數據從緩存更新回主存。這樣在計算期間,就無須訪問主存了,速度大大提升。加上緩存後,CPU 的數據訪問如下,
image
這個模型也就解釋了 blindThread運行過程中沒有讀取內存中的flag變量的原因。

Volatile關鍵字

對上面的 blindThread代碼進行一處修改就能夠解決子線程不輸出的問題,

private Boolean flag = false;

改爲,

private volatile Boolean flag = false;

4.有序性

CPU 爲了提高運行效率,可能會對編譯後代碼的指令做一些優化,這些優化不能保證代碼順序執行。但是一定能保證代碼執行的結果和按照編寫順序執行的結果是一致的。

指令重排序的優化僅對單線程程序確保安全。如果在併發的情況下,程序沒能保證有序性,程序的執行結果往往會出乎意料。另外注意,指令重排序,並不是代碼重排序。代碼被編譯後,一行代碼可能會對應多條指令,所以指令重排序更爲細粒度。

指令重排實例

public class Singleton {
    private static Singleton instance; 
    private Singleton (){}
 
    public static Singleton getSingleton() {
        if (instance == null) {                         
            synchronized (Singleton.class) {
                if (instance == null) {       
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

上面通過雙重判斷的方式希望能夠避免單例模式構建失敗,理想的執行過程中即使在併發的環境中不會出現單例模式失效的情況,理論上這樣的雙重判斷是沒毛病的。

但是實際執行過程中依舊會出現問題,與原子性中的例子不同,此時出現的不是過多對象構建的現象。而是出現對象獲取不正確的現象。

原因就出現在instance = new Singleton();創建對象的過程中,這一行代碼會被編譯爲3條指令。正常指令順序和實際執行順序的對比如下,
image
可以看出在優化後第 2 和第 3 步調換了位置。

  1. 假如線程 A 正在初始化 instance,此時執行完第 2 步,正在執行第三步
  2. 線程 B 執行到 if (instance == null) 的判斷,那麼線程 B 就會直接得到未初始化好的 instance,而此時線程 B 使用此 instance 顯然是有問題的

要解決本例的有序性問題很簡單,只需要爲 instance 聲明時增加 volatile 關鍵字,volatile 修飾的變量是會保證讀操作一定能讀到寫完的值

5. 問題根源—JMM模型

JMM概述

JMM是Java內存模型,是一種規範,描述了Java程序的運行行爲,包括多線程操作對共享內存讀取時所能讀取的遵守的規則。

CPU的性能越來越強大,受迫於頻率提升的困難,現代CPU架構開始向多核發展。爲充分使用CPU的性能,越來越多的開發者會選擇多線程程序開發。CPU在計算時會做一些優化,這些優化對於單線程程序來說是沒有問題的,但對多線程程序則不是那麼的友好。

計算機在運行時,絕大多數時間都會把對象信息保存在內存中。但是在此期間,編譯器、處理器或者緩存都可能會把變量從分配的內存中取出處理再放回。比如在while循環中判斷flag是否爲true來執行一段特定的邏輯,那麼編譯器爲了優化可能會選擇把flag值取到緩存中。此時主存中的flag值可能會被其它線程所改變,但是此線程是無法感知的。直到某個特定的時機觸發此線程從主存中刷新flag值。所有這些優化都是爲了程序有更好的性能。在單線程的程序中,這種優化對於用戶來講是毫無感知的,不過多線程的程序中,這種優化有些時候會造成難以預料的結果。

JMM允許編譯器和緩存保持對數據操作順序優化的自由度。除非程序使用Synchronized或者volatile顯式的告訴處理器需要確保可見性。這意味着如果沒有進行同步,那麼多線程程序對於數據的操作,將會呈現不同的順序。也就是有序性一節中,基於代碼順序對數據賦值順序的推論,在多線程程序中可能會不成立。

Happens-before規則

JMM對程序中所有操作都定義了Happens-before規則。無論兩個操作是否在同一個線程,如果要想保證操作A能看到操作B的結果,那麼A、B之間一定要滿足Happens-Before關係。如果兩者間不滿足Hapen-Before關係,JVM可以對其任意重排序。

Happens-Before在多線程領域具有重大意義,如果想對共享變量的操作符合設想的順序,那麼需要依照Happens-Before原則來開發。happens-before並不是指操作A先於操作B發生,而是指操作A的結果在什麼情況下可以被後面操作B所獲取。

Happens-before原則如下,

  1. 程序順序規則,如果程序中A操作在B操作之前,則線程中A操作也同樣在B操作之前執行
  2. 上鎖原則,不同線程對同一個鎖的lock操作一定發生在unlock操作之前
  3. volatile變量原則,對於volatile變量的寫操作一定早於對其的讀操作
  4. 傳遞規則,如果A早於B執行,B早於C執行,則A一定早於C執行
  5. 線程中斷原則,線程interruput方法一定早於檢測到線程的中斷信號
  6. 線程終結規則,如果線程A終結了,並導致另外一個線程B中ThreadA.join()方法取得返回,則線程A中所有的操作都早於線程B在ThreadA.join()之後執行的操作

6. 死鎖

前面幾節講解了併發的三大特性 - 原子性、可見性、有序性。解決這些問題的關鍵就是同步,而一種重要的同步方式就是加鎖,所謂的加鎖就是某個線程聲明某個資源暫時由當前獨享,等用完後,此線程解鎖,也就是放棄對該資源的佔有。

如果有其它線程等待使用該資源,那麼會再次對此資源加鎖。所謂的死鎖,其實就是因爲某種原因,達不到解鎖的條件,導致某線程對資源的佔有無法釋放,其他線程會一直等待其解鎖,而被一直 block 住。

死鎖產生原因

  1. 交叉死鎖
    線程A持有資源R1的鎖,線程B持有資源R2的鎖。此時線程A想獲取R2的鎖,B也想要獲取R1的鎖。兩個線程都在等待對方釋放資源,就一直僵持
  2. 內存不足
    假設系統內存20M,兩個執行的線程鴿子使用了10M,但是10M對於任務的執行是不夠的,兩個線程都在等待對方執行完畢釋放內存
  3. 交互過程死鎖
    客戶端發送請求服務端響應爲例,如果在交互過程中出現了數據丟失,客戶端以爲服務端沒有返回數據,服務端以爲客戶端還沒收到數據。兩個端都在等待對方的回信,如果沒有設置超時,會造成持續的等待
  4. 數據庫鎖
    數據庫中對錶或者行記錄加鎖,如果沒能正確釋放鎖,會導致其他線程陷入等待
  5. 文件鎖
    與數據庫鎖形式相近,無法正確釋放文件鎖會導致其他線程無法讀取文件內容,直到系統釋放文件資源
  6. 死循環
    某個線程對資源加鎖後,陷入死循環,一直無法釋放鎖

死鎖舉例

交叉死鎖是最常見的死鎖,

public class DeadLock {
    private final String write_lock = new String();
    private final String read_lock = new String();

    public void read() {
        synchronized (read_lock) {
            System.out.println(Thread.currentThread().getName() + " got read lock and then i want to write");
            synchronized (write_lock) {
                System.out.println(Thread.currentThread().getName() + " got read lock and write lock");
            }
        }
    }

    public void write() {
        synchronized (write_lock) {
            System.out.println(Thread.currentThread().getName() + " got write lock and then i want to read");
            synchronized (read_lock) {
                System.out.println(Thread.currentThread().getName() + " got write lock and read lock");
            }
        }
    }

    public static void main(String[] args) {
        DeadLock deadLock = new DeadLock();
        new Thread(() -> {
            while (true) {
                deadLock.read();
            }
        },"read-first-thread").start();

        new Thread(() -> {
            while (true) {
                deadLock.write();
            }
        },"write-first-thread").start();
    }
}

main方法的執行結果,(本次運行子線程read-first-thread先執行,可能其他測試過程中write-first-thread會先執行,不過都會產生交叉死鎖)

read-first-thread got read lock and then i want to write
read-first-thread got read lock and write lock
read-first-thread got read lock and then i want to write
read-first-thread got read lock and write lock
read-first-thread got read lock and then i want to write
read-first-thread got read lock and write lock
read-first-thread got read lock and then i want to write
write-first-thread got write lock and then i want to read

本次運行過程中,在 write 線程啓動前,一切正常。read-first-thread 線程能夠先後獲得 read 鎖和 write 鎖。但是當 write 線程啓動後,立刻出現了問題,日誌不再打印,而是停留在 write 線程等待 read 鎖這一步。這是因爲已經死鎖了。 read 線程在等 write 線程釋放寫鎖,而 write 線程在等 read 線程釋放讀鎖。兩個線程就會如此一直等下去了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章