Java多執行緒中Lock鎖的使用總結
多核時代
摩爾定律告訴我們:當價格不變時,積體電路上可容納的電晶體數目,約每隔18個月便會增加一倍,效能也將提升一倍。換言之,每一美元所能買到的電腦效能,將每隔18個月翻兩倍以上。然而最近摩爾定律似乎遇到了麻煩,目前微處理器的整合度似乎到了極限,在目前的製造工藝和體系架構下很難再提高單個處理器的速度了,否則它就被燒壞了。所以現在的晶片製造商改變了策略,轉而在一個電路板上整合更多的處理器,也就是我們現在常見的多核處理器。
這就給軟體行業帶來麻煩(也可以說帶來機會,比如說就業機會,呵呵)。原來的情況是:我買一臺頻率比原來快一倍的處理器,那麼我的程式就比原來快一倍,軟體工程師什麼也不用幹。現在不一樣了,我買一臺雙核的處理器,我的程式和原來一樣慢,當然這條機器同時處理的任務可以變多了,但是對於單個任務來說並沒有幫助。
在幾年前,併發(Concurrent)和並行(Paralleling)程式設計還是在少量的地方使用,現在在個人的PC機上已經是很常見了。(Concurrencyandparallelism的區別參考這個帖子)
造個諸葛亮的價錢遠遠高於造三個臭皮匠!多核是在一臺機器上的併發,但是單機也是會到極限,所以分散式的計算也是類似的思路,用大量普通的機器協作完成一項任務。
但是要想編寫一個正確並且高效的能利用多核的多執行緒程式不是件容易的是,更別說分散式的情況(網路問題,機器故障,負載均衡,。。。)。現在的編譯器沒有辦法把單執行緒的程式自動編譯成一個多執行緒的版本(如果到了那一天,估計所有的程式設計師就失業了)。所以只能提供一些語言上的支援(比如scala/erlang)或者mapreduce這樣的框架。
Java雖然沒有提供scala那樣的基於訊息的模型,但是也提供了豐富的concurrent特性,並且遮蔽了平臺的相關性(這不是件容易的事,比如多個處理器有自己的快取,他們寫的東西不會離開被其它處理器看到),下面我們看看java的記憶體模型(JMM)
JMM(Java Memory Model)
並行程式有很多模型,比如共享記憶體模型,訊息傳遞模型等等。這些模型或多或少的利用了平臺相關的特性(在並行程式設計裡很難迴避平臺的特性以便高效的通訊),Java抽象出了自己的記憶體模型,使得開放人員看不到平臺的差異(這不是件容易的事),不過即使這樣,和傳統程式不同,我們還是不能完全不瞭解一些體系架構的細節問題,至少我們得了解一些。
在共享記憶體的多處理器體系架構裡(我們現在用的伺服器甚至筆記本都是),每個處理器都有自己的區域性快取並定期的使之與記憶體同步。不同的處理器架構保證了不同程度的快取一致性(cache coherence),所以作業系統,編譯器和執行時環境必須一起努力來彌補平臺的差異性。
讓每個處理器都知道其它處理器的狀態的代價是非常昂貴的,所以大多數架構都不會保證一致性,這通常不會有什麼問題:程序/執行緒直接並不共享資訊,編譯器可以調整程式碼執行順序以便提高效率,我們都很開心。當然也有需要線上程之間進行同步的時候,比如某個執行緒要讀取到另一個執行緒寫入的資訊,這個時候快取裡的資料就得同步到記憶體裡才行。所以這些體系架構都提供了一些指令來完成資料的同步(當然這些指令是非常費時的,能不做就儘量不做)。這些指令一般叫做memory barriers or fences。當然只是很底層的一些東西,所幸Java提供了一些高層的抽象,讓我們的生活變得容易一些。
sequential consistency: 我們假設一個執行緒執行(可能在多個處理器上切換),每個變數讀取到的值都是最新的修改(也就是Cache裡的立馬生效),這樣得到的結果是我們預期的。
但是讓我們意外的事情是:如果我們不做任何事情,那麼很可能會出現錯誤,比如下面的這個例子:
public class NoVisibility { private static boolean ready; private static int number; private static class ReaderThread extends Thread { public void run() { while (!ready) Thread.yield(); System.out.println(number); } } public static void main(String[] args) { new ReaderThread().start(); number = 42; ready = true; } }
我們在主執行緒裡先讓number=42(初始值是0),然後讓ready=true,而另一個執行緒不斷堅持是否ready,如果ready,那麼讀出number。很自然的我們期望子執行緒打印出42,但是很可能結果會另我們失望。編譯器可能會調換number=42 和 ready=true的順序(思考一下為什麼它要這麼幹?為什麼在單執行緒的情況下沒有問題?),另外子執行緒可能永遠在while裡死迴圈。為什麼?子執行緒會永遠看不到ready的變化?這也許讓很多人吃驚,事實確實如此,JSR並不保證這一點(雖然大多數時候子執行緒能夠退出),參考這個帖子和JMM的文章
vilatile和snychronized(intrinsic Lock)
vilatile關鍵字告訴編譯器,一個執行緒對某個變數的修改立即對所有其它執行緒看見,加上這個能保證上面的程式不會死迴圈。但是不能保證讀到42,也就是保證number=42和ready=true的執行順序,要保證這點就要用到synchronized。
synchronized能夠保證執行的順序,除此之外,它也能保證可見性。
public class NoVisibility { private static boolean ready; private static int number; private static class ReaderThread extends Thread { public void run() { boolean r=false; while (true){ synchronized(NoVisibility.class){ r=ready; } if(r) break; else Thread.yield(); } System.out.println(number); } } public static void main(String[] args) { new ReaderThread().start(); synchronized(NoVisibility.class){ number = 42; ready = true; } } }
synchronized(NoVisibility.class){ number = 42; ready = true; }
這段程式碼保證了兩個語句的執行順序
synchronized(NoVisibility.class){ r=ready; }
這保證子執行緒能看到ready的變化 注意他們必須synchronized同一個物件,如果是下面的程式碼,則不能有任何保障。為什麼?試想任何synchronized裡的變數必須立即對所有的可見,那麼代價太大, 比如我有這樣的需求:我只要求兩個語句順序執行,它是否對別人可見我並不關心。
synchronized(AnotherObject){ r=ready; }
每個物件都有個Monitor,所以synchronized也經常叫Monitor Lock,另外這個鎖是語言內建的,所以也叫Intrinsic Lock。 這兩個關鍵字是java1.5之前就有了,在java1.5之後新引進了java.util.concurrent包,這裡有我們需要關注的很多東西,這裡我們只關心Lock相關的介面和類。 不過synchronized來解決互斥不是很完美嗎?我為什麼要花力氣搞這些新鮮東西呢?下面我們來看看synchronized解決不了(或者很難解決)的問題
銀行轉賬的例子
// Warning: deadlock-prone! public void transferMoney(Account fromAccount,Account toAccount,DollarAmount amount) throws InsufficientFundsException { synchronized (fromAccount) { synchronized (toAccount) { if (fromAccount.getBalance().compareTo(amount) < 0) throw new InsufficientFundsException(); else { fromAccount.debit(amount); toAccount.credit(amount); } } } }
比如我要在兩個使用者之間轉賬,為了防止意外,我必須同時鎖定兩個賬戶。但是這可能造成死鎖。比如:
A: transferMoney(myAccount,yourAccount,10); B: transferMoney(yourAccount,myAccount,20);
當執行緒A鎖住myAccount時,B鎖住了toAccount,這個時候A嘗試鎖住toAccount,但是已經被B鎖住,所以A不能繼續執行,同理B也不能執行,造成死鎖。
怎麼解決呢?你也許回想,我先鎖住一個賬戶,然後"嘗試"鎖定另一個賬戶,如果“失敗”,那麼我釋放所有的鎖,“休息”一下再繼續嘗試,當然兩個執行緒節拍一致的話,可能造成“活鎖”
可惜synchronized不能提供這樣的語義,它一旦嘗試加鎖,只能拿到鎖,你不能控制它,比如你可能有這樣的需求:嘗試拿鎖30s,如果拿不到就算了,synchronized是沒辦法滿足這樣的需求的。另外你使用“鴕鳥”策略來解決死鎖:什麼也不幹,如果死鎖了,kill他們,重啟他們。這種策略看起來很瘋狂,不過如果死鎖的概率很多,而避免死鎖的演算法很複雜,那這也是可以一試的策略(那一堆死鎖發生的充分必要條件太麻煩了!!!)。下面我們仔細的來看看java1.5後提供的Lock介面及其相關類。
Lock介面
Lock的基本用法如下,為了防止異常退出時沒有釋放鎖,一般都在拿到鎖後立馬try,try住所有臨界區的程式碼,然後finally釋放鎖。
主要和synchronized的區別,synchronized裡我們不用操心這些,如果synchronized保護的程式碼丟擲異常,那麼jvm會釋放掉Monitor Lock。
Lock l = ... l.lock(); try { // access the resource protected by this lock } finally { l.unlock(); }
Lock.lock()在鎖定成功後釋放鎖之前,它所保護的程式碼段必須與使用synchronized保護的程式碼段有相同的語義(可見性,順序性)。
所以從這個角度來說,Lock完全可以代替synchronized,那麼是否應該拋棄掉synchronized呢?答案是否定的。
是否應該拋棄synchronized?
在java5引進Lock後,實現了Lock介面的類就是ReentrantLock(呆會再解釋Reentrant),因為java5之前synchronized的實現很爛,同樣是為了實現互斥,ReentrantLock會比synchronized速度上快很多,不過到了jdk6之後就不是這樣了,下面是一個測試結果:from book "Java Concurrency in Practice"
橫軸是執行緒數,縱軸是ReentrantLock的吞吐量/IntrinsicLock的吞吐量。
可以看出,jdk5中,ReentrantLock快很多,但是到了jdk6,他們就沒什麼大的差別了。
synchronized的優點:鎖的釋放是語言內建的,不會出現忘記釋放鎖的情況,另外由於是語言內建的支援,除錯是能很快知道鎖被哪個執行緒持有,它加鎖的次數。而Lock只是util.concurrent一個普通的類,所以偵錯程式並不知道這個鎖的任何資訊,它只是一個普通的物件(當然你可以仔細觀察每個執行緒的stack frame來看它在等待鎖)。
所以建議:如果只是為了實現互斥,那麼使用synchronized(扔掉jdk5吧,現在都java7了),如果想用Lock附加的功能,那麼才使用Lock。
下面回來繼續看Lock介面。
Interface Lock
public interface Lock { void lock(); void lockInterruptibly() throws InterruptedException; boolean tryLock(); boolean tryLock(long timeout,TimeUnit unit) throws InterruptedException; void unlock(); Condition newCondition(); }
void lock();
嘗試獲取鎖。如果鎖被別人拿著,那麼當前執行緒不在執行,也不能被排程,直到拿到鎖為止。
void lockInterruptibly() throws InterruptedException
嘗試獲取鎖,除非被interrupted。如果鎖可以獲取,那麼立刻返回。
如果無非獲取鎖,那麼執行緒停止執行,並且不能被再排程,直到:
- 當前執行緒獲得鎖
- 如果鎖的實現支援interruption,並且有其它執行緒interrupt當前執行緒。
仔細閱讀javadoc的第二個情況:Lock介面並不要求Lock的實現支援interruption,不過sun jdk的實現都是支援的。
這個函式在下面兩個情況下丟擲InterruptedException:
- 如果鎖的實現支援interruption,並且有其它執行緒interrupt當前執行緒。
- 執行緒呼叫這個函式之前就被設定了interrupted狀態位
可以發現這個方法並不區分這個interrupted狀態位是之前就有的還是lock過程中產生的。不管如果,丟擲異常後會清除interrupted標記。
使用這個方法,我們可以中斷某個等鎖的執行緒,比如我們檢測到了死鎖,那麼我們可以中斷這個執行緒
boolean tryLock()
嘗試獲取鎖,如果可以,那麼鎖住物件然後返回true,否則返回false,不管怎麼樣,這個方法會立即返回。下面的例子展示了用這個方法來解決前面轉賬的死鎖:
public boolean transferMoney(Account fromAcct,Account toAcct,DollarAmount amount,long timeout,TimeUnit unit) throws InsufficientFundsException,InterruptedException { long fixedDelay = getFixedDelayComponentNanos(timeout,unit); long randMod = getRandomDelayModulusNanos(timeout,unit); long stopTime = System.nanoTime() + unit.toNanos(timeout); while (true) { if (fromAcct.lock.tryLock()) { try { if (toAcct.lock.tryLock()) { try { if (fromAcct.getBalance().compareTo(amount) < 0) throw new InsufficientFundsException(); else { fromAcct.debit(amount); toAcct.credit(amount); return true; } } finally { toAcct.lock.unlock(); } } } finally { fromAcct.lock.unlock(); } } if (System.nanoTime() < stopTime) return false; NANOSECONDS.sleep(fixedDelay + rnd.nextLong() % randMod); } }
tryLock boolean tryLock(long time,TimeUnit unit) throws InterruptedException
和tryLock類似,不過不是立即返回,而是嘗試一定時間後還拿不到鎖就返回
unlock
釋放鎖
newCondition
暫且不管
Class ReentrantLock
這是sun jdk(open jdk)裡唯一直接實現了Lock介面的類,所以如果你想用Lock的那些特性,比如tryLock,那麼就應該首先考慮它
首先我們解釋一下Reentrant
Reentrant翻譯成中文應該是“可重入”,對於鎖來說,可重入是指如果一個執行緒已拿到過一把鎖,那麼它可以再次拿到鎖。
聽起來似乎沒有什麼意思,讓我們來看看“不可重入”鎖可能的一些問題和需要使用”可重入“鎖的場景吧。
public class Widget { public synchronized void doSomething() { ... } } public class LoggingWidget extends Widget { public synchronized void doSomething() { System.out.println(toString() + ": calling doSomething"); super.doSomething(); } } Widget widget=new LoggingWidget(); widget.doSomething();
設想這樣一個應用場景:我們有一個圖的資料結構,我們需要遍歷所有節點,找到滿足某些條件的節點,鎖定所有這些節點,然後對他們進行一些操作。由於圖的遍歷可能重複訪問某個節點,如果簡單的鎖定每個滿足條件的節點,那麼可能死鎖。當然我們可以自己用程式記下哪些節點已經訪問過了,不過也可以把這就事情交給ReentrantLock,第二次鎖定某個物件也會成功並立即返回。那麼你可能會問,我釋放鎖的時候怎麼記得它鎖定過了多少次呢?如果釋放少了,那麼會死鎖;釋放多了,可能也會有問題(有些鎖實現會丟擲異常,但是JMM好像沒有定義)。
【上面的場景參考http://stackoverflow.com/questions/1312259/what-is-the-re-entrant-lock-and-concept-in-general】不用擔心,ReentrantLock提供了getHoldCount方法,最後釋放這麼多次就好了。
ReentrantLock會記下當前拿鎖的執行緒,已經拿鎖的次數,每次unlock都會減一,如果為零了,那麼釋放鎖,另一個執行緒到鎖並且計數器值為一。
ReentrantLock的建構函式可以接受一個fairness的引數。如果為true,那麼它會傾向於把鎖給等待時間最長的執行緒。但是這樣的代價也是巨大的:
橫軸是併發執行緒數,參考方法是ConcurrentHashMap,另外分別用Nonfair Lock和 fair Lock封裝普通的HashMap,可以看到,是否fair的差別是非常巨大的。
正如前面所說的,ReentrantLock是支援Interrupted的。
Interface ReadWriteLock
有的應用場景下,有兩類角色:Reader和Writer。Reader讀取資料,Writer更新資料。多個Reader同時讀取是沒有問題的,但是Reader們和Writer是互斥的,並且Writer和Writer也是互斥的。而且很多應用中,Reader會很多,而Writer會比較少。這個介面就是為了解決這類特殊場景的。
public interface ReadWriteLock { Lock readLock(); Lock writeLock(); } 用法: ReadWriteLock rwl = ...; //Reader threads read(){ rwl.readLock().lock(); try{ //entering critical setion }finally{ rwl.readLock().unlock(); } } write(){ rwl.writeLock().lock(); try{ //entering critical setion }finally{ rwl.writeLock().unlock(); }}
Class ReentrantReadWriteLock
這是Sun jdk裡唯一實現ReadWriteLock介面的類。這個類的特性:
獲取鎖的順序
這個類並不傾向Reader或者Writer,不過有個fairness的策略非公平模式(預設)
如果很多Reader和Writer的話,很可能Reder一直能獲取鎖,而Writer可能會飢餓
公平模式
這種模式下,會盡量以請求鎖的順序來保證公平性。當前鎖釋放以後,等待時間最長的Writer或者一組Reader(Reader是一夥的!)獲取鎖。如果鎖被拿著,這時Writer來了,他會開始排隊;如果Reader來了,如果它之前沒有Writer並且當前拿鎖的是Reader,那麼它直接就拿到鎖,當然如果是Writer拿著,那麼它也只能排隊等鎖。 不過如果Reader拿著鎖,Writer排隊,然後Reader排在Writer後,但是Writer放棄了排隊(比如它用的是tryLock 30s),那麼Reader直接拿到鎖而不用排隊。
還有就是ReentrantReadWriteLock.ReadLock.tryLock() 和 ReentrantReadWriteLock.WriteLock.tryLock()方法不管這些,一旦呼叫的時候能拿到鎖,那麼它們就會插隊!!
Reentrancy
從名字就知道它支援可重入。
以前拿過鎖的Reader和Writer可以繼續拿鎖。另外拿到WriteLock的執行緒可以拿到ReadLock,但是反之不然。
Lock downgrading
拿到WriteLock的可以直接變成ReadLock,不用釋放WriteLock再從新請求ReadLock(這樣需要重新排隊),實現的方法是先拿到WriteLock,接著拿ReadLock(上面的特性保證了不會死鎖),然後釋放WriteLock,這樣就得到一個ReadLock並立馬持有。
Interruption of lock acquisition
支援
一個使用讀寫鎖的例子
class CachedData { Object data; volatile boolean cacheValid; ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); void processCachedData() { rwl.readLock().lock(); if (!cacheValid) { // Must release read lock before acquiring write lock rwl.readLock().unlock(); rwl.writeLock().lock(); // Recheck state because another thread might have acquired // write lock and changed state before we did. if (!cacheValid) { data = ... cacheValid = true; } // Downgrade by acquiring read lock before releasing write lock rwl.readLock().lock(); rwl.writeLock().unlock(); // Unlock write,still hold read } use(data); rwl.readLock().unlock(); } }
一個Cache資料的例子,讀取資料時首先拿讀鎖,如果cache是有效的(volatile boolean cacheValid),直接使用資料。
如果失效了,那麼釋放讀鎖,獲取寫鎖【這個類不支援upgrading】,然後double check一下是否cache有效,如果還是無效(說明它應該更新),那麼更新資料,並且修改變數cacheValid,讓其它執行緒看到。
臭名昭著的double check
前面提到了double check,這裡也順便討論一下:
@NotThreadSafe public class DoubleCheckedLocking { private static Resource resource; public static Resource getInstance() { if (resource == null) { synchronized (DoubleCheckedLocking.class) { if (resource == null) resource = new Resource(); } } return resource; } }
很多“hacker”再提到延遲載入的時候都會提到它,上面的程式碼看起來沒有什麼問題:首先檢查一些resource,如果為空,那麼加鎖,因為檢查resource==null沒有加鎖,所以可能同時兩個執行緒進入if並且請求加鎖,所以第一個拿到鎖的初始化一次,第二次拿鎖的會再次check。這看起來很完美:大多數情況下resouce不為空,很少的情況(剛開始時)resource為空,那麼再加鎖,這比一上來就加鎖要高效很多不過千萬別高興地太早了,因為編譯器對引用的賦值可能會做優化,可能這個物件還沒有正確的構造好,值已經賦好了(為什麼要這麼做?也許構造物件需要IO,io等待的時間把值賦好了能提高速度)。這個時候別的執行緒就慘了!另外很多講延遲載入的文章都比較早(早於jdk6),那個年代java的synchronized確實很不給力。如果你實在在乎這點效能的話,應該用jvm的靜態類載入機制來實現:
@ThreadSafe public class ResourceFactory { private static class ResourceHolder { public static Resource resource = new Resource(); } public static Resource getResource() { return ResourceHolder.resource ; } }
到此這篇關於Java多執行緒中Lock鎖的使用總結的文章就介紹到這了,更多相關Java多執行緒 Lock鎖的使用內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!