显式锁Lock的集大成之作,最细节教程
顯式鎖是什么?
我們一般喊synchronized就叫synchronized。其實synchronized又被稱為隱式鎖,但我們就愛喊它synchronized。而顯式鎖就是我們一般說的Lock鎖,但大家就是愛叫它顯式鎖。大概是Lock很容易和別的單詞搞混吧?但無論如何,顯式鎖就是說的Lock鎖。
那么Lock為啥要叫它顯式鎖呢?八竿子打不著一邊我很難記住啊!
我們來看一下Lock加鎖的范式:
如上圖,我們注意到,lock.unlock();是被放入 finally 代碼塊里的,這是為了保證出現異常時,鎖依然能被釋放掉,避免死鎖的產生。順便提一下,我們的synchronized方法或synchronized代碼塊中的代碼,在執行期間發生異常,變會自動釋放鎖,因此沒有顯示的退出(unlock)。
Lock是一個接口,提供了無條件的、可輪詢的、定時的、可中斷的鎖獲取操作,所有的加鎖和解鎖操作方法都是顯示的(必須得寫出來),因而稱為顯式鎖。這下印象深刻了吧。
同時,我們還要注意到,加鎖的過程:lock.lock();,并沒有放在 try 代碼塊內,而且你會發現,JDK 文檔中很多使用 lock 的地方都是將加鎖過程:lock.lock();放在了 try 的外部。
lock.lock()放在try語句中的后果
博主在瀏覽一下網上的技術博客時,發現竟然有人將lock.lock();加在了try語句內,如下面這種格式:
這樣做合適么?答案是肯定不合適。
假如我們在try語句和lock.lock();之間發生了異常,或者直接在獲取鎖的時候發生異常。那么就會在未成功執行lock.lock();時,執行finally代碼塊的lock.unlock();去釋放鎖。可是此時我們并沒有獲取鎖,直接執行釋放鎖會出現問題么?這個問題,我們留到后文的AQS中繼續探討。
synchronized和lock的區別
那么此處就對我們的隱式鎖和顯式鎖進行一下對比:
出身,層次不同
從synchronized和lock的出身(原始的構成)來看看兩者的不同:
- synchronized : Java中的關鍵字,是由JVM來維護的。是JVM層面的鎖。
- Lock:是JDK5以后才出現的具體的類。使用lock是調用對應的API。是API層面的鎖。
synchronized 是底層是通過monitorenter進行加鎖(底層是通過monitor對象來完成的,其中的wait/notify等方法也是依賴于monitor對象的。只有在同步塊或者是同步方法中才可以調用wait/notify等方法的。因為只有在同步塊或者是同步方法中,JVM才會調用monitory對象的);通過monitorexit來退出鎖的。
而lock是通過調用對應的API方法來獲取鎖和釋放鎖的。
通過反編譯的結果,我們可以看出synchronized和lock的區別。
用總結漫威電影的一句話來概述這兩個鎖的區別:窮人靠變異,富人裝備。synchronized自打娘胎里(JVM級別)就擁有不俗的實例,而lock則是在后期不斷通過裝備(類級別的封裝)展現出更多姿多彩的能力。
使用方式不同
這個在我們文章的頭部已經介紹了,此處再給出以下用法的對比:
切記使用lock的時候,加鎖放在try外面,解鎖放在finally中。
換到漫威電影里,我們可以類比為:蜘蛛俠(synchronized)每次行動前,帶個頭套就出去干了。而鋼鐵俠(lock)一定需要后期頻繁的給裝甲進行充電,保養等護理事宜。
等待是否可以被打斷
首先,synchronized是不可中斷的(網上常說的一個不怎么規范的說法)。除非拋出異常或者正常運行完成。
這里可能有些容易混淆。我們的線程Thread類不是提供了一個interrupt方法來中斷線程么?憑啥說synchronized是不可中斷的?
其實就是這個說法容易誤導我們,實際上是synchronized在阻塞狀態中是不可被打斷的。
我們知道,當多個線程去訪問同一個synchronized對象鎖資源時,一次只能有一個線程獲取到鎖,其他的線程此時就會進入阻塞狀態,直到搶到鎖資源的線程執行完畢時,這些阻塞中的線程才會被喚醒,去重新爭搶鎖。而這些正在阻塞中,尚未被喚醒的鎖資源,我們是無法將它們從阻塞中進行打斷的。
而lock可以中斷,也是針對這些等待鎖資源的線程而言的。中斷的方式有以下兩種:
這里就不拿漫威的例子來講了。我們的大人常說我們談戀愛時不要在一棵樹上吊死就是這個打斷機制的原理。synchronized鎖在愛上了一個女孩兒,但這個女孩兒嫁給了別的男人,于是它選擇終其一生去等待她。而lock鎖就比較聽大人的話,更加的靈活。知道等待一段時間無法讓心愛的女孩兒回心轉意,就放棄了這一棵小樹,回過頭來就能發現一片森林。
當然,并不是說lock就比synchronized好了。具體在程序中,用哪種鎖來處理問題,其實各有千秋。我們作為開發人員要根據不同的場景,選擇最適合處理這個業務場景的鎖。
公平與非公平的選擇權利不同
- synchronized:非公平鎖。
- lock:可以主動選擇公平和非公平。可以在其構造方法中進行設置。默認是非公平的(false),可以主動設置為公平的(true)。
這個繼續用漫威電影來講。蜘蛛俠從小出身貧苦,因此出去和大家吃飯只能搶著吃。而鋼鐵俠成天和蜘蛛俠混一起,體驗平民生活,因此也默認是搶著吃飯。但同時他也可以選擇去上流環境中,大家井然有序的排隊打飯。
當然,也可以看出,非公平(搶占式)鎖往往伴隨更高的效率(具體原因我們放在AQS中細講),而公平(非搶占式)鎖往往會降低效率,卻可以保證線程根據搶鎖的時間進行排序執行。
喚醒線程的粒度不同
- synchronized
不能精確喚醒線程。要么隨機喚醒一個線程;要么是喚醒所有等待的線程。 - Lock
用(condition)來實現分組喚醒需要喚醒的線程,可以精確的喚醒。
性能比較
JDK1.5(lock強于synchronized)
synchronized是托管給JVM執行的,而lock是java寫的控制鎖的代碼。在Java1.5中,synchronized的性能是低效的,因為這是一個重量級鎖,需要調用操作接口,導致了有可能加鎖消耗的系統時間比加鎖之外的操作還多,相比之下,使用Java提供的Lock對象,性能就會高一些。
JDK1.6以及之后(官方推薦synchronized)
到了Java1.6之后,發生了變化。synchronized在語義很清晰并進行了許多優化。有適應自旋,鎖消除,鎖粗化,輕量級鎖,偏向鎖等等,導致Java1.6中synchronized的性能并不比lock差。官方也表示,他們更支持使用synchronized,在未來的版本中還有優化余地。
因此,現在大家最常用的可能都是Java1.8或者更高級的版本了,lock現在可以被我們當做一個具有更多功能的一個鎖。當業務不需要這些功能實現的時候,我們就盡量的選擇synchronized來加鎖。
Lock的常用API
void lock();
拿不到鎖就不罷休,不然一直block(阻塞)。和synchronized一樣的效果。而之后的方法都可以看作是對synchronized鎖的增強
void lockInterruptibly();
可中斷地獲取鎖,和lock()方法的不同之處在于該方法會響應中斷,即在鎖在還未獲取到,阻塞等待中也可以中斷當前線程。
boolean tryLock();
立刻判斷是否能拿到鎖,拿到返回true,否則返回false。
比較灑脫,跟渣男一樣,和女孩說一句我愛你,要么在一起,要么就拜拜。
boolean tryLock(long time,TimeUnit unit);
超時獲取鎖,當線程在以下三種情況會返回:
void unlock();
釋放鎖
Lock的重要實現類
我們得Lock只是一個接口,要想具體干活兒,我們還得分析它旗下的子類們:
ReentrantLock(可重入鎖)
鎖的可重入
ReentrantLock翻譯過來為可重入鎖,它的可重入性表現在同一個線程可以多次獲得鎖,而不同線程依然不可多次獲得鎖,以線程作為鎖的分界線。最常見的場景就是遞歸,一個遞歸頻繁的調用一個帶鎖的方法,此時作為可重復鎖,對于該線程是可以重復的累積獲取鎖的。當然,在遞歸結束后,我們也需要再次循環的將鎖釋放依次釋放掉,以達到我們所說的效果。具體的流程我們依舊放在AQS中討論。
鎖的公平與非公平
ReentrantLock還分為公平鎖和非公平鎖(好家伙,這一個類占了倆名兒),公平鎖保證等待時間最長的線程將優先獲得鎖,而非公平鎖并不會保證多個線程獲得鎖的順序,但是非公平鎖的并發性能表現更好,至于性能的分析問題,我們還是放在AQS中去說。ReentrantLock默認使用非公平鎖。
ReentrantReadWriteLock(讀寫鎖)
之前提到的鎖(synchronized和ReentrantLock)都是排他鎖,這些鎖在同一時刻只運行一個線程進行訪問讀寫鎖維護了兩把鎖,一個讀鎖和一個寫鎖。通過分離讀鎖和寫鎖,使得并發性相比一般的排他鎖有了很大提升。
- 當讀線程搶到鎖:同一時刻可以允許多個讀線程訪問,并阻塞寫線程。
- 當寫線程搶到鎖:寫鎖就是一個排它鎖,所有讀線程和其他寫線程均被阻塞。
讀寫鎖比互斥鎖允許對于共享數據更大程度的并發。每次只能有一個寫線程,但是同時可以有多個線程并發地讀數據。ReadWriteLock適用于讀多寫少的并發情況。
讀寫鎖造成的寫線程饑餓
其實,博主一直認為讀寫鎖是一個很矛盾的鎖。因為他的設計原理(維護了兩把鎖),導致它的應用場景就是讀多寫少的場景。而讀多寫少的場景必然會造成另一個問題,就是寫線程饑餓。
什么是線程饑餓呢?我們來舉一個例子。
假設我們現在有A,B兩個線程讀,一個C線程寫。假設A線程在快執行完畢的時候,B線程繼續開始進行讀操作。當B線程快執行完畢時,A線程又繼續開始讀操作。我們發現,只要在所有讀線程結束前,任何一個新出現的讀線程就可以繼續維持讀鎖的使用權。這種概率伴隨著讀線程的增多而增大,因此,讀多寫少的情況下適合讀寫鎖是勿用質疑的,但是寫線程饑餓可能讓我們的讀線程長時間無法獲取到最新的數據。
那么,有什么方法可以解決我們的線程饑餓呢?
公平鎖處理線程饑餓
之前說到公平鎖的執行效率會比非公平鎖低下,也許讀者就會思考,那么有什么場景下會寧可犧牲效率,也要使用公平鎖呢?瞧,這里就是一個應用場景。
公平鎖的實現原理就是讓根據線程的訪問先后順序,對它們進行排序。再加上讀線程之間是不互斥的,因此讀線程過多的場景下并不會讓我們的維持的隊列堵塞,當遇到一個寫線程時,隊列才會堵塞,等待最后一個讀線程執行完畢后,就可以開始執行我們的寫線程了。這個優化就保證我們的寫線程的執行時效性問題。
但是公平鎖天然的就會比非公平鎖效率底下很多(具體原因在AQS中詳細說明),因此此策略是以犧牲系統吞吐量為代價的。
StampedLock處理線程饑餓(簡單理解)
StampedLock是Java8引入的一種新的所機制,簡單的理解,可以認為它是讀寫鎖的一個改進版本,它提供了一種樂觀的讀策略。
StampedLock 的樂觀讀允許一個寫線程獲取寫鎖,所以不會導致所有寫線程阻塞,也就是當讀多寫少的時候,寫線程有機會獲取寫鎖,減少了線程饑餓的問題,吞吐量大大提高。
這里可能你就會有疑問,竟然同時允許多個樂觀讀和一個先線程同時進入臨界資源操作,那讀取的數據可能是錯的怎么辦?
是的,樂觀讀不能保證讀取到的數據是最新的,所以將數據讀取到局部變量的時候需要通過 lock.validate(stamp) 這個標志位校驗是否被寫線程修改過,若是修改過則需要上悲觀讀鎖,再重新讀取數據到局部變量。可以看到其實就是一個CAS操作。
同時由于樂觀讀并不是鎖,所以沒有線程喚醒與阻塞導致的上下文切換,性能更好。
讀寫鎖之鎖降級(寫鎖可降級)
鎖降級是指把持住當前擁有的寫鎖的同時,再獲取到讀鎖,隨后釋放寫鎖的過程。
注意:如果當前線程擁有寫鎖,然后將其釋放,最后再獲取到讀鎖,這種分段完成的過程不能稱之為鎖降級。
很多人對這個概念都會忽略,即時略有了解鎖降級的朋友,也可能光知道上面的概念而已。本文讓你徹底悟透這個技術。
首先,寫鎖降級為讀鎖,并不是我們的寫鎖自帶的功能!!!重要的事兒說三遍,因為博主自己在這個點上誤解了很久。就像打游戲一樣,寫鎖降級完全是一個主動釋放技能,而不是人家寫鎖自帶的被動技能!那么寫鎖降級技術解決了什么問題呢?那就是為了防止臟讀,因此設置一個讀鎖在當前線程徹底執行結束前,阻塞其他線程獲取寫鎖修改數據。
純語言描述可能不易理解,我們來看一下下面的例子,理解臟讀產生的原因:
? ?? ???我們發現,只加寫鎖,并且作用域不夠大的話,就可能會出現我們描述的這種情況。對于對數據精準度較高的系統而言,這種情況就不大友好了。那么寫鎖降級是怎么解決這個臟讀的問題的呢?
首先先用代碼揭開一下鎖降級的真面目。
我們可以看到,所謂的鎖降級,并不是真正意義的降級,而是在保持寫鎖的過程中,可以繼續獲取讀鎖,當釋放寫鎖時,讀鎖依舊保持著,以達到"表面降級"的目的。
我們來看看鎖降級具體是如何操作的:
可以看到,實際上還是靈活運用了讀寫鎖的機制,讓其他寫線程一直阻塞到本線程結束后再繼續擁有爭奪鎖的資格,以解決臟讀的問題。
當然,這種解決方案會極大的影響業務的更新數據的時效性。
那你可能會想到,如果是可見性問題,那么voliate關鍵字是否可以解決可見性問題呢?這里千萬不能混淆,因為這里依舊會出現數據原子性的問題。如果本身不用降級會只會讓我們讀到舊版的數據,那么想用voliate替代鎖降級,直接會造成數據錯亂的問題。
鎖降級的好處
繞這么大半天,我們直接把鎖的作用域加大,不也可以保證數據的強一致性?但是用了鎖降級技術,可以在我們上述代碼等待10s的過程中不阻塞那些需要獲取讀鎖的線程。因此鎖降級技術是一個非常好用卻又容易被我們忽略的技術。
Lock的分組喚醒機制Condition
回憶 synchronized 關鍵字,它配合 Object 的 wait()、notify() 系列方法可以實現等待/通知模式。對于 Lock,通過 Condition 也可以實現等待/通知模式。Condition是在java 1.5中才出現的,它用來替代傳統的Object的wait()、notify()實現線程間的協作,相比使用Object的wait()、notify(),使用Condition的await()、signal()這種方式實現線程間協作更加安全和高效。因此通常來說比較推薦使用Condition,阻塞隊列實際上是使用了Condition來模擬線程間協作。
Condition的API介紹
Condition是個接口,基本的方法就是await()和signal()方法;
Condition依賴于Lock接口,生成一個Condition的基本代碼是lock.newCondition();
調用Condition的await()和signal()方法,都必須在lock保護之內,就是說必須在lock.lock()和lock.unlock之間才可以使用。下面先列舉三個最常用的方法:
- Conditon中的await()對應Object的wait();
- Condition中的signal()對應Object的notify();
- Condition中的signalAll()對應Object的notifyAll()。
同時,我們的根據我們的java線程的超時等待狀態,共提供了以下這些方法:
- await() :造成當前線程在接到信號或被中斷之前一直處于等待狀態。
- await(long time, TimeUnit unit) :造成當前線程在接到信號、被中斷或到達指定等待時間之前一直處于等待狀態
- awaitNanos(long nanosTimeout) :造成當前線程在接到信號、被中斷或到達指定等待時間之前一直處于等待狀態。返回值表示剩余時間,如果在nanosTimesout之前喚醒,那么返回值 = nanosTimeout - 消耗時間,如果返回值 <= 0 ,則可以認定它已經超時了。
- awaitUninterruptibly() :造成當前線程在接到信號之前一直處于等待狀態。【注意:該方法對中斷不敏感】。
- awaitUntil(Date deadline) :造成當前線程在接到信號、被中斷或到達指定最后期限之前一直處于等待狀態。如果沒有到指定時間就被通知,則返回true,否則表示到了指定時間,返回返回false。
- signal() :喚醒一個等待線程。該線程從等待方法返回前必須獲得與Condition相關的鎖。
- signal()All :喚醒所有等待線程。能夠從等待方法返回的線程必須獲得與Condition相關的鎖。
分組喚醒Condition如何高效
synchronized實現生產消費模式的弊端
我們來說說,我們直接用原本synchronized自帶的等待喚醒機制去實現生產消費模式存在什么弊端。
首先來看看一個生產者與消費者多對多的例子:
存值模板
取值模板
線程任務類
調用者
消費,生產線程各創建5個。
問題分析
稍微看一下我們的邏輯,可知每當消費者沒有數據就掛起,喚醒生產者。而生產者僅僅生產一個數據就進行掛起,喚醒消費者進行消費。但是僅僅這種簡單的單數據生產消費者模式,我們依舊需要使用notifyAll()喚醒所有線程,這是為什么?答案是:我們的存值線程與取值線程用的都是同一把鎖。如果僅僅喚醒一個線程,我們無法保證消費完了,下次喚醒的一定是生產者。生產者生產完了,下次喚醒的一定是消費者。
運氣不好的情況下,假設我們生產線程生產完畢,連續隨機又喚醒10次生產線程,那么此時這10次生產線程必定都是無法生產的,只能經過判斷重新掛起,直到消費線程搶占到鎖,程序才能繼續正常執行。兒這中間就進行了10次無意義的上下文切換,是非常低效的。
我們來看一下運行結果:
從運行結果中可知,我畫的紅線部分的線程都是隨機喚醒后,無法成功執行任務的線程。在while循環中判斷不符合執行條件后,重新進入等待狀態。這些步驟完全是浪費時間的。
Condition保證生產消費模式的高效
成員變量
存值模板
取值模板
線程任務類
調用者
消費,生產線程各創建5個。
結果分析
結果已經變得很井井有條了,固定的消費一個會生產一個,生產一個消費一個。
我們發現,我們將消費者和生產者進行了分流。在我們的產品固定只有一個(list.size()=1)的情況下,我們甚至可以使用signal()來隨機喚醒一個消費/生產線程來執行任務。
當然,如果我們產品數大于一個(list.size()>1)的情況下,我們依舊需要使用signalAll()來批量喚醒線程來搶占執行任務。但是在需要生產的情況下,依舊會排除掉所有的消費者。需要消費的時候也會排除掉所有的生產者,從而減小上下文切換的概率,以達到提升效率的目的。
一個鎖兩個Condition能否被兩把鎖替代
可能這個點是作者的奇思妙想。但在此處也提一下:
還是之前的例子。做以下修改:
成員遍變量
生成兩個鎖,并且分別持有一個消費者Condition和生產者Condition。
存值模板
注意,在生產者鎖范圍內,我加了一個消費者的Condition喚醒。
取值模板
與上面相反,在消費者鎖范圍內,我加了一個生產者的Condition喚醒。
線程任務類
調用者
依舊創建5個線程運行,我們來看看結果
結果分析
結果發生了異常,在網上查到了該異常的解釋:
拋出該異常表明某一線程已經試圖等待對象的監視器,或者試圖通知其他正在等待對象的監視器,然而本身沒有指定的監視器的線程。
也就是說,我們一把鎖內只能使用本鎖內的Condition。
更簡單靈活的等待喚醒工具類LockSupport
LockSupport是什么
LockSupport是一個編程工具類,主要是為了阻塞和喚醒線程用的。它所有的方法都是靜態方法,可以讓線程在任意位置阻塞,也可以在任意位置喚醒。
它的內部其實兩類主要的方法:park(停車阻塞線程)和unpark(啟動喚醒線程)。
注意上面的123方法,都有一個blocker,這個blocker是用來記錄線程被阻塞時被誰阻塞的。用于線程監控和分析工具來定位原因的。
現在我們知道了LockSupport是用來阻塞和喚醒線程的,而且之前相信我們都知道wait/notify也是用來阻塞和喚醒線程的,那么它相比,LockSupport有什么優點呢?
與wait/notify對比
wait/notify機制是基于鎖機制的等待喚醒。那么我們這個工具類能強大到什么地步呢?
上面的代碼中,MyThread線程中,不需要加鎖,便可以實現線程的中斷。而當其他線程需要對其進行喚醒時,僅僅需要將該線程對象作為參數,調用LockSupport.unpark(線程對象),即可讓程序繼續運行。
與wait/notfy的區別具體有以下兩點:
notify只能隨機選擇一個線程喚醒,無法喚醒指定的線程,unpark卻可以喚醒一個指定的線程
總結
以上是生活随笔為你收集整理的显式锁Lock的集大成之作,最细节教程的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 树莓派新手使用iobroker日志三(米
- 下一篇: 关于SYSTICK延时函数的两个小疑问