面試突擊:線程安全問題是如何產(chǎn)生的?
線程安全是指某個方法或某段代碼,在多線程中能夠正確的執(zhí)行,不會出現(xiàn)數(shù)據(jù)不一致或數(shù)據(jù)污染的情況,我們把這樣的程序稱之為線程安全的,反之則為非線程安全的。
舉個例子來說,比如銀行只有張三一個人來辦理業(yè)務(wù),這種情況在程序中就叫做單線程執(zhí)行,而單線程執(zhí)行是沒有問題的,也就是線程安全的。但突然有一天來了很多人同時辦理業(yè)務(wù),這種情況就叫做多線程執(zhí)行。如果所有人都一起爭搶著辦理業(yè)務(wù),很有可能會導(dǎo)致錯誤,而這種錯誤就叫非線程安全。如果每個人都能有序排隊(duì)辦理業(yè)務(wù),且工作人員不會操作失誤,我們就把這種情況稱之為線程安全的。
問題演示
接下來我們演示一下,程序中非線程安全的示例。我們先創(chuàng)建一個變量 number 等于 0,然后開啟線程 1 執(zhí)行 100 萬次 number++ 操作,同時再開啟線程 2 執(zhí)行 100 萬次 number-- 操作,等待線程 1 和線程 2 都執(zhí)行完,正確的結(jié)果 number 應(yīng)該還是 0,但不加干預(yù)的多線程執(zhí)行結(jié)果卻與預(yù)期的正確結(jié)果不一致,如下代碼所示:
public class ThreadSafeTest {
// 全局變量
private static int number = 0;
// 循環(huán)次數(shù)(100W)
private static final int COUNT = 1_000_000;
public static void main(String[] args) throws InterruptedException {
// 線程1:執(zhí)行 100W 次 number+1 操作
Thread t1 = new Thread(() -> {
for (int i = 0; i < COUNT; i++) {
number++;
}
});
t1.start();
// 線程2:執(zhí)行 100W 次 number-1 操作
Thread t2 = new Thread(() -> {
for (int i = 0; i < COUNT; i++) {
number--;
}
});
t2.start();
// 等待線程 1 和線程 2,執(zhí)行完,打印 number 最終的結(jié)果
t1.join();
t2.join();
System.out.println("number 最終結(jié)果:" + number);
}
}
以上程序的執(zhí)行結(jié)果如下圖所示:
從上述執(zhí)行結(jié)果可以看出,number 變量最終的結(jié)果并不是 0,和我們預(yù)期的正確結(jié)果是不相符的,這就是多線程中的線程安全問題。
產(chǎn)生原因
導(dǎo)致線程安全問題的因素有以下 5 個:
- 多線程搶占式執(zhí)行。
- 多線程同時修改同一個變量。
- 非原子性操作。
- 內(nèi)存可見性。
- 指令重排序。
接下來我們分別來看這 5 個因素的具體含義。
1.多線程搶占式執(zhí)行
導(dǎo)致線程安全問題的第一大因素就是多線程搶占式執(zhí)行,想象一下,如果是單線程執(zhí)行,或者是多線程有序執(zhí)行,那就不會出現(xiàn)混亂的情況了,不出現(xiàn)混亂的情況,自然就不會出現(xiàn)非線程安全的問題了。
2.多線程同時修改同一個變量
如果是多線程同時修改不同的變量(每個線程只修改自己的變量),也是不會出現(xiàn)非線程安全的問題了,比如以下代碼,線程 1 修改 number1 變量,而線程 2 修改 number2 變量,最終兩個線程執(zhí)行完之后的結(jié)果如下:
public class ThreadSafe {
// 全局變量
private static int number = 0;
// 循環(huán)次數(shù)(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:執(zhí)行 100W 次 number+1 操作
Thread t1 = new Thread(() -> {
for (int i = 0; i < COUNT; i++) {
number1++;
}
});
t1.start();
// 線程2:執(zhí)行 100W 次 number-1 操作
Thread t2 = new Thread(() -> {
for (int i = 0; i < COUNT; i++) {
number2--;
}
});
t2.start();
// 等待線程 1 和線程 2,執(zhí)行完,打印 number 最終的結(jié)果
t1.join();
t2.join();
number = number1 + number2;
System.out.println("number=number1+number2 最終結(jié)果:" + number);
}
}
以上程序的執(zhí)行結(jié)果如下圖所示:
從上述結(jié)果可以看出,多線程只要不是同時修改同一個變量,也不會出現(xiàn)線程安全問題。
3.非原子性操作
原子性操作是指操作不能再被分隔就叫原子性操作。比如人類吸氣或者是呼氣這個動作,它是一瞬間一次性完成的,你不可能先吸一半(氣),停下來玩會手機(jī),再吸一半(氣),這種操作就是原子性操作。而非原子性操作是我現(xiàn)在要去睡覺,但睡覺之前要先上床,再拉被子,再躺下、再入睡等一系列的操作綜合在一起組成的,這就是非原子性操作。非原子性操作是有可以被分隔和打斷的,比如要上床之前,發(fā)現(xiàn)時間還在,先刷個劇、刷會手機(jī)、再玩會游戲,甚至是再吃點(diǎn)小燒烤等等,所以非原子性操作有很多不確定性,而這些不確定性就會造成線程安全問題問題。像 i++ 和 i-- 這種操作就是非原子的,它在 +1 或 -1 之前,先要查詢原變量的值,并不是一次性完成的,所以就會導(dǎo)致線程安全問題。比如以下操作流程:
操作步驟 | 線程1 | 線程2 |
T1 | 讀取到 number=1,準(zhǔn)備執(zhí)行 number-1 的操作,但還沒有執(zhí)行,時間片就用完了。 | |
T2 | 讀取到 number=1,并且執(zhí)行 number+1 操作,將 number 修改成了 2。 | |
T3 | 恢復(fù)執(zhí)行,因?yàn)橹耙呀?jīng)讀取了 number=1,所以直接執(zhí)行 -1 操作,將 number 變成了 0。 |
以上就是一個經(jīng)典的錯誤,number 原本等于 1,線程 1 進(jìn)行 -1 操作,而線程 2 進(jìn)行加 1,最終的結(jié)果 number 應(yīng)該還等于 1 才對,但通過上面的執(zhí)行,number 最終被修改成了 0,這就是非原子性導(dǎo)致的問題。
4.內(nèi)存可見性問題
在 Java 編程中內(nèi)存分為兩種類型:工作內(nèi)存和主內(nèi)存,而工作內(nèi)存使用的是 CPU 寄存器實(shí)現(xiàn)的,而主內(nèi)存是指電腦中的內(nèi)存,我們知道 CPU 寄存器的操作速度是遠(yuǎn)大于內(nèi)存的操作速度的,它們的性能差異如下圖所示:
那這和線程安全有什么關(guān)系呢?這是因?yàn)樵?Java 語言中,為了提高程序的執(zhí)行速度,所以在操作變量時,會將變量從主內(nèi)存中復(fù)制一份到工作內(nèi)存,而主內(nèi)存是所有線程共用的,工作內(nèi)存是每個線程私有的,這就會導(dǎo)致一個線程已經(jīng)把主內(nèi)存中的公共變量修改了,而另一個線程不知道,依舊使用自己工作內(nèi)存中的變量,這樣就導(dǎo)致了問題的產(chǎn)生,也就導(dǎo)致了線程安全問題。
5.指令重排序
指令重排序是指 Java 程序?yàn)榱颂岣叱绦虻膱?zhí)行速度,所以會對一下操作進(jìn)行合并和優(yōu)化的操作。比如說,張三要去圖書館還書,舍友又讓張三幫忙借書,那么程序的執(zhí)行思維是,張三先去圖書館把自己的書還了,再去一趟圖書館幫舍友把書借回來。而指令重排序之后,把兩次執(zhí)行合并了,張三帶著自己的書去圖書館把書先還了,再幫舍友把書借出來,整個流程就執(zhí)行完了,這是正常情況下的指令重排序的好處。但是指令重排序也有“副作用”,而“副作用”是發(fā)生在多線程執(zhí)行中的,還是以張三借書和幫舍友還書為例,如果張三是一件事做完再做另一件事是沒有問題的(也就是單線程執(zhí)行是沒有問題的),但如果是多線程執(zhí)行,就是兩件事由多個人混合著做,比如張三在圖書館遇到了自己的多個同學(xué),于是就把任務(wù)分派給多個人一起執(zhí)行,有人借了幾本書、有人借了還了幾本書、有人再借了幾本書、有人再借了還了幾本書,執(zhí)行的很混亂沒有明確的目標(biāo),到最后悲劇就發(fā)生了,這就是在指令重排序帶來的線程安全問題。
總結(jié)
線程安全是指某個方法或某段代碼,在多線程中能夠正確的執(zhí)行,不會出現(xiàn)數(shù)據(jù)不一致或數(shù)據(jù)污染的情況,反之則為線程安全問題。簡單來說所謂的非線程安全是指:在多線程中,程序的執(zhí)行結(jié)果和預(yù)期的正確結(jié)果不一致的問題。而造成線程安全問題的因素有 5 個:多線程搶占式執(zhí)行、多線程同時修改同一個變量、非原子性操作、內(nèi)存可見性和指令重排序。