Java SE多線程之線程安全、synchronized、volatile與wait/notify全解
一、線程不安全
1.1 線程不安全的直觀現(xiàn)象
多線程的優(yōu)勢是提升效率,但多個線程同時操作共享數(shù)據(jù)時,極易出現(xiàn)數(shù)據(jù)錯亂的問題,這就是線程不安全。
計數(shù)器案例:
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
// 線程1:自增5萬次
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) count++;
});
// 線程2:自增5萬次
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) count++;
});
t1.start();
t2.start();
t1.join();
t2.join();
// 預(yù)期10萬,實際永遠小于10萬
System.out.println("count: " + count);
}運行結(jié)果永遠小于預(yù)期的10萬,這就是典型的線程不安全問題。
1.2 線程不安全的原因
操作系統(tǒng)對線程的調(diào)度是隨機的,這也是線程不安全的罪魁禍?zhǔn)住?/p>
(1)原子性缺失:操作被拆分打斷
原子性指一段操作不可分割,要么全部執(zhí)行,要么全部不執(zhí)行。
count++看似一行代碼,實則對應(yīng)3個CPU的指令:
- load:把內(nèi)存中的值加載到CPU寄存器中
- add:把寄存器的內(nèi)容+1
- save:把寄存器中的內(nèi)容保存回內(nèi)存中
執(zhí)行這三個指令時不一定能一次執(zhí)行完,很有可能1和2執(zhí)行完,調(diào)度走;過了很久,再調(diào)度回來執(zhí)行3。
如果兩個線程對于同一個count進行操作,極大概率t1還沒來得及保存新結(jié)果(1),t2就已經(jīng)加載并修改,保存了數(shù)據(jù)(0 -> 1),此時再調(diào)度t1,t1接下來該保存新數(shù)據(jù)(1),t1的修改覆蓋了t2的修改,對于這兩次自增,count的結(jié)果是1。這樣多次覆蓋,就會導(dǎo)致count最終的結(jié)果小于100000
(2)可見性缺失:數(shù)據(jù)更新互相看不見
Java內(nèi)存模型(JMM)規(guī)定線程有獨立工作內(nèi)存,共享數(shù)據(jù)存主內(nèi)存。
- 線程修改共享變量時,先改工作內(nèi)存副本,再同步到主內(nèi)存。
- 線程A修改了變量,但未及時同步到主內(nèi)存,線程B讀取的還是舊值,導(dǎo)致邏輯錯誤。
(3)有序性缺失:指令被亂序優(yōu)化
為提升效率,編譯器和CPU會對指令重排序(不影響單線程結(jié)果),但多線程下會打亂邏輯:
- 典型場景:雙重檢查鎖單例模式中,
instance = new Singleton()可能被重排序,導(dǎo)致線程獲取未初始化的對象,引發(fā)空指針。
二、synchronized
synchronized是Java內(nèi)置的互斥鎖,能同時保證原子性、可見性、有序性,是解決線程安全最常用的關(guān)鍵字。
- 進入 synchronized 修飾的代碼塊, 相當(dāng)于加鎖
- 退出 synchronized 修飾的代碼塊, 相當(dāng)于解鎖
加鎖操作不是把線程鎖死在CPU上,不讓這個線程被調(diào)度走,而是禁止其他線程重新加這個鎖,避免其他線程的操作,在當(dāng)前線程的執(zhí)行過程中插隊。
2.1 三大特性
(1)互斥性(原子性)
synchronized用的鎖是存在Java對象里的,可以粗略的理解為每個對象在內(nèi)存中存儲時,都有一塊內(nèi)存表示當(dāng)前鎖定的狀態(tài)(類似于廁所的有人/無人)
- 如果是無人狀態(tài),就可以使用,使用時設(shè)置為“有人”狀態(tài)
- 如果是有人狀態(tài),其他人無法使用,只能等待。
理解阻塞等待:
針對每一把鎖,操作系統(tǒng)內(nèi)部都維護了一個等待隊列,當(dāng)這個鎖被某個線程占有時,其他線程嘗試進行加鎖,就加不上了,就會阻塞等待,一直到之前的線程解鎖后,由操作系統(tǒng)喚醒一個新線程,再來獲取這個鎖。
- 上個一個線程解鎖后,下一個線程不是立即就能獲取,而是靠操作系統(tǒng)來“喚醒”,這也是操作系統(tǒng)線程調(diào)度的一部分工作
- 假設(shè)A B C三個線程,線程A先獲得鎖,然后B嘗試獲取,C再嘗試獲取。此時B和C都在阻塞隊列中排隊等待,但是當(dāng)A釋放鎖之后,雖然B比C先來,B不一定立即獲得鎖,而是重新與C競爭。
- 同一時刻,只有一個線程能獲取同一把鎖,執(zhí)行臨界區(qū)代碼。
- 其他線程嘗試獲取鎖時,會進入阻塞等待狀態(tài),直到鎖被釋放。
(2)可見性
- 線程進入
synchronized代碼塊時,清空工作內(nèi)存,從主內(nèi)存加載最新數(shù)據(jù)。 - 線程退出代碼塊時,強制將修改后的數(shù)據(jù)刷新回主內(nèi)存,其他線程能立即看到最新值。
(3)可重入性
理解“把自己鎖死”
一個線程沒有釋放鎖,又嘗試重新加鎖
例如第一次加鎖,成功上鎖;第二次加同一把鎖,鎖已經(jīng)被占用,就會阻塞等待。
按照之前的鎖的設(shè)定,第二次加鎖,阻塞等待,直到第一次的鎖釋放;而釋放第一個鎖也是由該線程完成,這樣就陷入了死循環(huán),把自己鎖死了。
這樣的鎖稱為“不可重入鎖”
Java的synchronized引入了可重入的概念,同一線程可重復(fù)獲取同一把鎖,不會自己鎖死自己。
底層通過線程持有者+計數(shù)器實現(xiàn):加鎖時計數(shù)器+1,解鎖時計數(shù)器-1,計數(shù)器為0時真正釋放鎖。
2.2 三種使用方式
(1)修飾代碼塊(鎖自定義對象)
// 鎖任意對象
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (lock) { // 加鎖
count++;
} // 解鎖
}
});
}
對于鎖來說任意對象都可以,一般情況下,我們會專門定義一個Object類給鎖使用。
(2)修飾實例方法(鎖當(dāng)前對象)
// 鎖當(dāng)前實例對象
public synchronized void increment() {
count++;
}
(3)修飾靜態(tài)方法(鎖類對象,全局唯一)
// 鎖當(dāng)前類的Class對象,所有實例共享一把鎖
public synchronized static void increment() {
count++;
}
2.3 修復(fù)計數(shù)器案例
給count++加synchronized鎖,保證原子性:
private static int count = 0;
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (lock) { count++; }
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (lock) { count++; }
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count: " + count); // 輸出10萬
}
2.4 死鎖
死鎖是指兩個或多個線程,各自拿著對方需要的鎖,又互相等待對方釋放鎖,誰都不放手、誰都走不了,程序永久阻塞卡死。
構(gòu)成死鎖的必要條件
- 鎖是互斥的。一個線程拿到鎖后,另一個線程想要拿鎖必須阻塞等待
- 鎖不可剝奪。線程1拿到鎖,線程2也想獲取這個鎖,必須阻塞等待,而不能直接搶占
- 請求和保持。一個線程拿到鎖A,不釋放鎖1的前提下,獲取鎖B
- 循環(huán)等待。 多個線程的等待過程構(gòu)成了循環(huán),例如A等B釋放,B也在等A釋放
如何避免死鎖
前兩個條件是鎖的基本特性,想要避免死鎖的出現(xiàn)就要破壞掉3或4.
- 統(tǒng)一鎖的獲取順序
所有線程都按固定順序拿鎖,例如約定從序號小的鎖開始獲取,如果鎖已經(jīng)被獲取,就要阻塞等待 - 放棄請求與保持
要么一次性把需要的鎖全部拿到,一把都不拿不到就不執(zhí)行。 - 設(shè)置超時
嘗試拿鎖等待一段時間,拿不到就放棄,不永久阻塞。 - 減少嵌套加鎖
2.5 Java標(biāo)準(zhǔn)庫中的線程安全類
Java標(biāo)準(zhǔn)庫中很多是線程不安全的,這些類可能會涉及多線程修改共享數(shù)據(jù),又沒有加鎖措施。
ArrayListLinkedListHashMapTreeMapHashSetTreeSetStringBuilder
線程安全類:
StringBuffer是線程安全的,正是因為其中的方法都有synchronized。

但是由于synchronized的限制,代碼中可能出現(xiàn)鎖的競爭導(dǎo)致阻塞,會使代碼的效率大打折扣
String:雖然沒有加鎖,但是不涉及修改,仍然是線程安全的
三、volatile
3.1 內(nèi)存可見性問題
以下面代碼為例:t2改變flag的值來影響t1的執(zhí)行,當(dāng)輸入1時,理論上t1內(nèi)while條件不成立,應(yīng)該跳出循環(huán),線程結(jié)束。然而實際上輸入1后t1線程還在繼續(xù)
public class demo18 {
private static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(flag==0){
}
System.out.println("t1 線程結(jié)束");
});
Thread t2 = new Thread(()->{
//修改flag
Scanner scan = new Scanner(System.in);
System.out.println("輸入flag的值:");
flag = scan.nextInt();
});
t1.start();
t2.start();
}
}很明顯,這也是由于線程安全導(dǎo)致的bug。一個線程在讀取,一個線程在修改,修改的值沒有被另一個線程讀取到,這就是“內(nèi)存可見性問題”。
這涉及到編譯器優(yōu)化:
我們寫的代碼,都會通過javac 把.java文件編譯成.class字節(jié)碼文件,由jvm執(zhí)行。編譯器可以保持代碼邏輯不變的情況下,對代碼進行優(yōu)化,提升效率。
而在多線程場景,編譯器很有可能判斷錯誤,導(dǎo)致優(yōu)化前后的邏輯不完全相同。
分析編譯器如何誤判:
t1中是空循環(huán),對于CPU主要就是兩個操作:load (加載flag的值),cmp(條件跳轉(zhuǎn))
- 對于
cmp:cpu的寄存器操作,速度快很多 - 對于
load:需要到內(nèi)存中訪問,時間可能是cmp的幾千倍。
每輪循環(huán)執(zhí)行速度非???,短時間內(nèi)就可以執(zhí)行很多次,每次讀取flag的值都是不變的。經(jīng)過多次循環(huán),JVM認為這個讀取操作可以被優(yōu)化(正是因為load在循環(huán)中時間消耗是cmp的幾千倍),因此把讀內(nèi)存操作改成了讀寄存器操作。
而用戶輸入值可能要經(jīng)過好幾秒,與上述的操作時間完全不是一個量級。等到用戶真的輸入flag的值,t1已經(jīng)感知不到了(編譯器優(yōu)化使得t1的讀操作不是真正的讀內(nèi)存)
如果在循環(huán)中加入一些語句
while(flag==0){
sleep(1);
}
加入sleep后,使循環(huán)的速度大大大大幅度下降,此時load時間占比對于整個循環(huán)小了很多,JVM認為這個優(yōu)化沒有必要,每次都是讀內(nèi)存操作。因此t2的修改可以被t1感知到,結(jié)果正確。
3.2 volatile的作用
volatile是輕量級并發(fā)關(guān)鍵字,不保證原子性,僅保證可見性和禁止指令重排序,適合解決“一個線程寫、多個線程讀”的場景。
(1)保證可見性:數(shù)據(jù)更新立即同步
- 寫
volatile變量:修改后立即刷新到主內(nèi)存。 - 讀
volatile變量:強制從主內(nèi)存讀取最新值,不讀工作內(nèi)存緩存。
解決線程感知不到變量更新的問題:
public class demo18 {
private volatile static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(flag==0){
}
System.out.println("t1 線程結(jié)束");
});
Thread t2 = new Thread(()->{
//修改flag
Scanner scan = new Scanner(System.in);
System.out.println("輸入flag的值:");
flag = scan.nextInt();//輸入后t1就可以感知到,跳出循環(huán),線程結(jié)束
});
t1.start();
t2.start();
}
}(2)禁止指令重排序:避免邏輯錯亂
volatile變量前后會加內(nèi)存屏障,禁止編譯器和CPU對其前后指令重排序。- 應(yīng)用:雙重檢查鎖(DCL)單例模式,防止指令重排序?qū)е碌目罩羔槨?/li>
volatile適合無復(fù)合操作(如count++)、僅需可見性/有序性的場景;復(fù)合操作必須用synchronized或AtomicInteger。
四、wait 等待 / notify 通知
多線程不僅要“互斥”,還要“協(xié)作”——比如生產(chǎn)者生產(chǎn)完數(shù)據(jù),通知消費者消費;消費者無數(shù)據(jù)時等待。wait()、notify()、notifyAll()是實現(xiàn)線程等待-喚醒的核心方法,定義在Object類中。
4.1 方法詳解
(1)wait():讓線程等待并釋放鎖
- 作用:當(dāng)前線程進入阻塞等待狀態(tài),釋放持有的鎖,允許其他線程獲取鎖執(zhí)行任務(wù)。
- 重載:
wait()(無限等待)、wait(long timeout)(超時等待,毫秒)。 - 必須在
synchronized代碼塊/方法中調(diào)用,否則拋IllegalMonitorStateException。
(2)notify():隨機喚醒一個等待線程
- 作用:隨機喚醒一個在當(dāng)前對象鎖上等待的線程。
- 喚醒后不立即釋放鎖,需當(dāng)前線程退出
synchronized代碼塊后,被喚醒線程才能競爭鎖。
(3)notifyAll():喚醒所有等待線程
- 作用:喚醒所有在當(dāng)前對象鎖上等待的線程。
- 所有線程被喚醒后競爭同一把鎖,同一時刻只有一個線程能執(zhí)行。
注意:
- 調(diào)用wait(),notify()以及這兩個方法所在的synchronized代碼塊內(nèi)必須是同一對象才能生效
- 要確保先wait 再notify,才會有作用。如果先notify再wait,不會對notify所在的線程有影響,但是沒啥用
4.2 wait()與sleep()的區(qū)別
| 特性 | wait() | sleep() |
|---|---|---|
| 所屬類 | Object類 | Thread類 |
| 鎖行為 | 釋放鎖 | 不釋放鎖 |
| 喚醒方式 | 需notify()/notifyAll()喚醒 | 超時自動喚醒 |
| 使用場景 | 線程協(xié)作(等待-喚醒) | 線程休眠(暫停執(zhí)行) |
如果sleep()在synchronized代碼塊內(nèi),就會出現(xiàn)“抱著鎖睡”的情況,休眠期間其他線程也不能拿到這把鎖。
到此這篇關(guān)于Java SE多線程之線程安全、synchronized、volatile與wait/notify全解的文章就介紹到這了,更多相關(guān)Java SE 多線程volatile與wait/notify內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- Java多線程之Semaphore實現(xiàn)信號燈
- java多線程CountDownLatch與線程池ThreadPoolExecutor/ExecutorService案例
- 利用Java多線程技術(shù)導(dǎo)入數(shù)據(jù)到Elasticsearch的方法步驟
- JAVA 多線程之信號量(Semaphore)實例詳解
- 理解java多線程中ExecutorService使用
- java多線程并發(fā)executorservice(任務(wù)調(diào)度)類
- JavaEE中volatile、wait和notify詳解
- Java使用wait/notify實現(xiàn)線程間通信上篇
- Java多線程wait()和notify()方法詳細圖解
- Java使用wait和notify實現(xiàn)線程之間的通信
相關(guān)文章
Java設(shè)計模式之中介者模式的實現(xiàn)方式
Java中介者模式是一種行為型設(shè)計模式,它通過一個中介者對象來協(xié)調(diào)多個對象之間的交互,降低對象之間的耦合度,提高系統(tǒng)的可維護性和可擴展性。本文將介紹該設(shè)計模式的原理、使用場景和實現(xiàn)方法2023-04-04
PageHelper在springboot+mybatis框架中的使用步驟及原理解析
這篇文章主要介紹了PageHelper在springboot+mybatis框架中的使用步驟及原理解析,本文通過實例代碼給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2023-03-03
SpringBoot3.3.X整合Mybatis-Plus的實現(xiàn)示例
本文介紹了在Spring Boot 3.3.2中整合MyBatis-Plus 3.5.7,文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2025-03-03

