一、寫在開頭
我們在上一篇寫ReentrantReadWriteLock讀寫鎖的末尾留了一個小坑,那就是讀寫鎖因為寫鎖的悲觀性,會導致 “寫飢餓”,這樣一來會大大的降低讀寫效率,而今天我們就來將此坑填之!填坑工具為:StampedLock,一個素有Java鎖王稱號的同步類,也是在 java.util.concurrent.locks
包中。
需要宣告的是,這個類在Java的面試過程中極少被問及,如果僅僅是為了準備面試的話,這部分內容可以忽略,但這個類的實現邏輯還是值得一學的。
二、StampedLock 是什麼?
StampedLock是由Java8時引入的一個效能更好的讀寫鎖,作者:Doug Lea,支援讀鎖、寫鎖,這與ReentrantReadWriteLock類似,但同時多了一個樂觀讀鎖的實現,這一點直接提升了它的效能。
三、StampedLock的原理
雖然StampedLock效能更好,但是!不可重入且不支援條件變數 Condition,且並沒有直接實現Lock或者ReadWriteLock介面,而是與AQS類似的採用CLH(Craig, Landin, and Hagersten locks)作為底層實現。
在Java的官方docs中對於它進行了如下的描述:
並且官方還提供了一個示例,我們來看一下:
class Point {
//共享變數
private double x, y;
private final StampedLock sl = new StampedLock();
// 寫鎖的使用
void move(double deltaX, double deltaY) {
long stamp = sl.writeLock(); //涉及對共享資源的修改,使用寫鎖-獨佔操作
try {
x += deltaX;
y += deltaY;
} finally {
sl.unlockWrite(stamp); // 釋放寫鎖
}
}
/**
* 使用樂觀讀鎖訪問共享資源
* 注意:樂觀讀鎖在保證資料一致性上需要複製一份要操作的變數到方法棧,並且在運算元據時候 可能其他寫執行緒已經修改了資料,
* 而我們操作的是方法棧裡面的資料,也就是一個快照,所以最多返回的不是最新的資料,但是一致性還是得到保障的。
*
* @return
*/
double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead(); // 獲取樂觀讀鎖
double currentX = x, currentY = y; // 複製共享資源到本地方法棧中
if (!sl.validate(stamp)) { // //檢查樂觀讀鎖後是否有其他寫鎖發生,有則返回false
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) {
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); // 釋放所有鎖
}
}
}
在StampedLock 的底層提供了三種鎖
:
- 寫鎖: 獨佔鎖,一把鎖只能被一個執行緒獲得。當一個執行緒獲取寫鎖後,其他請求讀鎖和寫鎖的執行緒必須等待。類似於 ReentrantReadWriteLock 的寫鎖,不過這裡的寫鎖是不可重入的。
- 讀鎖 (悲觀讀):共享鎖,沒有執行緒獲取寫鎖的情況下,多個執行緒可以同時持有讀鎖。如果己經有執行緒持有寫鎖,則其他執行緒請求獲取該讀鎖會被阻塞。類似於 ReentrantReadWriteLock 的讀鎖,不過這裡的讀鎖是不可重入的。
- 樂觀讀 :允許多個執行緒獲取樂觀讀以及讀鎖。同時允許一個寫執行緒獲取寫鎖。
【原始碼示例】
// 寫鎖
public long writeLock() {
long s, next; // bypass acquireWrite in fully unlocked case only
return ((((s = state) & ABITS) == 0L &&
U.compareAndSwapLong(this, STATE, s, next = s + WBIT)) ?
next : acquireWrite(false, 0L));
}
// 讀鎖
public long readLock() {
long s = state, next; // bypass acquireRead on common uncontended case
return ((whead == wtail && (s & ABITS) < RFULL &&
U.compareAndSwapLong(this, STATE, s, next = s + RUNIT)) ?
next : acquireRead(false, 0L));
}
// 樂觀讀
public long tryOptimisticRead() {
long s;
return (((s = state) & WBIT) == 0L) ? (s & SBITS) : 0L;
}
StampedLock 在獲取鎖的時候會返回一個 long 型的資料戳,該資料戳用於稍後的鎖釋放引數,如果返回的資料戳為 0 則表示鎖獲取失敗。當前執行緒持有了鎖再次獲取鎖還是會返回一個新的資料戳,這也是StampedLock不可重入的原因。此外,在官網給的示例中我們也看到了,StampedLock 還支援這3種鎖的轉換:
long tryConvertToWriteLock(long stamp){}
long tryConvertToReadLock(long stamp){}
long tryConvertToOptimisticRead(long stamp){}
內部常量說明
在原始碼中我們看到,無論哪種鎖,在獲取的時候都會返回一個long型別的時間戳,這其實就是StampedLock命名的由來,而這個時間戳的第8位用來標識寫鎖,前 7 位(LG_READERS)來表示讀鎖,每獲取一個悲觀讀鎖,就加 1(RUNIT),每釋放一個悲觀讀鎖,就減 1。而悲觀讀鎖最多隻能裝 128 個(7 位限制),很容易溢位,所以用一個 int 型別的變數來儲存溢位的悲觀讀鎖。
四、StampedLock的使用
結果上面的StampedLock特性和官方的示例,我們寫一個小demo來感受一下它的使用,需要注意的是在獲取樂觀鎖時,如果有寫鎖改變資料時,為保證資料一致性,要切換為普通的讀鎖模式。
【測試示例】
public class Test {
private final StampedLock sl = new StampedLock();
private int data = 0;
public void write(int value) {
long stamp = sl.writeLock();
try {
data = value;
} finally {
sl.unlockWrite(stamp);
}
}
public int read() {
long stamp = sl.tryOptimisticRead();
int currentData = data;
// 如果有寫鎖被佔用,可能造成資料不一致,所以要切換到普通讀鎖模式
if (!sl.validate(stamp)) {
stamp = sl.readLock();
try {
currentData = data;
} finally {
sl.unlockRead(stamp);
}
}
return currentData;
}
public static void main(String[] args) {
Test test = new Test();
Thread writer = new Thread(() -> {
for (int i = 0; i < 5; i++) {
test.write(i);
System.out.println("當前執行緒" + Thread.currentThread().getName() + ":Write: " + i);
}
});
Thread reader = new Thread(() -> {
for (int i = 0; i < 5; i++) {
int value = test.read();
System.out.println("當前執行緒" + Thread.currentThread().getName() + ":Read: " + value);
}
});
writer.start();
reader.start();
}
}
輸出:
當前執行緒Thread-0:Write: 0
當前執行緒Thread-0:Write: 1
當前執行緒Thread-1:Read: 0
當前執行緒Thread-0:Write: 2
當前執行緒Thread-1:Read: 2
當前執行緒Thread-0:Write: 3
當前執行緒Thread-1:Read: 3
當前執行緒Thread-0:Write: 4
當前執行緒Thread-1:Read: 4
當前執行緒Thread-1:Read: 4
五、總結
相比於傳統讀寫鎖多出來的樂觀讀是StampedLock比 ReadWriteLock 效能更好的關鍵原因。StampedLock 的樂觀讀允許一個寫執行緒獲取寫鎖,所以不會導致所有寫執行緒阻塞,也就是當讀多寫少的時候,寫執行緒有機會獲取寫鎖,減少了執行緒飢餓的問題,吞吐量大大提高。
不過,需要注意的是StampedLock不可重入,不支援條件變數 Condition,對中斷操作支援也不友好(使用不當容易導致 CPU 飆升)。如果你需要用到 ReentrantLock 的一些高階效能,就不太建議使用 StampedLock 了。
六、結尾彩蛋
如果本篇部落格對您有一定的幫助,大家記得留言+點贊+收藏呀。原創不易,轉載請聯絡Build哥!
如果您想與Build哥的關係更近一步,還可以關注“JavaBuild888”,在這裡除了看到《Java成長計劃》系列博文,還有提升工作效率的小筆記、讀書心得、大廠面經、人生感悟等等,歡迎您的加入!