Java并發(fā)編程:線程安全
1. 什么是線程安全?
《Java 并發(fā)編程實(shí)戰(zhàn)》的作者 Brian Goetz 對(duì)線程安全的理解是:當(dāng)多個(gè)線程訪問(wèn)一個(gè)對(duì)象時(shí),如果不需要考慮這些線程在運(yùn)行時(shí)環(huán)境中的調(diào)度和交替執(zhí)行,也不需要額外的同步,調(diào)用這個(gè)對(duì)象的行為都能獲得正確的結(jié)果,那么這個(gè)對(duì)象就是線程安全的。
通俗地說(shuō),無(wú)論有多少線程訪問(wèn)業(yè)務(wù)中的一個(gè)對(duì)象或方法,在編寫這段業(yè)務(wù)邏輯時(shí),無(wú)需做任何額外處理(即可以像單線程程序一樣編寫),程序也能正常運(yùn)行(不會(huì)因多線程而失敗),這樣的代碼就可以稱為線程安全的。
2. 什么是線程不安全?
當(dāng)多個(gè)線程同時(shí)訪問(wèn)一個(gè)對(duì)象時(shí),如果某個(gè)線程正在更新對(duì)象的值,而另一個(gè)線程同時(shí)讀取該對(duì)象的值,就可能導(dǎo)致獲取到錯(cuò)誤的值。這種情況下,我們需要采取額外措施(例如使用synchronized關(guān)鍵字同步這部分代碼的執(zhí)行)來(lái)確保結(jié)果的正確性。
3. 為什么不是所有程序都設(shè)計(jì)成線程安全的?
主要是出于程序性能、設(shè)計(jì)復(fù)雜度成本等方面的考量。
4. 線程安全問(wèn)題的分類
4.1 運(yùn)行結(jié)果錯(cuò)誤
首先來(lái)看多線程同時(shí)操作一個(gè)變量如何導(dǎo)致運(yùn)行結(jié)果錯(cuò)誤。
假設(shè)用兩個(gè)線程對(duì)count變量進(jìn)行計(jì)數(shù),每個(gè)線程各計(jì) 10000 次:
public class ResultError {
static int count;
public static void main(String[] args) throws InterruptedException {
Runnable runnable = () -> {
for (int i = 0; i < 10000; i++) {
count++;
}
};
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(count);
}
}
輸出:
圖片
理論上結(jié)果應(yīng)為 20000,但實(shí)際輸出遠(yuǎn)小于理論值,且每次結(jié)果不同。為什么會(huì)這樣?
這是因?yàn)槎嗑€程下,CPU 的調(diào)度是以時(shí)間片為單位分配的,每個(gè)線程獲得一定時(shí)間片后,若時(shí)間片耗盡會(huì)被掛起并讓出 CPU 資源給其他線程,這可能導(dǎo)致線程安全問(wèn)題。例如,i++看似一行代碼,實(shí)際并非原子操作,其執(zhí)行步驟主要分為三步,且每一步操作之間可能被中斷:
- 讀取當(dāng)前值;
- 遞增;
- 保存結(jié)果。
圖片
假設(shè)線程 1 先讀取count=1,隨后執(zhí)行count + 1操作,但此時(shí)結(jié)果尚未保存,線程 1 被切換。CPU 開(kāi)始執(zhí)行線程 2,其操作與線程 1 相同。但此時(shí)線程 2 讀取的count值是多少?由于線程 1 的+1操作未保存結(jié)果,線程 2 讀取的仍然是count=1。
假設(shè)線程 2 執(zhí)行count + 1后保存結(jié)果為 2,隨后線程 1 恢復(fù)執(zhí)行,保存其計(jì)算結(jié)果為 2。雖然兩個(gè)線程各執(zhí)行了一次+1,但最終count結(jié)果為 2 而非預(yù)期的 3。這就是典型的線程安全問(wèn)題,此時(shí)count變量被稱為共享變量或共享數(shù)據(jù)。
如何解決?
解決此類問(wèn)題需要一種機(jī)制:當(dāng)多個(gè)線程操作共享變量時(shí),確保同一時(shí)刻僅有一個(gè)線程能操作該變量,其他線程必須等待當(dāng)前線程處理完成。這種方法使用互斥鎖(Mutex Lock)實(shí)現(xiàn)互斥訪問(wèn)——當(dāng)共享數(shù)據(jù)被當(dāng)前線程加鎖時(shí),其他線程只能等待鎖釋放。
Java 中,用synchronized關(guān)鍵字修飾的方法或代碼塊可以保證同一時(shí)刻僅有一個(gè)線程執(zhí)行。代碼如下:
public class ResultErrorResolution {
staticint count;
public static void main(String[] args) throws InterruptedException {
Runnable runnable = () -> {
synchronized (ResultErrorResolution.class) {
for (int i = 0; i < 10000; i++) {
count++;
}
}
};
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(count);
}
}
輸出:
20000
輸出結(jié)果與預(yù)期一致??。
關(guān)于synchronized關(guān)鍵字,后續(xù)章節(jié)會(huì)詳細(xì)講解。目前只需知道它能保證同一時(shí)刻最多一個(gè)線程執(zhí)行該代碼段(需持有對(duì)應(yīng)的鎖,本例中為ResultErrorResolution.class),從而實(shí)現(xiàn)并發(fā)安全。
4.2 線程活躍性問(wèn)題
第二類線程安全問(wèn)題統(tǒng)稱為活躍性問(wèn)題。活躍性問(wèn)題指程序無(wú)法獲得運(yùn)行的最終結(jié)果。相比前文的錯(cuò)誤,活躍性問(wèn)題的后果可能更嚴(yán)重,例如死鎖會(huì)導(dǎo)致程序完全卡死。
典型的活躍性問(wèn)題包括死鎖(Deadlock)、活鎖(Livelock)和饑餓(Starvation)。由于內(nèi)容較多,后續(xù)會(huì)單獨(dú)寫篇文章介紹。
4.3 對(duì)象初始化時(shí)的安全問(wèn)題
最后是對(duì)象初始化過(guò)程中引發(fā)的線程安全問(wèn)題。創(chuàng)建對(duì)象以供其他類或?qū)ο笫褂檬浅R?jiàn)操作,但若時(shí)機(jī)或錯(cuò)誤可能導(dǎo)致線程安全問(wèn)題。
看一個(gè)例子:
public class InitError {
private Map<Long, String> students;
public InitError() {
new Thread(() -> {
students = new HashMap<>();
students.put(1L, "Tom");
students.put(2L, "Bob");
students.put(3L, "Victor");
}).start();
}
public Map<Long, String> getStudents() {
return students;
}
public static void main(String[] args) throws InterruptedException {
InitError initError = new InitError();
System.out.println(initError.getStudents().get(1L));
}
}
此例中,成員變量students在構(gòu)造函數(shù)的子線程中初始化。但主線程在初始化InitError后未等待子線程完成,直接嘗試獲取數(shù)據(jù),導(dǎo)致問(wèn)題:
public static void main(String[] args) throws InterruptedException {
InitError initError = new InitError();
System.out.println(initError.getStudents().get(1L));
}
運(yùn)行結(jié)果:
Exception in thread "main" java.lang.NullPointerException
at concurrency.chapter10.InitError.main(InitError.java:25)
原因:
students在構(gòu)造函數(shù)的新線程中初始化,而主線程未等待該線程完成就直接調(diào)用getStudents(),此時(shí)students可能尚未初始化(返回null),導(dǎo)致空指針異常。
5. 哪些場(chǎng)景需特別注意線程安全問(wèn)題?
5.1 訪問(wèn)共享變量或資源
當(dāng)訪問(wèn)靜態(tài)變量、共享緩存等共享資源時(shí),若多線程同時(shí)操作(如count++),需確保原子性。例如以下“檢查后執(zhí)行”操作可能被中斷:
if (count == 10) {
count = count * 10;
}
多個(gè)線程可能同時(shí)滿足count == 10,導(dǎo)致多次執(zhí)行count = count * 10,需通過(guò)加鎖保證原子性。
5.2 數(shù)據(jù)間存在綁定關(guān)系
當(dāng)不同數(shù)據(jù)成組出現(xiàn)且需保持對(duì)應(yīng)關(guān)系時(shí)(如 IP 和端口號(hào)),若修改未綁定為一個(gè)原子操作,可能導(dǎo)致信息不一致。例如僅修改 IP 而未同步修改端口號(hào),接收方可能獲取錯(cuò)誤的綁定結(jié)果。
5.3 依賴的類未聲明線程安全
若使用的類未聲明自身是線程安全的(如ArrayList),在多線程并發(fā)操作時(shí)可能引發(fā)線程安全問(wèn)題。責(zé)任不在該類本身,因其未做任何線程安全保證(源碼注釋中通常會(huì)說(shuō)明)。