Java - synchronized 那些事

由於功率牆的影響,現代 CPU 傾向於使用多個核心(core)來提高其整體性能。這意味着,軟件開發人員不再能夠像以前一樣,把軟件放兩年,再拿出來,它的性能就變得足夠好了。爲了充分利用多核 CPU 的能力,我們也必須進入多線程編程的世界。

對 Java 程序員來說,這不是一件太困難的事。我們的語言本來就內置了同步功能。其中最常用的,莫過於 synchronized 關鍵字。他一共有兩種用法:
a. synchronized 語句

synchronized (mLock) {
    // Put your stuff here
}

b. synchronized 方法

public synchronized void foo() {
    // Put your stuff here
}

synchronized 加鎖時,使用的是對象的內置鎖(intrinsic lock),任何 Java 對象都擁有這個鎖,所以任何 Java 對象都可以用來給 synchronized 執行同步。synchronized 語句使用的對象由我們顯式指定,synchronized 方法則用的是方法所屬的那個對象的內置鎖。

我們知道,靜態方法不屬於任何對象,那靜態的 synchronized 方法又用的是哪個鎖呢?

回想一下,剛剛我提到,任何 Java 對象都可以用來給 synchronized 執行同步。雖然靜態方法不關聯對象,但是,他卻屬於所在的那個類實例。下面我們通過例子說明一下:

class Foo {
    public static synchronized void foo() {}
}

這裏的 foo() 屬於 class Foo。我們又知道,Java 裏,class Foo 對應着這樣一個類實例 Foo.classFoo.class 也是一個對象,所以他能夠被用來加鎖。靜態的同步方法,就是使用對應的類實例來進行加鎖的。

同樣,對應靜態的代碼塊,我們也可以這樣做:

static {
    synchronized (Foo.class) {
        // your stuff...
    }
}

當然,更推薦的做法是(原因見《Effective Java》):

private static final Object sLock = new Object();

static {
    synchronized (sLock) {
        // your stuff...
    }
}



上面說完了 synchronized 的一些基本用法,下面講講和它相關的 wait()notify(), notifyAll()

在某些情況下,我們可能要先獲取鎖,然後檢查某些條件,如果條件不滿足,則放棄鎖,稍後再重試。

// some thread
synchronized (mLock) {
    while (!condition) {
        wait();
    }
}

// another thread
synchronized (mLock) {
    condition = true;
    notify();
    // or notifyAll();
}

這個時候, wait()notify(), notifyAll() 就派上用場了。執行 wait() 後,會釋放鎖,然後進入休眠。稍後,其他某個線程修改條件,並重新喚醒前面那個線程。先前調用 wait() 的線程被喚醒後,會自動重新獲得鎖。也就是說,wait() 調用返回後,該線程仍然持有鎖。

注:某些面試官喜歡讓面試者回答 wait()sleep() 的區別。sleep()class Thread 的一個方法,它所做的就是直接去睡覺(不釋放鎖),兩者的區別是非常明顯的。



瞭解了基本的用法後,我們現在來看看 synchronized 背地裏做了什麼。

首先,我們知道,synchronized 所實現的範式稱爲管程(monitor,操作系統的教科書一般都會介紹)。所謂的管程,簡單講就是某一段代碼,同一時間只有一個線程可以執行它。

Java 所實現的管程是可重入的。意思是,只要某個獲得鎖,它就可以重複地獲得這個鎖,就像下面的代碼這樣:

synchronized void foo() {
    bar();
}

synchronized void bar() {
}

這裏,我們在進入 foo() 後就已經獲得了鎖,調用 bar() 的時候,我們又再獲取了一次。與可重入鎖相對於的,是不可重入鎖。著名的 pthread 庫所實現的 mutex 默認就是不可重入的,C++ 的 std::mutex 也一樣。在上面的例子中,對於不可重入鎖,在 foo() 裏調用 bar() 將會導致死鎖。


接下來,我們聊聊條件隊列(condition queue)。Java 的 API 並沒有暴露出條件隊列這個東西,但是,瞭解它會讓我們更清楚 wait(), notify(),notifyAll() 的作用。

首先,條件隊列是一個等待隊列,每個 condition 都關聯着一個條件隊列。如果使用的是內置鎖(也就是 synchronized 這種方式),一個對象只有一個 condition。如果需要多個,我們就得使用 class ReentrantLock

我們調用 wait() 後,調用線程會將自己放到這個內置鎖對應的條件隊列上,然後釋放鎖並休眠。當另一個線程調用 notify() 的時候,就會喚醒這個條件隊列上的某一個線程(具體是哪一個依賴於實現)。被喚醒的線程則重新嘗試獲取鎖,如果成功了,wait() 調用就會返回。如果一直沒有人 notify() 它,它將會永遠處於休眠狀態。

這個時候,你應該可以猜到,notifyAll() 的作用就是喚醒條件隊列裏所有的線程。之所以某些時候 notifyAll() 會帶來性能問題,也是這個特性導致的。

想象一種極端的情況,我們有 100 個線程消費者線程,1 個生產者線程。消費者在沒有物品消費的時候,就調用 wait() 在條件隊列上等待。由於生產非常慢,100 個消費者在檢查條件後,發現都不滿足,於是都在條件隊列上休眠。過了一段時間,生產者終於生產出了一個物品,然後調用 nofityAll()。結果,100 個消費者都醒了過來,可是最後只有 1 個能夠拿到這個產品,其餘 99 個線程什麼工作都沒有做,就又得回到休眠狀態。這種情況下,使用 nofity() 會更加的高效。需要注意的是,nofity() 在某些情況下卻會導致死鎖,所以只有在經過精細地設計後,才能使用 nofity()

總的來講,一開始應該總是使用 notifyAll(),只有在發現確實它導致性能問題時,才考慮 notify(),並且對死鎖問題給予足夠的關注。




注:notify()喚醒哪個線程依賴實現指的是,多個線程去搶一個鎖,我們不知道哪一個線程會先執行,(檢查條件不滿足)然後先放到條件隊列裏面。雖然我們叫他條件隊列,但實現不一定就是隊列。假設實現是一個 list,那我們 notify 的時候,也不知道他會 notify 隊頭元素還是隊尾。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章