在前面的文章《雙刃劍-理解多執行緒帶來的安全問題》中,我們提到了多執行緒情況下存在的執行緒安全問題。本文將以這個問題為背景,介紹如何通過使用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你可以注意下:

  1. Java中的synchronized關鍵字用於解決多執行緒訪問共享資源時的同步,以解決執行緒干擾記憶體一致性問題;
  2. 你可以通過 程式碼塊(code block) 或者 方法(method) 來使用synchronized關鍵字;
  3. synchronized的原理基於物件中的鎖,當執行緒需要進入synchronized修飾的方法或程式碼塊時,它需要先獲得鎖並在執行結束後釋放它;
  4. 當執行緒進入非靜態(non-static)同步方法時,它獲得的是物件例項(Object level)的鎖。而執行緒進入靜態同步方法時,它所獲得的是類例項(Class level)的鎖,兩者沒有必然關係;
  5. 如果synchronized中使用的物件是null,將會丟擲NullPointerException錯誤;
  6. synchronized對方法的效能有一定影響,因為執行緒要等待獲取鎖;
  7. 使用synchronized儘量使用程式碼塊,而不是整個方法,以免阻塞整個方法;
  8. 儘量不要使用String型別和原始型別作為引數。這是因為,JVM在處理字串、原始型別時會對它們進行優化。比如,你原本是想對不同的字串進行加鎖,然而JVM認為它們是同一個,很顯然這不是你想要的結果。

關於synchronized的可見性、指令排序等底層原理,我們會在後面的階段中詳細介紹。

以上就是文字的全部內容,恭喜你又上了一顆星!

夫子的試煉

  • 手寫程式碼體驗synchronized的不同用法。

參考資料

關於作者

關注公眾號【庸人技術笑談】,獲取及時文章更新。記錄平凡人的技術故事,分享有品質(儘量)的技術文章,偶爾也聊聊生活和理想。不販賣焦慮,不兜售課程。