天天看點

Java中的ReentrantLock和synchronized兩種鎖機制的對比

多線程和并發性并不是什麼新内容,但是 java 語言設計中的創新之一就是,它是第一個直接把跨平台線程模型和正規的記憶體模型內建到語言中的主流語言。核心類庫包含一個 <code>thread</code> 類,可以用它來建構、啟動和操縱線程,java 語言包括了跨線程傳達并發性限制的構造 —— <code>synchronized</code> 和 <code>volatile</code>。在簡化與平台無關的并發類的開發的同時,它決沒有使并發類的編寫工作變得更繁瑣,隻是使它變得更容易了。

<a target="_blank">synchronized 快速回顧</a>

把代碼塊聲明為 synchronized,有兩個重要後果,通常是指該代碼具有 原子性(atomicity)和 可見性(visibility)。原子性意味着一個線程一次隻能執行由一個指定監控對象(lock)保護的代碼,進而防止多個線程在更新共享狀态時互相沖突。可見性則更為微妙;它要對付記憶體緩存和編譯器優化的各種反常行為。一般來說,線程以某種不必讓其他線程立即可以看到的方式(不管這些線程在寄存器中、在處理器特定的緩存中,還是通過指令重排或者其他編譯器優化),不受緩存變量值的限制,但是如果開發人員使用了同步,如下面的代碼所示,那麼運作庫将確定某一線程對變量所做的更新先于對現有<code>synchronized</code> 塊所進行的更新,當進入由同一監控器(lock)保護的另一個 <code>synchronized</code> 塊時,将立刻可以看到這些對變量所做的更新。類似的規則也存在于 <code>volatile</code> 變量上。

synchronized (lockobject) {   

  // update object state  

}  

是以,實作同步操作需要考慮安全更新多個共享變量所需的一切,不能有争用條件,不能破壞資料(假設同步的邊界位置正确),而且要保證正确同步的其他線程可以看到這些變量的最新值。通過定義一個清晰的、跨平台的記憶體模型(該模型在 jdk 5.0 中做了修改,改正了原來定義中的某些錯誤),通過遵守下面這個簡單規則,建構“一次編寫,随處運作”的并發類是有可能的:

不論什麼時候,隻要您将編寫的變量接下來可能被另一個線程讀取,或者您将讀取的變量最後是被另一個線程寫入的,那麼您必須進行同步。

不過現在好了一點,在最近的 jvm 中,沒有争用的同步(一個線程擁有鎖的時候,沒有其他線程企圖獲得鎖)的性能成本還是很低的。(也不總是這樣;早期 jvm 中的同步還沒有優化,是以讓很多人都這樣認為,但是現在這變成了一種誤解,人們認為不管是不是争用,同步都有很高的性能成本。)

<a target="_blank">對 synchronized 的改進</a>

如此看來同步相當好了,是麼?那麼為什麼 jsr 166 小組花了這麼多時間來開發 <code>java.util.concurrent.lock</code> 架構呢?答案很簡單-同步是不錯,但它并不完美。它有一些功能性的限制 —— 它無法中斷一個正在等候獲得鎖的線程,也無法通過投票得到鎖,如果不想等下去,也就沒法得到鎖。同步還要求鎖的釋放隻能在與獲得鎖所在的堆棧幀相同的堆棧幀中進行,多數情況下,這沒問題(而且與異常處理互動得很好),但是,确實存在一些非塊結構的鎖定更合适的情況。

<a target="_blank">reentrantlock 類</a>

<code>java.util.concurrent.lock</code> 中的 <code>lock</code> 架構是鎖定的一個抽象,它允許把鎖定的實作作為 java 類,而不是作為語言的特性來實作。這就為 <code>lock</code> 的多種實作留下了空間,各種實作可能有不同的排程算法、性能特性或者鎖定語義。 <code>reentrantlock</code> 類實作了 <code>lock</code> ,它擁有與 <code>synchronized</code> 相同的并發性和記憶體語義,但是添加了類似鎖投票、定時鎖等候和可中斷鎖等候的一些特性。此外,它還提供了在激烈争用情況下更佳的性能。(換句話說,當許多線程都想通路共享資源時,jvm

可以花更少的時候來排程線程,把更多時間用在執行線程上。)

reentrant 鎖意味着什麼呢?簡單來說,它有一個與鎖相關的擷取計數器,如果擁有鎖的某個線程再次得到鎖,那麼擷取計數器就加1,然後鎖需要被釋放兩次才能獲得真正釋放。這模仿了 <code>synchronized</code> 的語義;如果線程進入由線程已經擁有的監控器保護的 synchronized 塊,就允許線程繼續進行,當線程退出第二個(或者後續) <code>synchronized</code> 塊的時候,不釋放鎖,隻有線程退出它進入的監控器保護的第一個 <code>synchronized</code> 塊時,才釋放鎖。

在檢視清單 1 中的代碼示例時,可以看到 <code>lock</code> 和 synchronized 有一點明顯的差別 —— lock 必須在 finally 塊中釋放。否則,如果受保護的代碼将抛出異常,鎖就有可能永遠得不到釋放!這一點差別看起來可能沒什麼,但是實際上,它極為重要。忘記在 finally 塊中釋放鎖,可能會在程式中留下一個定時炸彈,當有一天炸彈爆炸時,您要花費很大力氣才有找到源頭在哪。而使用同步,jvm 将確定鎖會獲得自動釋放。

<a target="_blank">清單 1. 用 reentrantlock 保護代碼塊。</a>

lock lock = new reentrantlock();  

lock.lock();  

try {   

finally {  

  lock.unlock();   

除此之外,與目前的 synchronized 實作相比,争用下的 <code>reentrantlock</code> 實作更具可伸縮性。(在未來的 jvm 版本中,synchronized 的争用性能很有可能會獲得提高。)這意味着當許多線程都在争用同一個鎖時,使用 <code>reentrantlock</code> 的總體開支通常要比 <code>synchronized</code> 少得多。

<a target="_blank">比較 reentrantlock 和 synchronized 的可伸縮性</a>

tim peierls 用一個簡單的線性全等僞随機數生成器(prng)建構了一個簡單的評測,用它來測量 <code>synchronized</code> 和 <code>lock</code> 之間相對的可伸縮性。這個示例很好,因為每次調用 <code>nextrandom()</code> 時,prng 都确實在做一些工作,是以這個基準程式實際上是在測量一個合理的、真實的 <code>synchronized</code> 和 <code>lock</code> 應用程式,而不是測試純粹紙上談兵或者什麼也不做的代碼(就像許多所謂的基準程式一樣。)

在這個基準程式中,有一個 <code>pseudorandom</code> 的接口,它隻有一個方法 <code>nextrandom(int bound)</code> 。該接口與 <code>java.util.random</code> 類的功能非常類似。因為在生成下一個随機數時,prng 用最新生成的數字作為輸入,而且把最後生成的數字作為一個執行個體變量來維護,其重點在于讓更新這個狀态的代碼段不被其他線程搶占,是以我要用某種形式的鎖定來確定這一點。( <code>java.util.random</code> 類也可以做到這點。)我們為 <code>pseudorandom</code> 建構了兩個實作;一個使用

syncronized,另一個使用 <code>java.util.concurrent.reentrantlock</code> 。驅動程式生成了大量線程,每個線程都瘋狂地争奪時間片,然後計算不同版本每秒能執行多少輪。圖 1 和 圖 2 總結了不同線程數量的結果。這個評測并不完美,而且隻在兩個系統上運作了(一個是雙 xeon 運作超線程 linux,另一個是單處理器 windows 系統),但是,應當足以表現 <code>synchronized</code> 與 <code>reentrantlock</code> 相比所具有的伸縮性優勢了。

Java中的ReentrantLock和synchronized兩種鎖機制的對比

圖 1 和圖 2 中的圖表以每秒調用數為機關顯示了吞吐率,把不同的實作調整到 1 線程 <code>synchronized</code> 的情況。每個實作都相對迅速地集中在某個穩定狀态的吞吐率上,該狀态通常要求處理器得到充分利用,把大多數的處理器時間都花在處理實際工作(計算機随機數)上,隻有小部分時間花在了線程排程開支上。您會注意到,synchronized 版本在處理任何類型的争用時,表現都相當差,而 <code>lock</code> 版本在排程的開支上花的時間相當少,進而為更高的吞吐率留下空間,實作了更有效的

cpu 利用。

<a target="_blank">條件變量</a>

根類 <code>object</code> 包含某些特殊的方法,用來線上程的 <code>wait()</code> 、 <code>notify()</code> 和 <code>notifyall()</code> 之間進行通信。這些是進階的并發性特性,許多開發人員從來沒有用過它們 —— 這可能是件好事,因為它們相當微妙,很容易使用不當。幸運的是,随着 jdk 5.0 中引入 <code>java.util.concurrent</code> ,開發人員幾乎更加沒有什麼地方需要使用這些方法了。

通知與鎖定之間有一個互動 —— 為了在對象上 <code>wait</code> 或 <code>notify</code> ,您必須持有該對象的鎖。就像 <code>lock</code> 是同步的概括一樣, <code>lock</code> 架構包含了對 <code>wait</code> 和<code>notify</code> 的概括,這個概括叫作 <code>條件(condition)</code> 。 <code>lock</code> 對象則充當綁定到這個鎖的條件變量的工廠對象,與标準的 <code>wait</code> 和 <code>notify</code> 方法不同,對于指定的 <code>lock</code> ,可以有不止一個條件變量與它關聯。這樣就簡化了許多并發算法的開發。例如, <code>條件(condition)</code> 的

javadoc 顯示了一個有界緩沖區實作的示例,該示例使用了兩個條件變量,“not full”和“not empty”,它比每個 lock 隻用一個 wait 設定的實作方式可讀性要好一些(而且更有效)。 <code>condition</code>的方法與 <code>wait</code> 、 <code>notify</code> 和 <code>notifyall</code> 方法類似,分别命名為 <code>await</code> 、 <code>signal</code> 和 <code>signalall</code> ,因為它們不能覆寫 <code>object</code> 上的對應方法。

<a target="_blank">這不公平</a>

如果檢視 javadoc,您會看到, <code>reentrantlock</code> 構造器的一個參數是 boolean 值,它允許您選擇想要一個 公平(fair)鎖,還是一個 不公平(unfair)鎖。公平鎖使線程按照請求鎖的順序依次獲得鎖;而不公平鎖則允許讨價還價,在這種情況下,線程有時可以比先請求鎖的其他線程先得到鎖。

為什麼我們不讓所有的鎖都公平呢?畢竟,公平是好事,不公平是不好的,不是嗎?(當孩子們想要一個決定時,總會叫嚷“這不公平”。我們認為公平非常重要,孩子們也知道。)在現實中,公平保證了鎖是非常健壯的鎖,有很大的性能成本。要確定公平所需要的記帳(bookkeeping)和同步,就意味着被争奪的公平鎖要比不公平鎖的吞吐率更低。作為預設設定,應當把公平設定為 <code>false</code> ,除非公平對您的算法至關重要,需要嚴格按照線程排隊的順序對其進行服務。

那麼同步又如何呢?内置的監控器鎖是公平的嗎?答案令許多人感到大吃一驚,它們是不公平的,而且永遠都是不公平的。但是沒有人抱怨過線程饑渴,因為 jvm 保證了所有線程最終都會得到它們所等候的鎖。確定統計上的公平性,對多數情況來說,這就已經足夠了,而這花費的成本則要比絕對的公平保證的低得多。是以,預設情況下 <code>reentrantlock</code> 是“不公平”的,這一事實隻是把同步中一直是事件的東西表面化而已。如果您在同步的時候并不介意這一點,那麼在 <code>reentrantlock</code> 時也不必為它擔心。

圖 3 和圖 4 包含與 圖 1和 圖 2 相同的資料,隻是添加了一個資料集,用來進行随機數基準檢測,這次檢測使用了公平鎖,而不是預設的協商鎖。正如您能看到的,公平是有代價的。如果您需要公平,就必須付出代價,但是請不要把它作為您的預設選擇。

Java中的ReentrantLock和synchronized兩種鎖機制的對比

<a target="_blank">處處都好?</a>

看起來 <code>reentrantlock</code> 無論在哪方面都比 <code>synchronized</code> 好 —— 所有 <code>synchronized</code> 能做的,它都能做,它擁有與 <code>synchronized</code> 相同的記憶體和并發性語義,還擁有 <code>synchronized</code> 所沒有的特性,在負荷下還擁有更好的性能。那麼,我們是不是應當忘記 <code>synchronized</code> ,不再把它當作已經已經得到優化的好主意呢?或者甚至用 <code>reentrantlock</code> 重寫我們現有的 <code>synchronized</code> 代碼?實際上,幾本

java 程式設計方面介紹性的書籍在它們多線程的章節中就采用了這種方法,完全用 <code>lock</code> 來做示例,隻把 synchronized 當作曆史。但我覺得這是把好事做得太過了。

<a target="_blank">還不要抛棄 synchronized</a>

雖然 <code>reentrantlock</code> 是個非常動人的實作,相對 synchronized 來說,它有一些重要的優勢,但是我認為急于把 synchronized 視若敝屣,絕對是個嚴重的錯誤。 <code>java.util.concurrent.lock </code>中的鎖定類是用于進階使用者和進階情況的工具 。一般來說,除非您對 <code>lock</code> 的某個進階特性有明确的需要,或者有明确的證據(而不是僅僅是懷疑)表明在特定情況下,同步已經成為可伸縮性的瓶頸,否則還是應當繼續使用

synchronized。

為什麼我在一個顯然“更好的”實作的使用上主張保守呢?因為對于 <code>java.util.concurrent.lock</code> 中的鎖定類來說,synchronized 仍然有一些優勢。比如,在使用 synchronized 的時候,不能忘記釋放鎖;在退出 <code>synchronized</code> 塊時,jvm 會為您做這件事。您很容易忘記用 <code>finally</code> 塊釋放鎖,這對程式非常有害。您的程式能夠通過測試,但會在實際工作中出現死鎖,那時會很難指出原因(這也是為什麼根本不讓初級開發人員使用 <code>lock</code> 的一個好理由。)

另一個原因是因為,當 jvm 用 synchronized 管理鎖定請求和釋放時,jvm 在生成線程轉儲時能夠包括鎖定資訊。這些對調試非常有價值,因為它們能辨別死鎖或者其他異常行為的來源。 <code>lock</code> 類隻是普通的類,jvm 不知道具體哪個線程擁有 <code>lock</code> 對象。而且,幾乎每個開發人員都熟悉 synchronized,它可以在 jvm 的所有版本中工作。在 jdk 5.0 成為标準(從現在開始可能需要兩年)之前,使用 <code>lock</code> 類将意味着要利用的特性不是每個

jvm 都有的,而且不是每個開發人員都熟悉的。

<a target="_blank">什麼時候選擇用 reentrantlock 代替 synchronized</a>

既然如此,我們什麼時候才應該使用 <code>reentrantlock</code> 呢?答案非常簡單 —— 在确實需要一些 synchronized 所沒有的特性的時候,比如時間鎖等候、可中斷鎖等候、無塊結構鎖、多個條件變量或者鎖投票。 <code>reentrantlock</code> 還具有可伸縮性的好處,應當在高度争用的情況下使用它,但是請記住,大多數 synchronized 塊幾乎從來沒有出現過争用,是以可以把高度争用放在一邊。我建議用 synchronized 開發,直到确實證明 synchronized

不合适,而不要僅僅是假設如果使用 <code>reentrantlock</code> “性能會更好”。請記住,這些是供進階使用者使用的進階工具。(而且,真正的進階使用者喜歡選擇能夠找到的最簡單工具,直到他們認為簡單的工具不适用為止。)。一如既往,首先要把事情做好,然後再考慮是不是有必要做得更快。

<code>lock</code> 架構是同步的相容替代品,它提供了 <code>synchronized</code> 沒有提供的許多特性,它的實作在争用下提供了更好的性能。但是,這些明顯存在的好處,還不足以成為用 <code>reentrantlock</code> 代替 <code>synchronized</code> 的理由。相反,應當根據您是否 需要 <code>reentrantlock</code> 的能力來作出選擇。大多數情況下,您不應當選擇它 —— synchronized

工作得很好,可以在所有 jvm 上工作,更多的開發人員了解它,而且不太容易出錯。隻有在真正需要 <code>lock</code> 的時候才用它。在這些情況下,您會很高興擁有這款工具。