在前面的文章《雙刃劍-理解多執行緒帶來的安全問題》中,我們提到了多執行緒情況下存在的執行緒安全問題。本文將以這個問題為背景,介紹如何通過使用synchronized
關鍵字解這一問題。當然,在青銅階段,我們仍不會過多地描述其背後的原理,重點還是先體驗並理解它的用法。
一、從場景中體驗synchronized
是誰擊敗了主宰
在峽谷中,擊敗主宰可以獲得高額的經濟收益。因此,在條件允許的情況下,大家都會爭相擊敗主宰。於是,哪吒和敵方的蘭陵王開始爭奪主宰。按規矩,誰是擊敗主宰的最後一擊,誰便是勝利的一方。
假設主宰的初始血量是100,我們通過程式碼來模擬下:
public class Master {
//主宰的初始血量
private int blood = 100;
//每次被擊打後血量減5
public int decreaseBlood() {
blood = blood - 5;
return blood;
}
//通過血量判斷主宰是否還存活
public boolean isAlive() {
return blood > 0;
}
}
我們定義了哪吒和蘭陵王兩個執行緒,讓他們同時攻擊主宰:
public static void main(String[] args) {
final Master master = new Master();
Thread neZhaAttachThread = new Thread() {
public void run() {
while (master.isAlive()) {
try {
int remainBlood = master.decreaseBlood();
if (remainBlood == 0) {
System.out.println("哪吒擊敗了主宰!");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread lanLingWangThread = new Thread() {
public void run() {
while (master.isAlive()) {
try {
int remainBlood = master.decreaseBlood();
if (remainBlood == 0) {
System.out.println("蘭陵王擊敗了主宰!");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
neZhaAttachThread.start();
lanLingWangThread.start();
}
下面是執行的結果:
蘭陵王擊敗了主宰!
哪吒擊敗了主宰!
Process finished with exit code 0
兩人竟然都獲得了主宰!很顯然,我們不可能接受這樣的結果。然而,細看程式碼,你會發現這個神奇的結果其實一點也不意外,兩個執行緒在對blood
做併發減法時出了錯誤,因為程式碼中壓根沒有必要的併發安全控制。
當然,解決辦法也比較簡單,在decreaseBlood
方法上新增synchronized
關鍵字即可:
public synchronized int decreaseBlood() {
blood = blood - 5;
return blood;
}
為什麼加上synchronized
關鍵字就可以了呢?這就需要往下看了解Java中的鎖和同步了。
二、認識synchronized
1. 理解Java物件中的鎖
在理解synchronized
之前,我們先簡單理解下鎖的概念。在Java中,每個物件都會有一把鎖。當多個執行緒都需要訪問物件時,那麼就需要通過獲得鎖來獲得許可,只有獲得鎖的執行緒才能訪問物件,並且其他執行緒將進入等待狀態,等待其他執行緒釋放鎖。如下圖所示:
2. 理解synchronized關鍵字
根據Sun官文文件的描述,synchronized
關鍵字提供了一種預防執行緒干擾和記憶體一致性錯誤的簡單策略,即如果一個物件對多個執行緒可見,那麼該物件變數(final
修飾的除外)的讀寫都需要通過synchronized
來完成。
你可能已經注意到其中的兩個關鍵名詞:
- 執行緒干擾(Thread Interference):不同執行緒中執行但作用於相同資料的兩個操作交錯時,就會發生干擾。這意味著這兩個操作由多個步驟組成,並且步驟順序重疊;
- 記憶體一致性錯誤(Memory Consistency Errors):當不同的執行緒對應為相同資料的檢視不一致時,將發生記憶體一致性錯誤。記憶體一致性錯誤的原因很複雜,幸運的是,我們不需要詳細瞭解這些原因,所需要的只是避免它們的策略。
從競態的角度講,執行緒干擾對應的是Read-modify-write,而記憶體一致性錯誤對應的則是Check-then-act。
結合鎖和synchronized的概念可以理解為,鎖是多執行緒安全的基礎機制,而synchronized是鎖機制的一種實現。
三、synchronized的四種用法
1. 在例項方法中使用synchronized
public synchronized int decreaseBlood() {
blood = blood - 5;
return blood;
}
注意這段程式碼中的synchronized
欄位,它表示當前方法每次能且僅能有一個執行緒訪問。另外,由於當前方法是例項方法,所以如果該物件存在多個例項的話,不同的例項可以由不同的執行緒訪問,它們之間並無協作關係。
然而,你可能已經想到了,如果當前執行緒中有兩個synchronized
方法,不同的執行緒是否可以訪問不同的synchronized
方法呢?
答案是:不能。
這是因為每個例項內的同步方法,能且僅能有一個執行緒訪問。
2. 在靜態方法中使用synchronized
public static synchronized int decreaseBlood() {
blood = blood - 5;
return blood;
}
與例項方法的synchronized
不同,靜態方法的synchronized
是基於當前方法所屬的類,即Master.class
,而每個類在虛擬機器上有且只有一個類物件。所以,對於同一類而言,每次有且只能有一個執行緒能訪問靜態synchronized
方法。
當類中包含有多個靜態的synchronized
方法時,每次也仍然有且只能有一個執行緒可以訪問其中的方法。
注意: 從synchronized
在例項方法和靜態方法中的應用可以看出,synchronized
方法是否能允許其他執行緒的進入,取決於synchronized
的引數。每個不同的引數,在同一時刻都只允許一個執行緒訪問。基於這樣的認知,下面的兩種用法就很容易理解了。
3. 在例項方法的程式碼塊中使用synchronized
public int decreaseBlood() {
synchronized(this) {
blood = blood - 5;
return blood;
}
}
在某些情況下,你不需要在整個方法層面使用synchronized
,畢竟這樣的方式粒度較大,容易產生阻塞。此時,在程式碼塊中使用synchronized
就是非常不錯的選擇,如上面程式碼所示。
剛才已經提到,synchronized
的併發限制取決於其引數,在上面這段程式碼中的引數是this
,即當前類的例項物件。而在前面的public synchronized int decreaseBlood()
中,synchronized
的引數也是當前類的例項物件。因此,下面這兩段程式碼是等同的:
public int decreaseBlood() {
synchronized(this) {
blood = blood - 5;
return blood;
}
}
public synchronized int decreaseBlood() {
blood = blood - 5;
return blood;
}
4. 在靜態方法的程式碼塊中使用synchronized
同理,下面這兩個方法的效果也是等同的。
public static int decreaseBlood() {
synchronized(Master.class) {
blood = blood - 5;
return blood;
}
}
public static synchronized int decreaseBlood() {
blood = blood - 5;
return blood;
}
四、synchronized小結
前面,我們已經介紹了synchronized
的幾種常見用法,不必死記硬背,你只要記住synchronized
可以接受任何非null物件作為引數,而每個引數在同一時刻能且只能允許一個執行緒訪問即可。此外,還有一些具有實際指導意義的Tips你可以注意下:
- Java中的
synchronized
關鍵字用於解決多執行緒訪問共享資源時的同步,以解決執行緒干擾和記憶體一致性問題; - 你可以通過 程式碼塊(code block) 或者 方法(method) 來使用
synchronized
關鍵字; synchronized
的原理基於物件中的鎖,當執行緒需要進入synchronized
修飾的方法或程式碼塊時,它需要先獲得鎖並在執行結束後釋放它;- 當執行緒進入非靜態(non-static)同步方法時,它獲得的是物件例項(Object level)的鎖。而執行緒進入靜態同步方法時,它所獲得的是類例項(Class level)的鎖,兩者沒有必然關係;
- 如果
synchronized
中使用的物件是null,將會丟擲NullPointerException
錯誤; synchronized
對方法的效能有一定影響,因為執行緒要等待獲取鎖;- 使用
synchronized
時儘量使用程式碼塊,而不是整個方法,以免阻塞整個方法; - 儘量不要使用String型別和原始型別作為引數。這是因為,JVM在處理字串、原始型別時會對它們進行優化。比如,你原本是想對不同的字串進行加鎖,然而JVM認為它們是同一個,很顯然這不是你想要的結果。
關於synchronized
的可見性、指令排序等底層原理,我們會在後面的階段中詳細介紹。
以上就是文字的全部內容,恭喜你又上了一顆星!
夫子的試煉
- 手寫程式碼體驗
synchronized
的不同用法。
參考資料
- https://docs.oracle.com/javase/tutorial/essential/concurrency/syncmeth.html
- https://javagoal.com/synchronization-in-java/
關於作者
關注公眾號【庸人技術笑談】,獲取及時文章更新。記錄平凡人的技術故事,分享有品質(儘量)的技術文章,偶爾也聊聊生活和理想。不販賣焦慮,不兜售課程。