我去,你竟然還不會用 synchronized

沉默王二發表於2020-05-12

二哥,離你上一篇我去已經過去兩週時間了,這個系列還不打算更新嗎?著急著看呢。

以上是讀者 Jason 發來的一條資訊,不看不知道,一看真的是嚇一跳,上次我去是 4 月 3 號更新的,離現在一個多月了,可不只是兩週時間啊。可能我自己天天寫,沒覺得時間已經過去這麼久了,是時候帶來新的一篇“我去”了。

這次沒有程式碼 review,是同事小王直接問我的,“青哥,能給我詳細地說一說 synchronized 關鍵字怎麼用嗎?”他問的態度很謙遜,但我還是忍不住破口大罵:“我擦,小王,你丫的竟然不會用 synchronized,我當初是怎麼面試你進來的!”

(我筆名是沉默王二,讀者都叫二哥,但在公司不是的,同事叫我青哥,想知道我真名的,可以搜《Web全棧開發進階之路》)

簡單地說,當兩個或者兩個以上的執行緒同一時間要修改同一個可變的共享資料時,就需要一些保護措施,否則,共享資料修改後的結果大概率會超出你的預期。對於初學者來說,synchronized 關鍵字就是最好用的一種解決方案。

01、為什麼需要保護

可能很多初學者不明白,為什麼多執行緒環境下,可變共享變數修改後的結果會超出預期。為了解釋清楚這一點,來看一個例子。

public class SynchronizedMethod {
    private int sum;

    public int getSum() {
        return sum;
    }

    public void setSum(int sum) {
        this.sum = sum;
    }

    public void calculate() {
        setSum(getSum() + 1);
    }
}

SynchronizedMethod 是一個非常簡單的類,有一個私有的成員變數 sum,對應的 getter/setter,以及給 sum 加 1 的 calculate() 方法。

然後,我們來給 calculate() 方法寫一個簡單的測試用例。

可能一些初學者還不知道怎麼快速建立測試用例,我這裡就手摸手地現場教學下。

第一步,把滑鼠移動到類名上,會彈出一個提示框。

第二步,點選「More actions」按鈕,會彈出以下提示框。

第三步,選擇「Create Test」,彈出建立測試用例的對話方塊。

選擇最新的 JUnit5,如果專案之前沒有引入 JUnit5 依賴的話,IDEA 會提醒你,點選 Fix,IDEA 會自動幫你新增,非常智慧化。在對話方塊中勾選要建立測試用例的方法——calculate()

點選 OK 按鈕後,IDEA 會在 src 的同級目錄 test 下建立一個名為 SynchronizedMethodTest 的測試類:

class SynchronizedMethodTest {
    @Test
    void calculate() {
    }
}

calculate() 方法上會有一個 @Test 的註解,表示這是一個測試方法。新增具體的程式碼,如下所示:

ExecutorService service = Executors.newFixedThreadPool(3);
SynchronizedMethod summation = new SynchronizedMethod();

IntStream.range(01000)
        .forEach(count -> service.submit(summation::calculate));
service.awaitTermination(1000, TimeUnit.MILLISECONDS);

assertEquals(1000, summation.getSum());

1)Executors.newFixedThreadPool() 方法可以建立一個指定大小的執行緒池服務 ExecutorService。

2)通過 IntStream.range(0, 1000).forEach() 來執行 calculate() 方法 1000 次。

3)通過 assertEquals() 方法進行判斷。

執行該測試用例,結果會是什麼呢?

很不幸,失敗了。預期的值為 1000,但實際的值是 976。這是因為多執行緒環境下,可變的共享資料沒有得到保護。

02、synchronized 的用法

這麼說吧,初學者在遇到多執行緒問題時,只要 synchronized 關鍵字使用得當,問題就能夠迎刃而解。記得我剛回洛陽的時候,面試官問我,專案中是怎麼解決併發問題的呢?我就說用 synchronized 關鍵字,至於其他的一些鎖機制,我那時候還不知道。

嗯,面試官好像也不知道,因為小公司嘛,併發的量級有限,效能也不用考量得太過深入(大公司的讀者可以呵呵了)。接下來,就隨我來,一起看看 synchronized 最常見的三種用法吧。

1)直接用在方法上,就像下面這樣:

public synchronized void synchronizedCalculate() {
    setSum(getSum() + 1);
}

修改一下測試用例:

@Test
void synchronizedCalculate() throws InterruptedException {
    ExecutorService service = Executors.newFixedThreadPool(3);
    SynchronizedMethod summation = new SynchronizedMethod();

    IntStream.range(01000)
            .forEach(count -> service.submit(summation::synchronizedCalculate));
    service.awaitTermination(1000, TimeUnit.MILLISECONDS);

    assertEquals(1000, summation.getSum());
}

這時候,再執行測試用例就通過了。因為 synchronized 關鍵字會對 SynchronizedMethod 物件進行加鎖,同一時間內只允許一個執行緒對 sum 進行修改。這就好像有一間屋子,執行緒進入屋子裡面才可以對 sum 加 1,而 synchronized 就相當於在門上加了一個鎖,一個執行緒進去後就鎖上門,修改完 sum 後,下一個執行緒再進去,其他執行緒就在門外候著。

2)用在 static 方法上,就像下面這樣:

public class SynchronizedStaticMethod {
    public static int sum;

    public synchronized static void synchronizedCalculate() {
        sum = sum + 1;
    }
}

sum 是一個靜態變數,要修改靜態變數的時候,就需要把方法也變成 static 的。

來新建一個測試用例:

class SynchronizedStaticMethodTest {
    @Test
    void synchronizedCalculate() throws InterruptedException {
        ExecutorService service = Executors.newFixedThreadPool(3);

        IntStream.range(01000)
                .forEach(count -> service.submit(SynchronizedStaticMethod::synchronizedCalculate));
        service.awaitTermination(1000, TimeUnit.MILLISECONDS);

        assertEquals(1000, SynchronizedStaticMethod.sum);
    }
}

靜態方法上新增 synchronized 的時候就不需要例項化物件了,直接使用類名就可以引用方法和使用變數了。測試用例也是可以通過的。

synchronized static 和 synchronized 不同的是,前者鎖的是類,同一時間只能有一個執行緒訪問這個類;後者鎖的是物件,同一時間只能有一個執行緒訪問方法。

3)用在方法塊上,就像下面這樣:

public void synchronisedThis() {
    synchronized (this) {
        setSum(getSum() + 1);
    }
}

這時候,將 this 傳遞給了 synchronized 程式碼塊,當在某個執行緒中執行這段程式碼塊,該執行緒會獲取 this 物件的鎖,從而使得其他執行緒無法同時訪問該程式碼塊。如果方法是靜態的,我們將傳遞類名代替物件引用,示例如下所示:

public static void synchronisedThis() {
    synchronized (SynchronizedStaticMethod.class) {
        sum = sum + 1;
    }
}

新建一個測試用例:

@Test
void synchronisedThis() throws InterruptedException {
    ExecutorService service = Executors.newFixedThreadPool(3);
    SynchronizedMethod summation = new SynchronizedMethod();

    IntStream.range(01000)
            .forEach(count -> service.submit(summation::synchronisedThis));
    service.awaitTermination(1000, TimeUnit.MILLISECONDS);

    assertEquals(1000, summation.getSum());
}

測試用例和 synchronized 方法的大差不差,執行後也是可以通過的。兩者之間有所不同,synchronized 程式碼塊的鎖粒度要比 synchronized 方法小一些,因為 synchronized 程式碼塊所在的方法裡還可以有其他程式碼。

好了,我親愛的讀者朋友,以上就是本文的全部內容了,synchronized 的三種用法你一定掌握了吧?覺得文章有點用的話,請微信搜尋「沉默王二」第一時間閱讀。

本文 GitHub 已經收錄,有大廠面試完整考點,歡迎 Star。

我是沉默王二,一枚有趣的程式設計師,關注即可提高學習效率。最後,請無情地點贊、收藏、留言吧,謝謝。

相關文章