面試突擊36:執行緒安全問題是怎麼產生的?

執行緒安全是指某個方法或某段程式碼,在多執行緒中能夠正確的執行,不會出現數據不一致或數據污染的情況,我們把這樣的程式稱之為執行緒安全的,反之則為非執行緒安全的。

舉個例子來說,比如銀行只有張三一個人來辦理業務,這種情況在程式中就叫做單執行緒執行,而單執行緒執行是沒有問題的,也就是執行緒安全的。但突然有一天來了很多人同時辦理業務,這種情況就叫做多執行緒執行。如果所有人都一起爭搶著辦理業務,很有可能會導致錯誤,而這種錯誤就叫非執行緒安全。如果每個人都能有序排隊辦理業務,且工作人員不會操作失誤,我們就把這種情況稱之為執行緒安全的。

問題演示

接下來我們演示一下,程式中非執行緒安全的示例。我們先創建一個變數 number 等於 0,然後開啟執行緒 1 執行 100 萬次 number++ 操作,同時再開啟執行緒 2 執行 100 萬次 number– 操作,等待執行緒 1 和執行緒 2 都執行完,正確的結果 number 應該還是 0,但不加干預的多執行緒執行結果卻與預期的正確結果不一致,如下程式碼所示:

public class ThreadSafeTest {
    // 全局變數
    private static int number = 0;
    // 循環次數(100W)
    private static final int COUNT = 1_000_000;

    public static void main(String[] args) throws InterruptedException {
        // 執行緒1:執行 100W 次 number+1 操作
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < COUNT; i++) {
                number++;
            }
        });
        t1.start();

        // 執行緒2:執行 100W 次 number-1 操作
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < COUNT; i++) {
                number--;
            }
        });
        t2.start();

        // 等待執行緒 1 和執行緒 2,執行完,列印 number 最終的結果
        t1.join();
        t2.join();
        System.out.println("number 最終結果:" + number);
    }
}

以上程式的執行結果如下圖所示:
image.png
從上述執行結果可以看出,number 變數最終的結果並不是 0,和我們預期的正確結果是不相符的,這就是多執行緒中的執行緒安全問題。

產生原因

導致執行緒安全問題的因素有以下 5 個:

  1. 多執行緒搶佔式執行。
  2. 多執行緒同時修改同一個變數。
  3. 非原子性操作。
  4. 記憶體可見性。
  5. 指令重排序。

接下來我們分別來看這 5 個因素的具體含義。

1.多執行緒搶佔式執行

導致執行緒安全問題的第一大因素就是多執行緒搶佔式執行,想像一下,如果是單執行緒執行,或者是多執行緒有序執行,那就不會出現混亂的情況了,不出現混亂的情況,自然就不會出現非執行緒安全的問題了。

2.多執行緒同時修改同一個變數

如果是多執行緒同時修改不同的變數(每個執行緒只修改自己的變數),也是不會出現非執行緒安全的問題了,比如以下程式碼,執行緒 1 修改 number1 變數,而執行緒 2 修改 number2 變數,最終兩個執行緒執行完之後的結果如下:

public class ThreadSafe {
    // 全局變數
    private static int number = 0;
    // 循環次數(100W)
    private static final int COUNT = 1_000_000;
    // 執行緒 1 操作的變數 number1
    private static int number1 = 0;
    // 執行緒 2 操作的變數 number2
    private static int number2 = 0;

    public static void main(String[] args) throws InterruptedException {
        // 執行緒1:執行 100W 次 number+1 操作
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < COUNT; i++) {
                number1++;
            }
        });
        t1.start();

        // 執行緒2:執行 100W 次 number-1 操作
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < COUNT; i++) {
                number2--;
            }
        });
        t2.start();

        // 等待執行緒 1 和執行緒 2,執行完,列印 number 最終的結果
        t1.join();
        t2.join();
        number = number1 + number2;
        System.out.println("number=number1+number2 最終結果:" + number);
    }
}

以上程式的執行結果如下圖所示:
image.png
從上述結果可以看出,多執行緒只要不是同時修改同一個變數,也不會出現執行緒安全問題

3.非原子性操作

原子性操作是指操作不能再被分隔就叫原子性操作。比如人類吸氣或者是呼氣這個動作,它是一瞬間一次性完成的,你不可能先吸一半(氣),停下來玩會手機,再吸一半(氣),這種操作就是原子性操作。而非原子性操作是我現在要去睡覺,但睡覺之前要先上床,再拉被子,再躺下、再入睡等一系列的操作綜合在一起組成的,這就是非原子性操作。
非原子性操作是有可以被分隔和打斷的,比如要上床之前,發現時間還在,先刷個劇、刷會手機、再玩會遊戲,甚至是再吃點小燒烤等等,所以非原子性操作有很多不確定性,而這些不確定性就會造成執行緒安全問題問題。
像 i++ 和 i– 這種操作就是非原子的,它在 +1 或 -1 之前,先要查詢原變數的值,並不是一次性完成的,所以就會導致執行緒安全問題。
比如以下操作流程:

操作步驟 執行緒1 執行緒2
T1 讀取到 number=1,準備執行 number-1 的操作,但還沒有執行,時間片就用完了。
T2 讀取到 number=1,並且執行 number+1 操作,將 number 修改成了 2。
T3 恢復執行,因為之前已經讀取了 number=1,所以直接執行 -1 操作,將 number 變成了 0。

以上就是一個經典的錯誤,number 原本等於 1,執行緒 1 進行 -1 操作,而執行緒 2 進行加 1,最終的結果 number 應該還等於 1 才對,但通過上面的執行,number 最終被修改成了 0,這就是非原子性導致的問題。

4.記憶體可見性問題

在 Java 編程中記憶體分為兩種類型:工作記憶體和主記憶體,而工作記憶體使用的是 CPU 暫存器實現的,而主記憶體是指電腦中的記憶體,我們知道 CPU 暫存器的操作速度是遠大於記憶體的操作速度的,它們的性能差異如下圖所示:

那這和執行緒安全有什麼關係呢?
這是因為在 Java 語言中,為了提高程式的執行速度,所以在操作變數時,會將變數從主記憶體中複製一份到工作記憶體,而主記憶體是所有執行緒共用的,工作記憶體是每個執行緒私有的,這就會導致一個執行緒已經把主記憶體中的公共變數修改了,而另一個執行緒不知道,依舊使用自己工作記憶體中的變數,這樣就導致了問題的產生,也就導致了執行緒安全問題。

5.指令重排序

指令重排序是指 Java 程式為了提高程式的執行速度,所以會對一下操作進行合併和優化的操作。
比如說,張三要去圖書館還書,舍友又讓張三幫忙借書,那麼程式的執行思維是,張三先去圖書館把自己的書還了,再去一趟圖書館幫舍友把書借回來。而指令重排序之後,把兩次執行合併了,張三帶著自己的書去圖書館把書先還了,再幫舍友把書借出來,整個流程就執行完了,這是正常情況下的指令重排序的好處。
但是指令重排序也有「副作用」,而「副作用」是發生在多執行緒執行中的,還是以張三借書和幫舍友還書為例,如果張三是一件事做完再做另一件事是沒有問題的(也就是單執行緒執行是沒有問題的),但如果是多執行緒執行,就是兩件事由多個人混合著做,比如張三在圖書館遇到了自己的多個同學,於是就把任務分派給多個人一起執行,有人借了幾本書、有人借了還了幾本書、有人再借了幾本書、有人再借了還了幾本書,執行的很混亂沒有明確的目標,到最後悲劇就發生了,這就是在指令重排序帶來的執行緒安全問題。

總結

執行緒安全是指某個方法或某段程式碼,在多執行緒中能夠正確的執行,不會出現數據不一致或數據污染的情況,反之則為執行緒安全問題。簡單來說所謂的非執行緒安全是指:在多執行緒中,程式的執行結果和預期的正確結果不一致的問題。而造成執行緒安全問題的因素有 5 個:多執行緒搶佔式執行、多執行緒同時修改同一個變數、非原子性操作、記憶體可見性和指令重排序。

是非審之於己,毀譽聽之於人,得失安之於數。

公眾號:Java面試真題解析

面試合集://gitee.com/mydb/interview