並發王者課-青銅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的不同用法。

參考資料

關於作者

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

如果本文對你有幫助,歡迎點贊關注


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM