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

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

