讀寫鎖與ReentrantLock對比
普通的 ReentrantLock可以我們保證了線程安全,但是也浪費了一定的資源,因為如果多個讀操作同時進行,其實並沒有線程安全問題,我們可以允許讓多個讀操作並行,以便提高程序效率。
但是寫操作不是線程安全的,如果多個線程同時寫,或者在寫的同時進行讀操作,便會造成線程安全問題。
我們的讀寫鎖就解決了這樣的問題,它設定了一套規則,既可以保證多個線程同時讀的效率,同時又可以保證有寫入操作時的線程安全。
整體思路是它有兩把鎖,第 1 把鎖是寫鎖,獲得寫鎖之后,既可以讀數據又可以修改數據,而第 2 把鎖是讀鎖,獲得讀鎖之后,只能查看數據,不能修改數據。讀鎖可以被多個線程同時持有,所以多個線程可以同時查看數據。
在讀的地方合理使用讀鎖,在寫的地方合理使用寫鎖,靈活控制,可以提高程序的執行效率。
讀寫鎖的獲取規則
使用讀寫鎖時遵守下面的獲取規則:
- 當一個線程已經占有了讀鎖,那么其他線程如果想要申請讀鎖,可以申請成功;
- 當一個線程已經占有了讀鎖,而且有其他線程想要申請獲取寫鎖的話,是不能申請成功的,因為讀寫互斥;
- 當一個線程已經占有了寫鎖,那么此時其他線程無論是想申請讀鎖還是寫鎖,都無法申請成功。
所以我們用一句話總結:要么是一個或多個線程同時有讀鎖,要么是一個線程有寫鎖,但是兩者不會同時出現。也可以總結為:讀讀共享、其他都互斥(寫寫互斥、讀寫互斥、寫讀互斥)。
使用案例
相比於 ReentrantLock 適用於一般場合,ReadWriteLock 適用於讀多寫少的情況,合理使用可以進一步提高並發效率。
示例
public class ReadWriteLockDemo {
private static final ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(
false);
private static final ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock
.readLock();
private static final ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock
.writeLock();
private static void read() {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到讀鎖,正在讀取");
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "釋放讀鎖");
readLock.unlock();
}
}
private static void write() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到寫鎖,正在寫入");
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "釋放寫鎖");
writeLock.unlock();
}
}
public static void main(String[] args) {
new Thread(() -> read()).start();
new Thread(() -> read()).start();
new Thread(() -> write()).start();
new Thread(() -> write()).start();
}
}
輸出
Thread-0得到讀鎖,正在讀取
Thread-1得到讀鎖,正在讀取
Thread-1釋放讀鎖
Thread-0釋放讀鎖
Thread-2得到寫鎖,正在寫入
Thread-2釋放寫鎖
Thread-3得到寫鎖,正在寫入
Thread-3釋放寫鎖
讀鎖插隊策略
公平與非公平鎖中講到的 ReentrantLock,如果鎖被設置為非公平,那么它是可以在前面線程釋放鎖的瞬間進行插隊的,而不需要進行排隊
public ReentrantReadWriteLock() {
this(false);
}
/**
* Creates a new {@code ReentrantReadWriteLock} with
* the given fairness policy.
*
* @param fair {@code true} if this lock should use a fair ordering policy
*/
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
讀寫鎖與ReentrantLock一樣都可以通過參數設置公平非公平鎖,默認非公平鎖
公平鎖
static final class FairSync extends Sync {
private static final long serialVersionUID = -2274990926593161451L;
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
}
final boolean readerShouldBlock() {
return hasQueuedPredecessors();
}
}
在公平鎖的情況下,只要等待隊列中有線程在等待,也就是 hasQueuedPredecessors() 返回 true 的時候,那么 writer 和 reader 都會 block,也就是一律不允許插隊,都乖乖去排隊,這也符合公平鎖的思想。
非公平
static final class NonfairSync extends Sync {
private static final long serialVersionUID = -8159625535654395037L;
final boolean writerShouldBlock() {
return false; // writers can always barge
}
final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive();
}
}
在 writerShouldBlock() 這個方法中始終返回 false,可以看出,對於想獲取寫鎖的線程而言,由於返回值是 false,所以它是隨時可以插隊的,這就和我們的 ReentrantLock 的設計思想是一樣的,但是讀鎖卻不一樣。這里實現的策略很有意思,
假設線程 2 和線程 4 正在同時讀取,線程 3 想要寫入,但是由於線程 2 和線程 4 已經持有讀鎖了,所以線程 3 就進入等待隊列進行等待。此時,線程 5 突然跑過來想要插隊獲取讀鎖
允許插隊策略
由於現在有線程在讀,而線程 5 又不會特別增加它們讀的負擔,因為線程們可以共用這把鎖,所以第一種策略就是讓線程 5 直接加入到線程 2 和線程 4 一起去讀取。
這種策略看上去增加了效率,但是有一個嚴重的問題,那就是如果想要讀取的線程不停地增加,比如線程 6,那么線程 6 也可以插隊,這就會導致讀鎖長時間內不會被釋放,導致線程 3 長時間內拿不到寫鎖,也就是那個需要拿到寫鎖的線程會陷入“飢餓”狀態,它將在長時間內得不到執行。
不允許插隊策略
這種策略認為由於線程 3 已經提前等待了,所以雖然線程 5 如果直接插隊成功,可以提高效率,但是我們依然讓線程 5 去排隊等待:
按照這種策略線程 5 會被放入等待隊列中,並且排在線程 3 的后面,讓線程 3 優先於線程 5 執行,這樣可以避免“飢餓”狀態,這對於程序的健壯性是很有好處的,直到線程 3 運行完畢,線程 5 才有機會運行,這樣誰都不會等待太久的時間。
所以我們可以看出,即便是非公平鎖,只要等待隊列的頭結點是嘗試獲取寫鎖的線程,那么讀鎖依然是不能插隊的,目的是避免“飢餓”。
策略選擇演示
策略的選擇取決於具體鎖的實現,ReentrantReadWriteLock 的實現選擇了策略 2 ,是很明智的。
下面我們就用實際的代碼來演示一下上面這種場景。
public class ReadLockJumpQue {
private static final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private static final ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
private static final ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
private static void read() {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到讀鎖,正在讀取");
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "釋放讀鎖");
readLock.unlock();
}
}
private static void write() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到寫鎖,正在寫入");
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "釋放寫鎖");
writeLock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
new Thread(() -> read(),"Thread-2").start();
Thread.sleep(100);
new Thread(() -> read(),"Thread-4").start();
Thread.sleep(100);
new Thread(() -> write(),"Thread-3").start();
Thread.sleep(100);
new Thread(() -> read(),"Thread-5").start();
Thread.sleep(100);
}
}
總結
ReentrantLock和ReentrantReadWriteLock都有公平鎖和非公平鎖,讀寫鎖對於寫鎖不允許讀鎖插隊,有效避免飢餓
鎖的升降級
讀寫鎖降級功能代碼演示
下面我們再來看一下鎖的升降級,首先我們看一下這段代碼,這段代碼演示了在更新緩存的時候,如何利用鎖的降級功能。
public class CacheData {
Object data;
volatile boolean cacheValid;
final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
void processCachedData() {
rwl.readLock().lock();
if (!cacheValid) {
//在獲取寫鎖之前,必須首先釋放讀鎖。
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
//這里需要再次判斷數據的有效性,因為在我們釋放讀鎖和獲取寫鎖的空隙之內,可能有其他線程修改了數據。
if (!cacheValid) {
data = new Object();
cacheValid = true;
}
//在不釋放寫鎖的情況下,直接獲取讀鎖,這就是讀寫鎖的降級。
rwl.readLock().lock();
} finally {
//釋放了寫鎖,但是依然持有讀鎖
rwl.writeLock().unlock();
}
}
try {
System.out.println(data);
} finally {
//釋放讀鎖
rwl.readLock().unlock();
}
}
}
在這段代碼中有一個讀寫鎖,最重要的就是中間的 processCachedData 方法,在這個方法中,會首先獲取到讀鎖,也就是rwl.readLock().lock(),它去判斷當前的緩存是否有效,如果有效那么就直接跳過整個 if 語句,如果已經失效,代表我們需要更新這個緩存了。由於我們需要更新緩存,所以之前獲取到的讀鎖是不夠用的,我們需要獲取寫鎖。
在獲取寫鎖之前,我們首先釋放讀鎖,然后利用 rwl.writeLock().lock() 來獲取到寫鎖,然后是經典的 try finally 語句,在 try 語句中我們首先判斷緩存是否有效,因為在剛才釋放讀鎖和獲取寫鎖的過程中,可能有其他線程搶先修改了數據,所以在此我們需要進行二次判斷。
如果我們發現緩存是無效的,就用 new Object() 這樣的方式來示意,獲取到了新的數據內容,並把緩存的標記位設置為 ture,讓緩存變得有效。由於我們后續希望打印出 data 的值,所以不能在此處釋放掉所有的鎖。我們的選擇是在不釋放寫鎖的情況下直接獲取讀鎖,也就是rwl.readLock().lock() 這行語句所做的事情,然后,在持有讀鎖的情況下釋放寫鎖,最后,在最下面的 try 中把 data 的值打印出來。
這就是一個非常典型的利用鎖的降級功能的代碼。
為什么需要鎖降級
如果我們在剛才的方法中,一直使用寫鎖,最后才釋放寫鎖的話,雖然確實是線程安全的,但是也是沒有必要的,因為我們只有一處修改數據的代碼
data = new Object();
后面我們對於 data 僅僅是讀取。如果還一直使用寫鎖的話,就不能讓多個線程同時來讀取了,持有寫鎖是浪費資源的,降低了整體的效率,所以這個時候利用鎖的降級是很好的辦法,可以提高整體性能。
支持鎖的降級,不支持升級
final static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
public static void main(String[] args) {
upgrade();
}
public static void upgrade() {
rwl.readLock().lock();
System.out.println("獲取到了讀鎖");
rwl.writeLock().lock();
System.out.println("成功升級");
}
這段代碼會打印出“獲取到了讀鎖”,但是卻不會打印出“成功升級”,因為 ReentrantReadWriteLock 不支持讀鎖升級到寫鎖。
為什么不支持鎖的升級?
讀鎖,是可以多個線程同時持有的,寫鎖,只能有一個線程持有,並且不可能存在讀鎖和寫鎖同時持有的情況
正是因為不可能有讀鎖和寫鎖同時持有的情況,所以升級寫鎖的過程中,需要等到所有的讀鎖都釋放,此時才能進行升級
假設有 A,B 和 C 三個線程,它們都已持有讀鎖。假設線程 A 嘗試從讀鎖升級到寫鎖。那么它必須等待 B 和 C 釋放掉已經獲取到的讀鎖。如果隨着時間推移,B 和 C 逐漸釋放了它們的讀鎖,此時線程 A 確實是可以成功升級並獲取寫鎖。
但是我們考慮一種特殊情況。假設線程 A 和 B 都想升級到寫鎖,那么對於線程 A 而言,它需要等待其他所有線程,包括線程 B 在內釋放讀鎖。而線程 B 也需要等待所有的線程,包括線程 A 釋放讀鎖。這就是一種非常典型的死鎖的情況。誰都願不願意率先釋放掉自己手中的鎖。
但是讀寫鎖的升級並不是不可能的,也有可以實現的方案,如果我們保證每次只有一個線程可以升級,那么就可以保證線程安全。只不過最常見的 ReentrantReadWriteLock 對此並不支持。
總結
ReentrantReadWriteLock 而言。
- 插隊策略
- 公平策略下,只要隊列里有線程已經在排隊,就不允許插隊。
- 非公平策略下:
- 如果允許讀鎖插隊,那么由於讀鎖可以同時被多個線程持有,所以可能造成源源不斷的后面的線程一直插隊成功,導致讀鎖一直不能完全釋放,從而導致寫鎖一直等待,為了防止“飢餓”,在等待隊列的頭結點是嘗試獲取寫鎖的線程的時候,不允許讀鎖插隊。
- 寫鎖可以隨時插隊,因為寫鎖並不容易插隊成功,寫鎖只有在當前沒有任何其他線程持有讀鎖和寫鎖的時候,才能插隊成功,同時寫鎖一旦插隊失敗就會進入等待隊列,所以很難造成“飢餓”的情況,允許寫鎖插隊是為了提高效率。
- 升降級策略:只能從寫鎖降級為讀鎖,不能從讀鎖升級為寫鎖。
java學習交流q群: 513650703