Java多執行緒併發程式設計/鎖的理解
一.前言
最近專案遇到多執行緒併發的情景(併發搶單&恢復庫存並行),程式碼在正常情況下執行沒有什麼問題,在高併發壓測下會出現:庫存超發/總庫存與sku庫存對不上等各種問題。
在運用了 限流/加鎖等方案後,問題得到解決。
限流方案見本人另一篇部落格:Guava-RateLimiter實現令牌桶限流
加鎖方案見下文。
二.樂觀鎖 & 悲觀鎖
1.樂觀鎖
顧名思義,就是很樂觀,每次去拿資料的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個資料,可以使用版本號(version)等機制。
例如:mybatis-plus 自帶外掛OptimisticLockerInterceptor,在資料庫表加上一個version欄位,每次更新完資料庫mp會自動在version欄位上加1,如果在更新提交的時候發現version欄位的值與資料庫中最新的值不一致,則提交失敗。
2.悲觀鎖
悲觀鎖總是假設會出現最壞的情況,每次去拿資料的時候都認為別人會修改,所以每次在拿資料的時候都會上鎖,這樣別人想拿這個資料就會阻塞直到它拿到鎖。
傳統的MySQL關係型資料庫裡邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。
Java的同步synchronized關鍵字的實現就是典型的悲觀鎖。
3.總結
悲觀鎖適合寫操作多的場景,先加鎖可以保證寫操作時資料正確。
樂觀鎖適合讀操作多的場景,不加鎖的特點能夠使其讀操作的效能大幅提升。
三.獨佔鎖 & 共享鎖
1.獨佔鎖
是指該鎖一次只能被一個執行緒所持有。
例如:Synchronized和ReentrantLock,它們同時也是悲觀鎖
2.共享鎖
是指該鎖可被多個執行緒所持有,允許多個執行緒同時去獲取。
例如:Semaphore和ReadWriteLock和countdownlatch,其讀鎖是共享鎖,寫鎖是獨享鎖。
3.總結
讀鎖的共享鎖可保證併發讀是非常高效的,讀寫,寫讀 ,寫寫的過程是互斥的。
獨享鎖與共享鎖也是通過AQS來實現的,通過實現不同的方法,來實現獨享或者共享。
四.公平鎖 & 非公平鎖
1.公平鎖
加鎖前先檢視是否有排隊等待的執行緒,有的話優先處理排在前面的執行緒,先來先得。
2.非公平鎖
執行緒加鎖時直接嘗試獲取鎖,獲取不到就自動到隊尾等待。
3.總結
更多的是直接使用非公平鎖:非公平鎖比公平鎖效能高5-10倍,因為公平鎖需要在多核情況下維護一個佇列,如果當前執行緒不是佇列的第一個無法獲取鎖,增加了執行緒切換次數。
五.java執行緒鎖
由於多個執行緒是共同佔有所屬程序的資源和地址空間的,那麼就會存在一個問題:如果多個執行緒要同時訪問某個資源,怎麼處理?
在Java併發程式設計中,經常遇到多個執行緒訪問同一個共享資源 ,這時候作為開發者必須考慮如何維護資料一致性,這就是Java鎖機制(同步問題)的來源。
Java提供了多種多執行緒鎖機制的實現方式,常見的有:
-
- synchronized
- ReentrantLock
- Semaphore
- AtomicInteger等
每種機制都有優缺點與各自的適用場景,必須熟練掌握他們的特點才能在Java多執行緒應用開發時得心應手。
1.Synchronized
在Java中synchronized關鍵字被常用於維護資料一致性。synchronized機制是給共享資源上鎖,只有拿到鎖的執行緒才可以訪問共享資源,這樣就可以強制使得對共享資源的訪問都是順序的。
Java開發人員都認識synchronized,使用它來實現多執行緒的同步操作是非常簡單的,只要在需要同步的對方的方法、類或程式碼塊中加入該關鍵字,它能夠保證在同一個時刻最多隻有一個執行緒執行同一個物件的同步程式碼,可保證修飾的程式碼在執行過程中不會被其他執行緒干擾。使用synchronized修飾的程式碼具有原子性和可見性,在需要程序同步的程式中使用的頻率非常高,可以滿足一般的程序同步要求。
-
synchronized (obj) { //方法 ……. }
synchronized實現的機理依賴於軟體層面上的JVM,因此其效能會隨著Java版本的不斷升級而提高。
-
到了Java1.6,synchronized進行了很多的優化,有適應自旋、鎖消除、鎖粗化、輕量級鎖及偏向鎖等,效率有了本質上的提高。在之後推出的Java1.7與1.8中,均對該關鍵字的實現機理做了優化。
-
需要說明的是,當執行緒通過synchronized等待鎖時是不能被Thread.interrupt()中斷的,因此程式設計時必須檢查確保合理,否則可能會造成執行緒死鎖的尷尬境地。
-
最後,儘管Java實現的鎖機制有很多種,並且有些鎖機制效能也比synchronized高,但還是強烈推薦在多執行緒應用程式中使用該關鍵字,因為實現方便,後續工作由JVM來完成,可靠性高。只有在確定鎖機制是當前多執行緒程式的效能瓶頸時,才考慮使用其他機制,如ReentrantLock等。
-
總結:在資源競爭不是很激烈的情況下,偶爾會有同步的情形下,synchronized是很合適的。原因在於,編譯程式通常會盡可能的進行優化synchronize,另外可讀性非常好。
-
2.ReentrantLock
可重入鎖,顧名思義,這個鎖可以被執行緒多次重複進入進行獲取操作。
ReentantLock繼承介面Lock並實現了介面中定義的方法,除了能完成synchronized所能完成的所有工作外,還提供了諸如可響應中斷鎖、可輪詢鎖請求、定時鎖等避免多執行緒死鎖的法。
Lock實現的機理依賴於特殊的CPU指定,可以認為不受JVM的約束,並可以通過其他語言平臺來完成底層的實現。在併發量較小的多執行緒應用程式中,ReentrantLock與synchronized效能相差無幾,但在高併發量的條件下,synchronized效能會迅速下降幾十倍,而ReentrantLock的效能卻能依然維持一個水準。
因此我們建議在高併發量情況下使用ReentrantLock。
ReentrantLock引入兩個概念:公平鎖與非公平鎖。
公平鎖指的是鎖的分配機制是公平的,通常先對鎖提出獲取請求的執行緒會先被分配到鎖。反之,JVM按隨機、就近原則分配鎖的機制則稱為不公平鎖。
ReentrantLock在建構函式中提供了是否公平鎖的初始化方式,預設為非公平鎖。這是因為,非公平鎖實際執行的效率要遠遠超出公平鎖,除非程式有特殊需要,否則最常用非公平鎖的分配機制。
ReentrantLock通過方法lock()與unlock()來進行加鎖與解鎖操作,與synchronized會被JVM自動解鎖機制不同,ReentrantLock加鎖後需要手動進行解鎖。為了避免程式出現異常而無法正常解鎖的情況,使用ReentrantLock必須在finally控制塊中進行解鎖操作。通常使用方式如下所示:
-
/** * 初始化一個非公平鎖(該鎖只適用於單例項) */ private static Lock lock = new ReentrantLock(false); void test() { try { lock.lock(); //...執行業務邏輯 }finally { lock.unlock(); } }
- 總結:在資源競爭不激烈的情形下,效能稍微比synchronized差點點。但是當同步非常激烈的時候,synchronized的效能一下子能下降好幾十倍,而ReentrantLock確還能維持常態。高併發量情況下使用ReentrantLock。
- 注意:Spring @Transactional註解和ReentrantLock同步鎖同時使用不能同步的問題
-
3.Semaphore (訊號量)
上述兩種鎖機制型別都是“互斥鎖”,互斥是程序同步關係的一種特殊情況,相當於只存在一個臨界資源,因此同時最多隻能給一個執行緒提供服務。但是,在實際複雜的多執行緒應用程式中,可能存在多個臨界資源,這時候我們可以藉助Semaphore訊號量來完成多個臨界資源的訪問。
Semaphore基本能完成ReentrantLock的所有工作,使用方法也與之類似,通過acquire()與release()方法來獲得和釋放臨界資源。
經實測,Semaphone.acquire()方法預設為可響應中斷鎖,與ReentrantLock.lockInterruptibly()作用效果一致,也就是說在等待臨界資源的過程中可以被Thread.interrupt()方法中斷。
此外,Semaphore也實現了可輪詢的鎖請求與定時鎖的功能,除了方法名tryAcquire與tryLock不同,其使用方法與ReentrantLock幾乎一致。Semaphore也提供了公平與非公平鎖的機制,也可在建構函式中進行設定。
Semaphore的鎖釋放操作也由手動進行,因此與ReentrantLock一樣,為避免執行緒因丟擲異常而無法正常釋放鎖的情況發生,釋放鎖的操作也必須在finally程式碼塊中完成。
/** 定義一個非公平的共享鎖 */ public static Semaphore LOCK = new Semaphore(5, false); void test() { try{ //獲取許可 LOCK.acquire(); } finally { //釋放許可 LOCK.release(); } }
總結:Semaphore有著非常強大的功能,並且是共享鎖,在特殊情景時非常有效。