Java 併發編程 Lock

可重入鎖,可中斷鎖,公平鎖,非公平鎖,AQS同步器,讀鎖,寫鎖,樂觀鎖,悲觀鎖 

2018年拍攝於日本京都幕府(二條城)唐門

微信公衆號

王皓的GitHub:https://github.com/TenaciousDWang

 

鎖,SUO,在生活中我們都用過,在計算機領域出現資源競爭時,我們也同樣需要鎖,來保證同時只有一個線程擁有當前資源進行操作,這個操作屬於黑盒操作,外面的線程無法獲知當前線程在做什麼操作,只有當前持有鎖的線程本身自己知道。

 

計算機裏的鎖是從最早的悲觀鎖發展而來的,後來才發展出如上一回說到的偏向鎖,輕量級鎖,及樂觀鎖,分段鎖等很多新型的鎖。

這裏簡單嘮叨兩句悲觀鎖與樂觀鎖。

 

悲觀鎖,正如其名,具有強烈的獨佔和排他特性。它指的是對數據被外界修改持保守態度,因此,在整個數據處理過程中,將數據處於鎖定狀態,例如我們的重量級鎖synchronized。

 

樂觀鎖顧名思義,就是很樂觀,每次去拿數據的時候都認爲別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,例如我們上一回說到的CAS算法,屬於非阻塞式同步。

 

 

兩種鎖各有優缺點,不可認爲一種好於另一種,像樂觀鎖適用於寫比較少的情況下,即衝突真的很少發生的時候,這樣可以省去了鎖的開銷,加大了系統的整個吞吐量。但如果經常產生衝突,例如CAS存在:“ABA”這種缺陷,就是當使用A與V比較時,不能保證A是修改過還是沒有修改過的數據,CAS算法會不斷的進行retry,這樣反倒是降低了性能,所以這種情況下用悲觀鎖就比較合適。

 

JDK1.5時,引用了Lock同步工具用於多線程下共享資源的操作控制,對於當時來說,Lock的引用是一大進步,比傳統的synchronized鎖性能更高,使用更加靈活,屬於API級別的操作,並提供了很多高級功能,今天這一回說Lock接口及其實現ReentrantLock可重入鎖,及ReadWriteLock接口及其實現ReentrantReadWriteLock讀寫鎖,讀寫鎖中包含兩個靜態內部類實現了Lock接口。

 

Lock接口是java.util.concurrent(JUC)中的頂級接口,其包含的方法如下:

 

 

我們來說一下Lock接口中每個方法的使用,其中lock()、tryLock()、tryLock(long time, TimeUnit unit)和lockInterruptibly()是用來獲取鎖的。

 

unLock()方法是用來釋放鎖的。

 

newCondition()這個方法用於線程協作,用於替換Object中的wait,notify,notifyAll,更高效更安全,代碼更優雅,以後會用經典生產者與消費者模型來說明一下如何使用,這一回先過。

 

ReentrantLock可重入鎖是Lock接口的唯一直接實現,也是最常用的鎖,所以這裏我們使用其創建鎖對象。

 

 

lock()方法,嘗試獲取鎖,獲取成功則返回,否則阻塞當前線程。多線程下訪問(互斥)共享資源時, 訪問前加鎖,訪問結束以後解鎖,解鎖的操作推薦放入finally塊中,以保證鎖一定被被釋放,防止死鎖的發生。運行結果如下。

 

https://mmbiz.qpic.cn/mmbiz_png/U6icu7jjcXN8POenibA2PtpcYPibD1jibxicRE6ico1fh9jevoia7dicFQyYz6gKu9T5xibfmXRECCnJ6JDloXqric7rSKfA/640?wx_fmt=png

 

第二種方式tryLock()方法是有返回值的,它表示用來嘗試獲取鎖,如果獲取成功,則返回true,如果獲取失敗(即鎖已被其他線程獲取),則返回false,也就說這個方法無論如何都會立即返回。

 

 

運行結果爲:

 

 

tryLock(long time, TimeUnit unit) 嘗試獲取鎖,若在規定時間內獲取到鎖,則返回true,否則返回false,未獲取鎖之前被中斷,則拋出異常,這裏不再贅述,使用方法參考tryLock()。

 

lockInterruptibly()嘗試獲取鎖,線程在成功獲取鎖之前被中斷,則放棄獲取鎖,拋出異常,注意獲取鎖時一定放到try catch外面,讓InterruptedException拋出。

 

首先創建一個LockInterruptedThread線程。

 

 

然後創建測試類用於啓動和中斷線程。

 

 

線程1在執行中時,線程2掛起,這時可以使用interrupt正常中斷。注意這裏使用的是Lock,是指在獲取鎖之前可中斷,如果使用synchronized獲取鎖則是線程阻塞時,即掛起時可中斷。

 

在說完Lock接口的基本用法後,我們來說一下ReentrantLock,ReentrantLock重入鎖是Lock接口裏最重要的實現,也是在實際開發中應用最多的一個,我們首先看一下ReentranLock的構造方法,總共有兩個。

 

 

這裏我們可以看到兩個類FairSync(公平鎖),NonfairSync(非公平鎖)。

 

第一種聲明的是FairSync公平鎖,是按照時間先後順序,使先等待的線程先得到鎖,而且,公平鎖不會產生飢餓鎖,也就是隻要排隊等待,最終能等待到獲取鎖的機會。

 

第二種聲明的是NonfairSync非公平鎖,所謂非公平鎖就和公平鎖概念相反,線程等待的順序並不一定是執行的順序,也就是後來進來的線程可能先被執行。

 

 

圖片引用自《碼出高效》

 

我們可以看到ReentrantLock對Lock接口的實現主要依賴了Sync,而Sync繼承了AbstractQueuedSynchronizer(AQS),AQS是JUC實現同步的基礎工具。

 

 

AQS中定義了一個volatile int state變量作爲共享變量,這裏主要利用了volatile解決多線程共享變量的可見性問題,類似於synchronized,但不具備其互斥性,後面會單獨說一下 volatile 關鍵字,如果線程獲取資源失敗,則進入同步FIFO(先進先出)隊列中等待,如果成功獲取資源就執行臨界區代碼(就是當前線程的需要執行的代碼),執行完釋放時,會通知隊列中等待的線程出隊執行。

 

我們以非公平ReentrantLock鎖先來說一下如何獲取鎖,上面說的state爲0時可獲取資源,獲取後設置爲1,並不斷加1,在釋放資源時不斷減1,直到爲0,這裏替換值是使用了CAS方法,非常高效。

 

 

調用compareAndSetState方法,傳了第一參數是期望值0,第二個參數是實際值1,當前這個方法實際是調用了unsafe.compareAndSwapInt實現CAS操作的,也就是上鎖之前狀態必須是0,如果是0調用setExclusiveOwnerThread方法。

 

當compareAndSetState方法返回false時,此時調用的是acquire方法,參數傳入1,tryAcquire()方法實際是調用了nonfairTryAcquire()方法。

 

 

註釋上寫着,請求獨佔鎖,忽略所有中斷,至少執行一次tryAcquire,如果成功就返回,否則線程進入阻塞--喚醒兩種狀態切換中,直到tryAcquire成功。

 

 

這裏一直使用CAS來設置狀態,效率很高,且一直使用自旋不停地調用,效率比Java1.6之前的synchronized高很多,但是大家知道自旋本身的效率是消耗更多的CPU資源而得來的。

 

接下來我們來對比一下公平鎖與非公平鎖。

 

在公平的鎖中,如果有另一個線程持有鎖或者有其他線程在等待隊列中等待這個鎖,那麼新發出的請求的線程將被放入到隊列中。

 

 

而非公平鎖上,只有當鎖被某個線程持有時,新發出請求的線程纔會被放入隊列中(此時和公平鎖是一樣的)。所以,它們的差別在於非公平鎖會有更多的機會去搶佔鎖。

 

公平鎖實現了先進先出的公平性,但是由於來一個線程就加入隊列中,往往都需要阻塞,再由阻塞變爲運行,這種上下文切換非常消耗性能,且在恢復一個被掛起的線程與該線程真正運行之間存在着嚴重的延遲。

 

非公平鎖由於允許插隊所以,上下文切換少的多,能更充分的利用cpu的時間片,儘量的減少cpu空閒的狀態時間,性能比較好,保證的大的吞吐量,但是容易出現飢餓問題。

 

ReentrantLock默認是非公平鎖,也是我們最常用的鎖,公平鎖性能不如非公平鎖,但是可以對業務進行控制,也是看情況來使用的。

 

ReentrantLock是排他鎖,這種鎖同一時刻只允許一個線程訪問,而讀寫鎖同一時刻可以多個線程訪問,但在寫線程訪問時,所有讀線程和其他寫線程都要被阻塞。讀寫鎖維護了一對鎖,一個讀鎖和一個寫鎖,通過分離讀寫鎖,使得併發性相比一般的排他鎖有很大提升。--《Java併發編程的藝術》

 

 

上圖是ReadWriteLock接口,只有兩個方法就是用來獲取讀鎖與寫鎖。除了同ReentrantLock一樣支持可重入性,公平與非公平鎖機制外,只有一個鎖降級特性,遵循獲取寫鎖、獲取讀鎖再釋放寫鎖的次序,寫鎖降級爲讀鎖。

 

接下來是ReadWriteLock的實現類ReentrantReadWriteLock。

 

 

ReentrantReadWriteLock裏面提供了很多豐富的方法,不過最主要的有兩個方法:readLock()和writeLock()用來獲取讀鎖和寫鎖,下面我們來看一下ReentrantReadWriteLock具體用法。

 

 

運行結果爲。

 

 

Thread0和Thread1在同時進行讀操作,這樣就大大提升了讀操作的效率。一般情況下,讀寫鎖的性能比排他鎖要好,因爲大多數場景讀是多於寫的,所以在讀多餘寫時,讀寫鎖能夠提供比排他鎖更好的性能和吞吐量。

 

讀鎖支持重入和共享,也就是同時可以被多個線程訪問,在沒有其他寫線程訪問時,所有讀操作總是不會阻塞的,就是都能獲取到。但是如果當前線程要獲取讀鎖時,發現寫鎖已經被獲取,那麼讀鎖要進入等待狀態。

 

寫鎖的獲取就比讀鎖的獲取繁瑣一些,要判斷讀鎖是否存在,存在讀鎖的話,寫鎖就不能獲取。

 

 

原因在於:要確保寫鎖的操作對讀鎖的可見性,如果允許讀鎖已經被獲取的情況下還要獲取寫鎖,那麼正在運行的其他讀線程就無法感知當前寫線程的操作。所以說獲取寫鎖前一定要看是否還有讀鎖已經被獲取。而寫鎖一旦被獲取,其他讀寫線程都要被阻塞了。我們要保證在寫之前不能有線程還在讀,這樣數據不準確。

 

總結一下規則就是:

 

如果有一個線程已經持有讀鎖,則此時其他線程如果要申請寫鎖,則申請寫鎖的線程會一直等待其他釋放讀鎖。

 

如果有一個線程已經持有寫鎖,則此時其他線程如果申請寫鎖或者讀鎖,則申請的線程會一直等待釋放寫鎖。

 

最後基於前面synchronized與本回說的Lock總結幾點Lock和synchronized的區別:

 

1、Lock是一個接口屬於鎖的API級實現,而synchronized是Java中的關鍵字,synchronized是內置的語言實現。

 

2、synchronized在發生異常時,會自動釋放線程佔有的鎖,因此不會導致死鎖現象發生,而Lock在發生異常時,如果沒有主動通過unLock()去釋放鎖,則很可能造成死鎖現象,因此使用Lock時需要在finally塊中釋放鎖。

 

3、Lock屬於可中斷鎖,synchronized屬於不可中斷鎖,Lock可以讓等待鎖的線程響應中斷,而synchronized卻不行,使用synchronized時,等待的線程會一直等待下去,不能夠響應中斷。

 

4、通過Lock可以知道有沒有成功獲取鎖,而synchronized卻無法辦到。

 

5、Lock可以提高多個線程進行讀操作的效率,有多種實現,使用起來比synchronized更加靈活。

 

6、ReentrantReadWriteLock不支持鎖升級或鎖膨脹。

 

JDK1.6之後,由於JVM虛擬機對synchronized這種重量級鎖進行了大量優化,在性能上來說,兩者的性能是差不多的,且官方文檔也建議使用synchronized來實現同步線程,但是當有特殊規則競爭資源且非常激烈時,此時Lock可以使用靈活的編程方式使性能遠遠優於synchronized。所以說,在具體使用時要根據適當情況選擇,以上。

 

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