在前面的文章《雙刃劍-理解多線程帶來的安全問題》中,我們提到了多線程情況下存在的線程安全問題。本文將以這個問題為背景,介紹如何通過使用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/
關於作者
關注公眾號【庸人技術笑談】,獲取及時文章更新。記錄平凡人的技術故事,分享有品質(盡量)的技術文章,偶爾也聊聊生活和理想。不販賣焦慮,不兜售課程。
如果本文對你有幫助,歡迎點贊、關注。