Java並發之搞懂讀寫鎖
ReentrantReadWriteLock
我們來探討一下java.concurrent.util包下的另一個鎖,叫做ReentrantReadWriteLock,也叫讀寫鎖。
實際項目中常常有這樣一種場景:
比如有一個共享資源叫做Some Data,多個線程去操作Some Data,這個操作有讀操作也有寫操作,並且是讀多寫少的,那麼在沒有寫操作的時候,多個線程去讀Some Data是不會有線程安全問題的,因為線程隻是訪問,並沒有修改,不存在競爭,所以這種情況應該允許多個線程同時讀取Some Data。
但是若某個瞬間,線程X正在修改Some Data的時候,那麼就不允許其他線程對Some Data做任何操作,否則就會有線程安全問題。
那麼針對這種讀多寫少的場景,J.U.C包提供瞭ReentrantReadWriteLock,它包含瞭兩個鎖:
- ReadLock:讀鎖,也被稱為共享鎖
- WriteLock:寫鎖,也被稱為排它鎖
下面我們看看,線程如果想獲取讀鎖,需要具備哪些條件:
- 不能有其他線程的寫鎖沒有寫請求;
- 或者有寫請求,但調用線程和持有鎖的線程是同一個
再來看一下線程獲取寫鎖的條件:
- 必須沒有其他線程的讀鎖
- 必須沒有其他線程的寫鎖
這個比較容易理解,因為寫鎖是排他的。
來看下面一段代碼:
public class ReentrantReadWriteLockTest { private Object data; //緩存是否有效 private volatile boolean cacheValid; private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); public void processCachedData() { rwl.readLock().lock(); //如果緩存無效,更新cache;否則直接使用data if (!cacheValid) { //獲取寫鎖前必須釋放讀鎖 rwl.readLock().unlock(); rwl.writeLock().lock(); if (!cacheValid) { //更新數據 data = new Object(); cacheValid = true; } //鎖降級,在釋放寫鎖前獲取讀鎖 rwl.readLock().lock(); //釋放寫鎖,依然持有讀鎖 rwl.writeLock().unlock(); } // 使用緩存 // ... // 釋放讀鎖 rwl.readLock().unlock(); } }
這段代碼演示的是獲取緩存的時候,判斷緩存是否過期,如果已經過期就更新緩存,如果沒有過期就使用緩存。
可以看到我們先創建瞭一個讀鎖,判斷如果緩存有效,就可以使用緩存,使用完之後再把讀鎖釋放。如果緩存無效,就更新緩存執行寫操作,所以先把讀鎖給釋放掉,然後創建一個寫鎖,最後更新緩存,更新完緩存後又重新獲取瞭一個讀鎖並釋放掉寫鎖。
從這段代碼裡可以看出來,一個線程在拿到寫鎖之後它還可以繼續獲得一個讀鎖。
小結
我們來總結一下ReentrantReadWriteLock的三個特性:
- 公平性
ReentrantReadWriteLock也可以在初始化時設置是否公平。
- 可重入性
讀鎖以及寫鎖也是支持重入的,比如一個線程拿到寫鎖後,他依然可以繼續拿寫鎖,同理讀鎖也可以。
- 鎖降級
要想實現鎖降級,隻需要先獲得寫鎖,再獲得讀鎖,最後釋放寫鎖,就可以把一個寫鎖降級為讀鎖瞭。但是一個讀鎖是沒有辦法升級為寫鎖的。
最後我們來對比一下ReentrantLock與ReentrantReadWriteLock
ReentrantLock
:完全互斥ReentrantReadWriteLock
:讀鎖共享,寫鎖互斥
因此在讀多寫少的場景下,ReentrantReadWriteLock的性能、吞吐量各方面都會比ReentrantLock要好很多。但是對於寫多的場景ReentrantReadWriteLock就不那麼明顯瞭。
StampedLock
上面我們已經探討瞭ReentrantReadWriteLock能夠大幅度提升讀多寫少場景下的性能,StampedLock是在JDK8引入的,可以認為這是一個ReentrantReadWriteLock的增強版。
那麼大傢想,既然有瞭ReentrantReadWriteLock,為什麼還要搞一個StampedLock呢?
這是因為ReentrantReadWriteLock在一些特定的場景下存在問題。
比如寫線程的“饑餓”問題。
舉個例子:假設現在有超級多的線程在操作ReentrantReadWriteLock,執行讀操作的線程超級多,而執行寫操作的線程很少,而如果這個執行寫操作的線程想要拿到寫鎖,而ReentrantReadWriteLock的寫鎖是排他的,要想拿到寫鎖就意味著其他線程不能有讀鎖也不能有寫鎖,所以在讀線程超級多,寫線程超級少的情況下就容易造成寫線程饑餓問題,也就是說,執行寫操作的線程可能一直搶不到鎖,即使可以把公平性設置為true,但是這樣又會導致性能的下降。
那麼我們看看StampedLock怎麼玩:
首先,所有獲取鎖的方法都會返回stamp,它是一個數字,如果stamp=0說明操作失敗瞭,其他的值表示操作成功。
其次就是所有獲取鎖的方法,需要用stamp作為參數,參數的值必須和獲得鎖時返回的stamp一致。
其中StampedLock提供瞭三種訪問模式:
Writing模式
:類似於ReentrantReadWriteLock的寫鎖Reding(
悲觀讀模式):類似於ReentrantReadWriteLock的讀鎖。Optimistic reading
:樂觀讀模式
悲觀讀模式:在執行悲觀讀的過程中,不允許有寫操作
樂觀讀模式:在執行樂觀讀的過程中,允許有寫操作
通過介紹我們可以發現,StampedLock中的悲觀讀與樂觀讀和我們操作數據庫中的悲觀鎖、樂觀鎖有一定的相似之處。
此外StampedLock還提供瞭讀鎖和寫鎖相互轉換的功能:
我們知道ReentrantReadWriteLock的寫鎖是可以降級為讀鎖的,但是讀鎖沒辦法升級為寫鎖,而StampedLock它提供瞭讀鎖和寫鎖之間互相轉換的功能。
最後,StampedLock是不可重入的,這也是和ReentrantReadWriteLock的一個區別。
讀過源碼的同學可能知道,在StampedLock源碼裡有一段註釋:
我們來看一下這段註釋,他寫的非常經典,演示瞭StampedLock API如何使用。
class Point { private double x, y; private final StampedLock sl = new StampedLock(); void move(double deltaX, double deltaY) { // an exclusively locked method //添加寫鎖 long stamp = sl.writeLock(); try { x += deltaX; y += deltaY; } finally { //釋放寫鎖 sl.unlockWrite(stamp); } } double distanceFromOrigin() { // A read-only method //獲得一個樂觀鎖 long stamp = sl.tryOptimisticRead(); // 假設(x,y)=(10,10) // 但是這是一個樂觀讀鎖,(x,y)可能被其他線程修改為(20,20) double currentX = x, currentY = y; //因此這裡要驗證獲得樂觀鎖後,有沒有發生寫操作 if (!sl.validate(stamp)) { stamp = sl.readLock(); try { currentX = x; currentY = y; } finally { sl.unlockRead(stamp); } } return Math.sqrt(currentX currentX + currentY currentY); } void moveIfAtOrigin(double newX, double newY) { // upgrade // Could instead start with optimistic, not read mode long stamp = sl.readLock(); try { while (x == 0.0 && y == 0.0) { long ws = sl.tryConvertToWriteLock(stamp); if (ws != 0L) { stamp = ws; x = newX; y = newY; break; } else { sl.unlockRead(stamp); stamp = sl.writeLock(); } } } finally { sl.unlock(stamp); } } }
這個類有三個方法,move方法用來移動一個點的坐標,instanceFromOrigin用來計算這個點到原點的距離,moveIfAtOrigin表示當這個點位於原點的時候用來移動這個點的坐標。
我們來分析一下源碼:
move方法是一個純粹的寫操作,在操作之前添加寫鎖,操作結束釋放寫鎖;
instanceOrigin首先獲得一個樂觀鎖,然後開始讀數據,我們假設(x,y)=(10,10),但是這是一個樂觀讀鎖,(x,y)可能被其他線程修改為(20,20),所以他會驗證獲得樂觀鎖後,有沒有發生寫操作,如果validate結果為true的話,表示沒有發生過寫操作,如果發生過寫操作,那麼就會改用悲觀讀鎖重讀數據,然後計算結果,當然最後要把鎖釋放掉。
最後moveIfAtOrigin方法也比較簡單,主要演示瞭怎麼從悲觀讀鎖轉換成寫鎖。
小結
StampedLock主要通過樂觀讀的方式提升性能,同時也解決瞭寫線程的饑餓問題,但是有得必有失,我們從示例代碼中不難看出,StampedLock使用起來要比ReentrantReadWriteLock復雜很多,所以使用者要在性能和復雜度之間做一個取舍。
總結
本篇文章就到這裡瞭,希望能夠給你帶來幫助,也希望您能夠多多關註WalkonNet的更多內容!
推薦閱讀:
- 一文瞭解Java讀寫鎖ReentrantReadWriteLock的使用
- java並發編程中ReentrantLock可重入讀寫鎖
- Java 讀寫鎖源碼分析
- ReentrantReadWriteLock不能鎖升級的原因總結
- 詳解java中各類鎖的機制