一、摘要
在上一篇文章中,我們講到了使用ReadWriteLock
可以解決多執行緒同時讀,但只有一個執行緒能寫的問題。
如果繼續深入的分析ReadWriteLock
,從鎖的角度分析,會發現它有一個潛在的問題:如果有執行緒正在讀資料,寫執行緒準備修改資料的時候,需要等待讀執行緒釋放鎖後才能獲取寫鎖,簡單的說就是,讀的過程中不允許寫,這其實是一種悲觀的讀鎖。
為了進一步的提升程式併發執行效率,Java 8 引入了一個新的讀寫鎖:StampedLock
。
與ReadWriteLock
相比,StampedLock
最大的改進點在於:在原先讀寫鎖的基礎上,新增了一種叫樂觀讀的模式。該模式並不會加鎖,因此不會阻塞執行緒,程式會有更高的執行效率。
什麼是樂觀鎖和悲觀鎖呢?
- 樂觀鎖:就是樂觀的估計讀的過程中大機率不會有寫入,因此被稱為樂觀鎖
- 悲觀鎖:指的是讀的過程中拒絕有寫入,也就是寫入必須等待
顯然樂觀鎖的併發執行效率會更高,但一旦有資料的寫入導致讀取的資料不一致,需要能檢測出來,再讀一遍就行。
下面我們一起來了解一下StampedLock
的用法!
二、StampedLock
StampedLock
的使用方式比較簡單,只需要例項化一個StampedLock
物件,然後呼叫對應的讀寫方法即可,它有三個核心方法如下!
readLock()
:表示讀鎖,多個執行緒讀不會阻塞,效果與ReadWriteLock
的讀鎖模式類似writeLock()
:表示寫鎖,同一時刻有且只有一個寫執行緒能獲取鎖資源,效果與ReadWriteLock
的寫鎖模式類似tryOptimisticRead()
:表示樂觀讀,並沒有加鎖,它用於非常短的讀操作,允許多個執行緒同時讀
其中readLock()
和writeLock()
方法,與ReadWriteLock
的效果完全一致,在此就不重複演示了。
下面我們來看一個tryOptimisticRead()
方法的簡單使用示例。
2.1、tryOptimisticRead 方法
public class CounterDemo {
private final StampedLock lock = new StampedLock();
private int count;
public void write() {
// 1.獲取寫鎖
long stamp = lock.writeLock();
try {
count++;
// 方便演示,休眠一下
sleep(200);
println("獲得了寫鎖,count:" + count);
} finally {
// 2.釋放寫鎖
lock.unlockWrite(stamp);
}
}
public int read() {
// 1.嘗試透過樂觀讀模式讀取資料,非阻塞
long stamp = lock.tryOptimisticRead();
// 2.假設x = 0,但是x可能被寫執行緒修改為1
int x = count;
// 方便演示,休眠一下
int millis = new Random().nextInt(500);
sleep(millis);
println("透過樂觀讀模式讀取資料,value:" + x + ", 耗時:" + millis);
// 3.檢查樂觀讀後是否有其他寫鎖發生
if(!lock.validate(stamp)){
// 4.如果有,採用悲觀讀鎖,並重新讀取資料到當前執行緒區域性變數
stamp = lock.readLock();
try {
x = count;
println("樂觀讀後檢查到資料發生變化,獲得了讀鎖,value:" + x);
} finally{
// 5.釋放悲觀讀鎖
lock.unlockRead(stamp);
}
}
// 6.返回讀取的資料
return x;
}
private void sleep(long millis){
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private void println(String message){
String time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS").format(new Date());
System.out.println(time + " 執行緒:" + Thread.currentThread().getName() + " " + message);
}
}
public class MyThreadTest {
public static void main(String[] args) throws InterruptedException {
CounterDemo counter = new CounterDemo();
Runnable readRunnable = new Runnable() {
@Override
public void run() {
counter.read();
}
};
Runnable writeRunnable = new Runnable() {
@Override
public void run() {
counter.write();
}
};
// 啟動3個讀執行緒
for (int i = 0; i < 3; i++) {
new Thread(readRunnable).start();
}
// 停頓一下
Thread.sleep(300);
// 啟動3個寫執行緒
for (int i = 0; i < 3; i++) {
new Thread(writeRunnable).start();
}
}
}
看一下執行結果:
2023-10-25 13:47:16:952 執行緒:Thread-0 透過樂觀讀模式讀取資料,value:0, 耗時:19
2023-10-25 13:47:17:050 執行緒:Thread-2 透過樂觀讀模式讀取資料,value:0, 耗時:172
2023-10-25 13:47:17:247 執行緒:Thread-1 透過樂觀讀模式讀取資料,value:0, 耗時:369
2023-10-25 13:47:17:382 執行緒:Thread-3 獲得了寫鎖,count:1
2023-10-25 13:47:17:586 執行緒:Thread-4 獲得了寫鎖,count:2
2023-10-25 13:47:17:788 執行緒:Thread-5 獲得了寫鎖,count:3
2023-10-25 13:47:17:788 執行緒:Thread-1 樂觀讀後檢查到資料發生變化,獲得了讀鎖,value:3
從日誌上可以分析得出,讀執行緒Thread-0
和Thread-2
在啟動寫執行緒之前就已經執行完,因此沒有進入競爭讀鎖階段;而讀執行緒Thread-1
因為在啟動寫執行緒之後才執行完,這個時候檢查到資料發生變化,因此進入讀鎖階段,保證讀取的資料是最新的。
和ReadWriteLock
相比,StampedLock
寫入資料的加鎖過程基本類似,不同的是讀取資料。
讀取資料大致的過程如下:
- 1.嘗試透過
tryOptimisticRead()
方法樂觀讀模式讀取資料,並返回版本號 - 2.資料讀取完成後,再透過
lock.validate()
去驗證版本號,如果在讀取過程中沒有寫入,版本號不會變,驗證成功,直接返回結果 - 3.如果在讀取過程中有寫入,版本號會發生變化,驗證將失敗。在失敗的時候,再透過悲觀讀鎖再次讀取資料,把讀取的最新結果返回
對於讀多寫少的場景,由於寫入的機率不高,程式在絕大部分情況下可以透過樂觀讀獲取資料,極少數情況下使用悲觀讀鎖獲取資料,併發執行效率得到了大大的提升。
樂觀鎖實際用途也非常廣泛,比如資料庫的欄位值修改,我們舉個簡單的例子。
在訂單庫存表上order_store
,我們通常會增加了一個數值型版本號欄位version
,每次更新order_store
這個表庫存資料的時候,都將version
欄位加1
,同時檢查version
的值是否滿足條件。
select id,... ,version
from order_store
where id = 1000
update order_store
set version = version + 1,...
where id = 1000 and version = 1
資料庫的樂觀鎖,就是查詢的時候將version
查出來,更新的時候利用version
欄位驗證是否一致,如果相等,說明資料沒有被修改,讀取的資料安全;如果不相等,說明資料已經被修改過,讀取的資料不安全,需要重新讀取。
這裡的version
就類似於StampedLock
的stamp
值。
2.2、tryConvertToWriteLock 方法
其次,StampedLock
還提供了將悲觀讀鎖升級為寫鎖的功能,對應的核心方法是tryConvertToWriteLock()
。
它主要使用在if-then-update
的場景,即:程式先採用讀模式,如果讀的資料滿足條件,就返回;如果讀的資料不滿足條件,再嘗試寫。
簡單示例如下:
public int readAndWrite(Integer newCount) {
// 1.獲取讀鎖,也可以使用樂觀讀
long stamp = lock.readLock();
int currentValue = count;
try {
// 2.檢查是否讀取資料
while (Objects.isNull(currentValue)) {
// 3.如果沒有,嘗試升級寫鎖
long wl = lock.tryConvertToWriteLock(stamp);
// 4.不為 0 升級寫鎖成功
if (wl != 0L) {
// 重新賦值
stamp = wl;
count = newCount;
currentValue = count;
break;
} else {
// 5.升級失敗,釋放之前加的讀鎖並上寫鎖,透過迴圈再試
lock.unlockRead(stamp);
stamp = lock.writeLock();
}
}
} finally {
// 6.釋放最後加的鎖
lock.unlock(stamp);
}
// 7.返回讀取的資料
return currentValue;
}
三、小結
總結下來,與ReadWriteLock
相比,StampedLock
進一步把讀鎖細分為樂觀讀和悲觀讀,能進一步提升了併發執行效率。
好處是非常明顯的,系統效能得到提升,但是代價也不小,主要有以下幾點:
- 1.程式碼邏輯更加複雜,如果程式設計不當很容易出 bug
- 2.
StampedLock
是不可重入鎖,不能在一個執行緒中反覆獲取同一個鎖,如果程式設計不當,很容易出現死鎖 - 3.如果執行緒阻塞在
StampedLock
的readLock()
或者writeLock()
方法上時,此時試圖透過interrupt()
方法中斷執行緒,會導致 CPU 飆升。因此,使用StampedLock
一定不要呼叫中斷操作,如果需要支援中斷功能,推薦使用可中斷的讀鎖readLockInterruptibly()
或者寫鎖writeLockInterruptibly()
方法。
最後,在實際的使用過程中,樂觀讀程式設計模型,推薦可以按照以下固定模板編寫。
public int read() {
// 1.嘗試透過樂觀讀模式讀取資料,非阻塞
long stamp = lock.tryOptimisticRead();
// 2.假設x = 0,但是x可能被寫執行緒修改為1
int x = count;
// 3.檢查樂觀讀後是否有其他寫鎖發生
if(!lock.validate(stamp)){
// 4.如果有,採用悲觀讀鎖,並重新讀取資料到當前執行緒區域性變數
stamp = lock.readLock();
try {
x = count;
} finally{
// 5.釋放悲觀讀鎖
lock.unlockRead(stamp);
}
}
// 6.返回讀取的資料
return x;
}
四、參考
1、https://www.liaoxuefeng.com/wiki/1252599548343744/1309138673991714
2、https://zhuanlan.zhihu.com/p/257868603
五、寫到最後
最近無意間獲得一份阿里大佬寫的技術筆記,內容涵蓋 Spring、Spring Boot/Cloud、Dubbo、JVM、集合、多執行緒、JPA、MyBatis、MySQL 等技術知識。需要的小夥伴可以點選如下連結獲取,資源地址:技術資料筆記。