並發王者課 - 青銅4:synchronized用法初體驗

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

參考資料

關於作者

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