面試突擊:線程安全問題是如何產生的?
線程安全是指某個方法或某段代碼,在多線程中能夠正確的執行,不會出現數據不一致或數據污染的情況,我們把這樣的程序稱之為線程安全的,反之則為非線程安全的。
舉個例子來說,比如銀行只有張三一個人來辦理業務,這種情況在程序中就叫做單線程執行,而單線程執行是沒有問題的,也就是線程安全的。但突然有一天來了很多人同時辦理業務,這種情況就叫做多線程執行。如果所有人都一起爭搶著辦理業務,很有可能會導致錯誤,而這種錯誤就叫非線程安全。如果每個人都能有序排隊辦理業務,且工作人員不會操作失誤,我們就把這種情況稱之為線程安全的。
問題演示
接下來我們演示一下,程序中非線程安全的示例。我們先創建一個變量 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);
}
}
以上程序的執行結果如下圖所示:
從上述執行結果可以看出,number 變量最終的結果并不是 0,和我們預期的正確結果是不相符的,這就是多線程中的線程安全問題。
產生原因
導致線程安全問題的因素有以下 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);
}
}
以上程序的執行結果如下圖所示:
從上述結果可以看出,多線程只要不是同時修改同一個變量,也不會出現線程安全問題。
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 個:多線程搶占式執行、多線程同時修改同一個變量、非原子性操作、內存可見性和指令重排序。