比synchronized效能更好,功能更多的ReentrantLock

元思發表於2020-05-29

主要內容

1. synchronized介紹
2. ReentrantLock介紹
3. ReentrantLock和synchronized的可伸縮性比較
4. Condition變數
5. ReentrantLock是公平的嗎?
6. ReentrantLock這麼完美嗎?
7. 不要放棄synchronized
8. 什麼時候選擇ReentrantLock?

多執行緒和併發並不是什麼新鮮事物,但是,Java是第一個把支援跨平臺執行緒模型和記憶體模型直接納入語言規範的主流程式語言。
諸如,在類庫裡有用於建立、啟動、操作執行緒的Thread類,在語言特性上有用於多執行緒之間協作的synchronizedvolatile
它簡化了跨平臺併發程式的開發,但並不意味著編寫併發應用就變得非常容易。

synchronized介紹

把一個程式碼塊宣告為synchronized,會產生兩個重要的效果,原子性可見性(atomicity,visibility)。
原子性意味著同一時刻只能有一個執行緒執行這段被monitor物件(鎖)保護的程式碼,從而可以防止多執行緒併發修改共享變數時產生衝突。
可見性更微妙一些,它解決了由記憶體快取和編譯器優化造成的不確定性。
平時,執行緒採用自己的方式自由地儲存變數,不需要關心該變數對其他執行緒是否立即可見(變數可能在暫存器中、處理器特定的快取中,經過了指令重排或其他編譯器優化)。
但是如果開發者使用了同步,如下面的程式碼所示,那麼當一個執行緒對變數更新後,synchronized能夠保證在該執行緒退出同步程式碼塊之前,該更新對之後持有相同monitor進入同步快的執行緒立即可見。(volatile變數也存在類似的規則。)

synchronized (lockObject) { 
  // update object state
}

因此,同步可以確保可靠地更新多個共享變數而不會發生競態條件或資料不一致,並可以保證其他執行緒可以看到最新的值。
有了明確的跨平臺記憶體模型定義(JDK5.0中做了修改,修復了最初定義中的某些錯誤),就可以保證併發類可以實現"Write Once, Run Anywhere"。
併發類需遵循以下規則:如果你更新的變數可能被另一個執行緒讀取,或者相反的,你要讀取另一個執行緒更新的變數,都必須進行同步。
順便提一下,在最新的JVM中(JDK5),無競爭的同步(當鎖被持有時,沒有其他執行緒試圖獲取鎖)的效能還是不錯的。

改進synchronized

所以同步聽起來不錯,對嗎?那麼,為什麼JSR 166小組花了這麼多時間來開發java.util.concurrent.lock框架呢?
答案很簡單,同步是好,但不夠完美。它有一些功能上的限制,無法中斷正在等待獲取鎖的執行緒,也無法輪詢鎖或者嘗試獲取鎖而又不想一直等待。
同步還要求在獲取鎖的同一棧幀中釋放鎖,這在大多數情況下是正確的做法(並能與異常處理很好地互動),
但是在少數情況下可能更需要非塊結構的鎖。(原文是non-block-structured locking,是指不是synchronized程式碼塊形式的鎖)

ReentrantLock 類

java.util.concurrent.lock中的Lock框架是對鎖的抽象,它允許鎖作為一個普通的Java類來實現,而不是Java語言的特性(與之對應的是synchronized關鍵字)。
它給鎖的不同實現留出了空間,你可以實現具有不同排程演算法、不同效能特性的鎖,甚至不同的鎖的語義。
ReentrantLock類就是Lock抽象的一個實現,它具有與synchronized相同的併發性和記憶體語義,但是它還新增了諸如鎖輪訓,定時等待,以及等待可中斷的特性。
此外,在競爭激烈的情況下,它有更好的效能表現。(換句話說,當多個執行緒嘗試訪問共享資源時,JVM將花費更少的時間來排程執行緒,而將更多的時間用於執行程式。)

那麼可重入鎖(reentrant lock)是什麼意思?簡單地說,每個鎖都有一個與之關聯的計數器,如果執行緒再次獲取它,計數器就加1,然後需要釋放兩次才能真正釋放該鎖。
這和synchronized的語義是相似的。如果執行緒通過已持有的monitor進入了另一個同步塊(例如在一個同步方法中進入了另一個同步方法),該執行緒被允許執行,但是該執行緒退出第二個同步塊時,monitor不會被釋放,只有繼續退出第一個同步塊後,才能真正的釋放monitor。
在清單1的程式碼示例中,Lock和synchronized最大不同就表現出來了——Lock必須在finally中顯示釋放。否則,如果同步的程式碼引發異常,則該鎖可能永遠不會釋放!
這種區別聽起來似乎微不足道,但實際上,它非常重要。忘記在finally塊中釋放鎖會在程式中埋下定時炸彈,當它最終炸燬您的程式時,你將很難追根溯源。
然而,使用synchronized,JVM確保鎖會被自動釋放。
Listing 1. Protecting a block of code with ReentrantLock.

Lock lock = new ReentrantLock();
lock.lock();
try { 
  // update object state
}
finally {
  lock.unlock(); 
}

另外,與當前的synchronized實現相比,ReentrantLock的實現在鎖競爭下具有更好的可伸縮性。 (在將來的JVM版本中,synchronized的競爭效能可能會有所改善。)
這意味著,當多執行緒都爭用同一個鎖時,使用ReentrantLock會獲得更好的吞吐量。

ReentrantLock和synchronized的可伸縮性比較

Tim Peierls(《Java併發程式設計實戰》作者)使用簡單的線性同餘偽隨機數生成器(PRNG)構建了一個簡單的基準,用於測量synchronized與Lock的相對可伸縮性。
這個示例很好,因為每次呼叫nextRandom()時,PRNG實際上都會做一些實際工作,因此該基準測試是合理的,符合實際應用的,而不是通過睡眠計時模擬或不做任何事情。

在此基準測試中,我們有一個PseudoRandom介面,介面中只有一個方法nextRandom(int bound)。該介面與java.util.Random類的功能非常相似。
因為PRNG將上一次生成的數作為下一次生成隨機數的輸入,並且將上一次生成的數作為例項變數進行維護,所以很重要的一點是,更新該變數的程式碼塊不能被其他執行緒搶佔,
因此我們需要某種形式的鎖來確保這一點。 (java.util.Random也是這麼做的)
我們分別用ReentrantLock和synchronized實現了兩個PseudoRandom。主程式會產生許多執行緒,每個執行緒都瘋狂地擲骰子,然後計算不同版本每秒能夠擲多少次骰子。
圖1和圖2中是不同執行緒數下的測試結果。
該基準測試並不完美,它僅在兩個系統上執行(具有超執行緒的dual Xeon執行Linux,一個單處理器Windows系統),但應該足以表明ReentrantLock比同步具有更好的可伸縮性。
Figure 1. Throughput for synchronization and Lock, single CPU

Figure 2. Throughput (normalized) for synchronization and Lock, four CPUs

圖1和圖2顯示了兩種實現的每秒吞吐量(已標準化為1個執行緒同步的情況)。
可以看到,兩種實現在穩態吞吐量(steady-state)上都相對較快地收斂,這通常意味著處理器已得到充分利用。
你也許已經注意到,無論哪種情況的競爭,synchronized版本的效能都會顯著惡化,而Lock版本在排程開銷上花費的時間要少得多,從而為更高的吞吐量和更有效的CPU利用率騰出了空間。

Condition變數

根物件(Object類)中包括一些用於跨執行緒通訊的特殊方法-wait(), notify(), notifyAll()。
這些是高階的併發功能,很多開發人員未曾使用過它們,不過這可能是好事,因為他們的工作機制非常微妙而且容易錯誤使用。
幸運的是,在JDK5.0中新增了java.util.concurrent後,開發人員能使用到這些方法的情況就更少了。
notify和wait之間存在互相作用,要在一個物件上wait或notify,你必須要持有該物件的monitor(鎖)。
就像Lock是synchronized的泛化一樣,Lock框架中也有notify和wait的泛化,稱為Condition。
Lock物件充當了把Condition變數繫結到鎖的工廠物件。與標準的wait和notify方法不同,可以給一個Lock繫結多個Condition變數。
這簡化了許多併發演算法的開發。例如,在Condition的JavaDoc中展示了一個例子,使用兩個條件變數實現一個有界緩衝區,"not full"和 "not empty"。
與每個鎖上只有一個等待集(wait set)相比,條件變數更易讀且更有效。
類似於wait,notify和notifyAll,Condition的方法被命名為await,signal和signalAll,因為它們無法覆蓋Object中的相應方法。

這是不公平的

如果你仔細閱讀過Javadoc,會發現ReentrantLock的建構函式中有一個布林型別的引數,讓你選擇是需要公平鎖還是非公平鎖。
公平鎖是指執行緒得到鎖的順序與請求鎖的順序相同,先來先得。非公平鎖可能會允許插入,其中某個執行緒可能會先於其他更早請求鎖的執行緒得到鎖。
為什麼我們不希望鎖都是公平的?畢竟公平是件好事,不公平是壞事,對吧?
實際上,公平鎖是非常重的,並且付出了巨大的效能成本。公平即意味著比非公平鎖更低的吞吐量。
預設情況下,你應該選擇非公平鎖,除非你的演算法對正確性有嚴苛的要求,執行緒必須按照它們排隊的順序執行。

那麼synchronized呢?內建的monitor鎖是公平的嗎?答案是,可能讓你很驚訝,他們不是,從來都不是。
沒有人會抱怨執行緒飢餓問題,因為JVM保證了所有正在等待鎖的執行緒最終都會獲取鎖。
大多數情況下,保證統計學上的公平性已經足夠了,而且其成本要比保證絕對公平性要低得多。
因此,預設情況下,ReentrantLock是“不公平的”,和synchronized保持一致。
圖3和圖4的基準測試與上面的圖1、圖2是一樣的,只是增加了一個公平鎖(FAIR)。如你所見,“公平”不是免費的。 所以不要把“公平”作為預設值。

圖3. Relative throughput for synchronization, barging Lock, and fair Lock, with four CPUs

圖 4. Relative throughput for synchronization, barging Lock, and fair Lock, with single CPU

ReentrantLock這麼完美嗎?

看起來ReentrantLock在各個方面都比同步更好,同步能做的它都可以做(具有相同的記憶體和併發語義),同步不能做的它也可以做,並且在負載下具有更好的效能。
那麼,我們真的應該放棄synchronized嗎?還是等著之後被改進,或者甚至直接用ReentrantLock重寫我們現有的synchronized程式碼?
實際上,有關Java程式設計的書籍,在多執行緒的章節中都採用了這種方法,將示例完全用Lock實現,對synchronized是一帶而過。我認為這是一件好事。

不要放棄synchronized

儘管ReentrantLock的表現是令人印象深刻的,在同步方面有很多明顯的優勢,但是我認為急於將synchronized視為不推薦的功能是不明智的。
java.util.concurrent.lock中的鎖類是針對高階開發者或者高階元件的。通常,除非你需要Lock的高階功能,或者你有證據(不僅是懷疑)證明使用synchronized遇到了效能瓶頸,否則你都應該堅持使用synchronized。
為什麼在面對“更好”的Lock時,我顯著有點保守主義?
其實與Lock相比,synchronized仍然具有一些優勢。
其一,使用synchronized時你不可能忘記釋放鎖,因為在退出同步塊時JVM幫你做了。
而在finally塊中釋放鎖是很容易忘記的,這對程式的傷害極大。而且一旦出現問題,你很難定位到問題在哪。(這是完全不推薦初級開發人員不使用Lock的充分理由)。
另一個原因是,當JVM使用synchronized獲取鎖和釋放鎖時,會保留鎖相關的資訊,在進行執行緒dump時這些資訊會包括在內。這對程式除錯是無價的,因為它可以讓你定位到死鎖或者其他異常的根源。而Lock只是一個普通的類,JVM也不知道某個執行緒持有哪個Lock物件。

什麼時候選擇ReentrantLock?

那麼,什麼時候應該使用ReentrantLock?答案很簡單,在你需要使用synchronized無法提供的功能時,例如定時鎖、可中斷鎖、非塊結構鎖、多個條件變數、或鎖輪詢。
ReentrantLock還具有很好的可伸縮性,如果你確實遇到鎖競爭激烈的情況,就使用它吧。
不過請記住,絕大多數synchronized塊幾乎從未遇過任何競爭,更不用說激烈的競爭了。
我建議你先使用synchronized,直到被證明synchronized不能滿足需求了,
而不是簡單的假設“ReentrantLock的效能會更好”。請記住,Lock是面向高階使用者的高階工具。
真正的高手傾向於使用最簡單的工具完成工作,直到他們確信簡單工具已經不適合了。
一個永恆的真理,先使其正確,然後再考慮是否需要讓它更快。

總結

Lock框架是synchronized的相容替代品,它提供了許多同步未提供的功能,以及在競爭條件下更好的效能。
但是,這些優勢還不足以讓你總是優先選擇ReentrantLock,而冷落了synchronized。
根據你的實際需求來決定是否需要ReentrantLock的魔法力量。
我想,在大多數情況下你是不需要的Lock的,因為同步可以很好的勝任,可以在所有JVM上工作,可以被更廣泛的開發者理解,並且不容易出錯。

譯者注

文中作者多次強調了(感覺有點囉嗦,滑稽臉),非必要情況下不要使用ReentrantLock,而是優先考慮synchronized。
之所以對ReentrantLock這麼保守,我想是因為這篇文章寫在JDK5剛剛釋出的2004年,那時候Lock對於大多數開發者還是一個陌生的東西,對其工作原理和優缺點都不太熟悉。
但是2020年的今天,JDK13都發布了,JDK8已成了主流,那麼想用就用吧。

1.non-block-structured locking的說明:https://stackoverflow.com/questions/59677725/what-does-non-block-structured-locking-mean)
2.英文版原文 : https://www.ibm.com/developerworks/java/library/j-jtp10264/index.html?ca=drs-

相關文章