Java中的ReentrantLock和synchronized兩種鎖定機制的對比
多線程和并發(fā)性并不是什么新內(nèi)容,但是?Java?語言設(shè)計中的創(chuàng)新之一就是,它是第一個直接把跨平臺線程模型和正規(guī)的內(nèi)存模型集成到語言中的主流語言。核心類庫包含一個?Thread
?類,可以用它來構(gòu)建、啟動和操縱線程,Java
語言包括了跨線程傳達并發(fā)性約束的構(gòu)造 ——?synchronized
?和?volatile
?。在簡化與平臺無關(guān)的并發(fā)類的開發(fā)的同時,它決沒有使并發(fā)類的編寫工作變得更繁瑣,只是使它變得更容易了。
synchronized 快速回顧
把代碼塊聲明為 synchronized,有兩個重要后果,通常是指該代碼具有?原子性(atomicity)和?可見性(visibility)。原子性意味著一個線程一次只能執(zhí)行由一個指定監(jiān)控對象(lock)保護的代碼,從而防止多個線程在更新共享狀態(tài)時相互沖突??梢娦詣t更為微妙;它要對付內(nèi)存緩存和編譯器優(yōu)化的各種反常行為。一般來說,線程以某種不必讓其他線程立即可以看到的方式(不管這些線程在寄存器中、在處理器特定的緩存中,還是通過指令重排或者其他編譯器優(yōu)化),不受緩存變量值的約束,但是如果開發(fā)人員使用了同步,如下面的代碼所示,那么運行庫將確保某一線程對變量所做的更新先于對現(xiàn)有synchronized
?塊所進行的更新,當(dāng)進入由同一監(jiān)控器(lock)保護的另一個?synchronized
?塊時,將立刻可以看到這些對變量所做的更新。類似的規(guī)則也存在于?volatile
?變量上。
[java]?view plain?copy synchronized?(lockObject)?{??? ??//?update?object?state?? }??
所以,實現(xiàn)同步操作需要考慮安全更新多個共享變量所需的一切,不能有爭用條件,不能破壞數(shù)據(jù)(假設(shè)同步的邊界位置正確),而且要保證正確同步的其他線程可以看到這些變量的最新值。通過定義一個清晰的、跨平臺的內(nèi)存模型(該模型在 JDK 5.0 中做了修改,改正了原來定義中的某些錯誤),通過遵守下面這個簡單規(guī)則,構(gòu)建“一次編寫,隨處運行”的并發(fā)類是有可能的:
不論什么時候,只要您將編寫的變量接下來可能被另一個線程讀取,或者您將讀取的變量最后是被另一個線程寫入的,那么您必須進行同步。
不過現(xiàn)在好了一點,在最近的 JVM 中,沒有爭用的同步(一個線程擁有鎖的時候,沒有其他線程企圖獲得鎖)的性能成本還是很低的。(也不總是這樣;早期 JVM 中的同步還沒有優(yōu)化,所以讓很多人都這樣認為,但是現(xiàn)在這變成了一種誤解,人們認為不管是不是爭用,同步都有很高的性能成本。)
對 synchronized 的改進
如此看來同步相當(dāng)好了,是么?那么為什么 JSR 166 小組花了這么多時間來開發(fā)?java.util.concurrent.lock
?框架呢?答案很簡單-同步是不錯,但它并不完美。它有一些功能性的限制 —— 它無法中斷一個正在等候獲得鎖的線程,也無法通過投票得到鎖,如果不想等下去,也就沒法得到鎖。同步還要求鎖的釋放只能在與獲得鎖所在的堆棧幀相同的堆棧幀中進行,多數(shù)情況下,這沒問題(而且與異常處理交互得很好),但是,確實存在一些非塊結(jié)構(gòu)的鎖定更合適的情況。
ReentrantLock 類
java.util.concurrent.lock
?中的?Lock
?框架是鎖定的一個抽象,它允許把鎖定的實現(xiàn)作為 Java 類,而不是作為語言的特性來實現(xiàn)。這就為?Lock
?的多種實現(xiàn)留下了空間,各種實現(xiàn)可能有不同的調(diào)度算法、性能特性或者鎖定語義。?ReentrantLock
?類實現(xiàn)了?Lock
?,它擁有與?synchronized
?相同的并發(fā)性和內(nèi)存語義,但是添加了類似鎖投票、定時鎖等候和可中斷鎖等候的一些特性。此外,它還提供了在激烈爭用情況下更佳的性能。(換句話說,當(dāng)許多線程都想訪問共享資源時,JVM
可以花更少的時候來調(diào)度線程,把更多時間用在執(zhí)行線程上。)
reentrant?鎖意味著什么呢?簡單來說,它有一個與鎖相關(guān)的獲取計數(shù)器,如果擁有鎖的某個線程再次得到鎖,那么獲取計數(shù)器就加1,然后鎖需要被釋放兩次才能獲得真正釋放。這模仿了?synchronized
?的語義;如果線程進入由線程已經(jīng)擁有的監(jiān)控器保護的 synchronized 塊,就允許線程繼續(xù)進行,當(dāng)線程退出第二個(或者后續(xù))?synchronized
?塊的時候,不釋放鎖,只有線程退出它進入的監(jiān)控器保護的第一個?synchronized
?塊時,才釋放鎖。
在查看清單 1 中的代碼示例時,可以看到?Lock
?和 synchronized 有一點明顯的區(qū)別 —— lock 必須在 finally 塊中釋放。否則,如果受保護的代碼將拋出異常,鎖就有可能永遠得不到釋放!這一點區(qū)別看起來可能沒什么,但是實際上,它極為重要。忘記在 finally 塊中釋放鎖,可能會在程序中留下一個定時炸彈,當(dāng)有一天炸彈爆炸時,您要花費很大力氣才有找到源頭在哪。而使用同步,JVM 將確保鎖會獲得自動釋放。
清單 1. 用 ReentrantLock 保護代碼塊。
[java]?view plain?copy Lock?lock?=?new?ReentrantLock();?? lock.lock();?? try?{??? ??//?update?object?state?? }?? finally?{?? ??lock.unlock();??? }??
除此之外,與目前的 synchronized 實現(xiàn)相比,爭用下的?ReentrantLock
?實現(xiàn)更具可伸縮性。(在未來的 JVM 版本中,synchronized 的爭用性能很有可能會獲得提高。)這意味著當(dāng)許多線程都在爭用同一個鎖時,使用?ReentrantLock
?的總體開支通常要比?synchronized
?少得多。
比較 ReentrantLock 和 synchronized 的可伸縮性
Tim Peierls 用一個簡單的線性全等偽隨機數(shù)生成器(PRNG)構(gòu)建了一個簡單的評測,用它來測量?synchronized
?和?Lock
?之間相對的可伸縮性。這個示例很好,因為每次調(diào)用?nextRandom()
?時,PRNG 都確實在做一些工作,所以這個基準程序?qū)嶋H上是在測量一個合理的、真實的?synchronized
?和?Lock
?應(yīng)用程序,而不是測試純粹紙上談兵或者什么也不做的代碼(就像許多所謂的基準程序一樣。)
在這個基準程序中,有一個?PseudoRandom
?的接口,它只有一個方法?nextRandom(int bound)
?。該接口與?java.util.Random
?類的功能非常類似。因為在生成下一個隨機數(shù)時,PRNG 用最新生成的數(shù)字作為輸入,而且把最后生成的數(shù)字作為一個實例變量來維護,其重點在于讓更新這個狀態(tài)的代碼段不被其他線程搶占,所以我要用某種形式的鎖定來確保這一點。(?java.util.Random
?類也可以做到這點。)我們?yōu)?PseudoRandom
?構(gòu)建了兩個實現(xiàn);一個使用
syncronized,另一個使用?java.util.concurrent.ReentrantLock
?。驅(qū)動程序生成了大量線程,每個線程都瘋狂地爭奪時間片,然后計算不同版本每秒能執(zhí)行多少輪。圖 1 和 圖 2 總結(jié)了不同線程數(shù)量的結(jié)果。這個評測并不完美,而且只在兩個系統(tǒng)上運行了(一個是雙 Xeon 運行超線程?Linux,另一個是單處理器
Windows 系統(tǒng)),但是,應(yīng)當(dāng)足以表現(xiàn)?synchronized
?與?ReentrantLock
?相比所具有的伸縮性優(yōu)勢了。
圖 1 和圖 2 中的圖表以每秒調(diào)用數(shù)為單位顯示了吞吐率,把不同的實現(xiàn)調(diào)整到 1 線程?synchronized
?的情況。每個實現(xiàn)都相對迅速地集中在某個穩(wěn)定狀態(tài)的吞吐率上,該狀態(tài)通常要求處理器得到充分利用,把大多數(shù)的處理器時間都花在處理實際工作(計算機隨機數(shù))上,只有小部分時間花在了線程調(diào)度開支上。您會注意到,synchronized 版本在處理任何類型的爭用時,表現(xiàn)都相當(dāng)差,而?Lock
?版本在調(diào)度的開支上花的時間相當(dāng)少,從而為更高的吞吐率留下空間,實現(xiàn)了更有效的
CPU 利用。
條件變量
根類?Object
?包含某些特殊的方法,用來在線程的?wait()
?、?notify()
?和?notifyAll()
?之間進行通信。這些是高級的并發(fā)性特性,許多開發(fā)人員從來沒有用過它們 —— 這可能是件好事,因為它們相當(dāng)微妙,很容易使用不當(dāng)。幸運的是,隨著 JDK 5.0 中引入?java.util.concurrent
?,開發(fā)人員幾乎更加沒有什么地方需要使用這些方法了。
通知與鎖定之間有一個交互 —— 為了在對象上?wait
?或?notify
?,您必須持有該對象的鎖。就像?Lock
?是同步的概括一樣,?Lock
?框架包含了對?wait
?和?notify
?的概括,這個概括叫作?條件(Condition)
?。?Lock
?對象則充當(dāng)綁定到這個鎖的條件變量的工廠對象,與標(biāo)準的?wait
?和?notify
?方法不同,對于指定的?Lock
?,可以有不止一個條件變量與它關(guān)聯(lián)。這樣就簡化了許多并發(fā)算法的開發(fā)。例如,?條件(Condition)
?的
Javadoc 顯示了一個有界緩沖區(qū)實現(xiàn)的示例,該示例使用了兩個條件變量,“not full”和“not empty”,它比每個 lock 只用一個 wait 設(shè)置的實現(xiàn)方式可讀性要好一些(而且更有效)。?Condition
的方法與?wait
?、?notify
?和?notifyAll
?方法類似,分別命名為?await
?、?signal
?和?signalAll
?,因為它們不能覆蓋?Object
?上的對應(yīng)方法。
這不公平
如果查看 Javadoc,您會看到,?ReentrantLock
?構(gòu)造器的一個參數(shù)是 boolean 值,它允許您選擇想要一個?公平(fair)鎖,還是一個?不公平(unfair)鎖。公平鎖使線程按照請求鎖的順序依次獲得鎖;而不公平鎖則允許討價還價,在這種情況下,線程有時可以比先請求鎖的其他線程先得到鎖。
為什么我們不讓所有的鎖都公平呢?畢竟,公平是好事,不公平是不好的,不是嗎?(當(dāng)孩子們想要一個決定時,總會叫嚷“這不公平”。我們認為公平非常重要,孩子們也知道。)在現(xiàn)實中,公平保證了鎖是非常健壯的鎖,有很大的性能成本。要確保公平所需要的記帳(bookkeeping)和同步,就意味著被爭奪的公平鎖要比不公平鎖的吞吐率更低。作為默認設(shè)置,應(yīng)當(dāng)把公平設(shè)置為?false
?,除非公平對您的算法至關(guān)重要,需要嚴格按照線程排隊的順序?qū)ζ溥M行服務(wù)。
那么同步又如何呢?內(nèi)置的監(jiān)控器鎖是公平的嗎?答案令許多人感到大吃一驚,它們是不公平的,而且永遠都是不公平的。但是沒有人抱怨過線程饑渴,因為 JVM 保證了所有線程最終都會得到它們所等候的鎖。確保統(tǒng)計上的公平性,對多數(shù)情況來說,這就已經(jīng)足夠了,而這花費的成本則要比絕對的公平保證的低得多。所以,默認情況下?ReentrantLock
?是“不公平”的,這一事實只是把同步中一直是事件的東西表面化而已。如果您在同步的時候并不介意這一點,那么在?ReentrantLock
?時也不必為它擔(dān)心。
圖 3 和圖 4 包含與 圖 1和 圖 2 相同的數(shù)據(jù),只是添加了一個數(shù)據(jù)集,用來進行隨機數(shù)基準檢測,這次檢測使用了公平鎖,而不是默認的協(xié)商鎖。正如您能看到的,公平是有代價的。如果您需要公平,就必須付出代價,但是請不要把它作為您的默認選擇。
處處都好?
看起來?ReentrantLock
?無論在哪方面都比?synchronized
?好 —— 所有?synchronized
?能做的,它都能做,它擁有與?synchronized
?相同的內(nèi)存和并發(fā)性語義,還擁有?synchronized
?所沒有的特性,在負荷下還擁有更好的性能。那么,我們是不是應(yīng)當(dāng)忘記?synchronized
?,不再把它當(dāng)作已經(jīng)已經(jīng)得到優(yōu)化的好主意呢?或者甚至用?ReentrantLock
?重寫我們現(xiàn)有的?synchronized
?代碼?實際上,幾本
Java 編程方面介紹性的書籍在它們多線程的章節(jié)中就采用了這種方法,完全用?Lock
?來做示例,只把 synchronized 當(dāng)作歷史。但我覺得這是把好事做得太過了。
還不要拋棄 synchronized
雖然?ReentrantLock
?是個非常動人的實現(xiàn),相對 synchronized 來說,它有一些重要的優(yōu)勢,但是我認為急于把 synchronized 視若敝屣,絕對是個嚴重的錯誤。?java.util.concurrent.lock?
中的鎖定類是用于高級用戶和高級情況的工具?。一般來說,除非您對?Lock
?的某個高級特性有明確的需要,或者有明確的證據(jù)(而不是僅僅是懷疑)表明在特定情況下,同步已經(jīng)成為可伸縮性的瓶頸,否則還是應(yīng)當(dāng)繼續(xù)使用
synchronized。
為什么我在一個顯然“更好的”實現(xiàn)的使用上主張保守呢?因為對于?java.util.concurrent.lock
?中的鎖定類來說,synchronized 仍然有一些優(yōu)勢。比如,在使用 synchronized 的時候,不能忘記釋放鎖;在退出?synchronized
?塊時,JVM 會為您做這件事。您很容易忘記用?finally
?塊釋放鎖,這對程序非常有害。您的程序能夠通過測試,但會在實際工作中出現(xiàn)死鎖,那時會很難指出原因(這也是為什么根本不讓初級開發(fā)人員使用?Lock
?的一個好理由。)
另一個原因是因為,當(dāng) JVM 用 synchronized 管理鎖定請求和釋放時,JVM 在生成線程轉(zhuǎn)儲時能夠包括鎖定信息。這些對調(diào)試非常有價值,因為它們能標(biāo)識死鎖或者其他異常行為的來源。?Lock
?類只是普通的類,JVM 不知道具體哪個線程擁有?Lock
?對象。而且,幾乎每個開發(fā)人員都熟悉 synchronized,它可以在 JVM 的所有版本中工作。在 JDK 5.0 成為標(biāo)準(從現(xiàn)在開始可能需要兩年)之前,使用?Lock
?類將意味著要利用的特性不是每個
JVM 都有的,而且不是每個開發(fā)人員都熟悉的。
什么時候選擇用 ReentrantLock 代替 synchronized
既然如此,我們什么時候才應(yīng)該使用?ReentrantLock
?呢?答案非常簡單 —— 在確實需要一些 synchronized 所沒有的特性的時候,比如時間鎖等候、可中斷鎖等候、無塊結(jié)構(gòu)鎖、多個條件變量或者鎖投票。?ReentrantLock
?還具有可伸縮性的好處,應(yīng)當(dāng)在高度爭用的情況下使用它,但是請記住,大多數(shù) synchronized 塊幾乎從來沒有出現(xiàn)過爭用,所以可以把高度爭用放在一邊。我建議用 synchronized 開發(fā),直到確實證明 synchronized
不合適,而不要僅僅是假設(shè)如果使用?ReentrantLock
?“性能會更好”。請記住,這些是供高級用戶使用的高級工具。(而且,真正的高級用戶喜歡選擇能夠找到的最簡單工具,直到他們認為簡單的工具不適用為止。)。一如既往,首先要把事情做好,然后再考慮是不是有必要做得更快。
Lock
?框架是同步的兼容替代品,它提供了?synchronized
?沒有提供的許多特性,它的實現(xiàn)在爭用下提供了更好的性能。但是,這些明顯存在的好處,還不足以成為用?ReentrantLock
?代替?synchronized
?的理由。相反,應(yīng)當(dāng)根據(jù)您是否?需要?ReentrantLock
?的能力來作出選擇。大多數(shù)情況下,您不應(yīng)當(dāng)選擇它 —— synchronized
工作得很好,可以在所有 JVM 上工作,更多的開發(fā)人員了解它,而且不太容易出錯。只有在真正需要?Lock
?的時候才用它。在這些情況下,您會很高興擁有這款工具。