JDK8併發包StampedLock鎖
StampedLock是併發包裡面jdk8版本新增的一個鎖,該鎖提供了三種模式的讀寫控制,三種模式分別如下:
- 寫鎖writeLock,是個排它鎖或者叫獨佔鎖,同時只有一個執行緒可以獲取該鎖,當一個執行緒獲取該鎖後,其它請求的執行緒必須等待,當目前沒有執行緒持有讀鎖或者寫鎖的時候才可以獲取到該鎖,請求該鎖成功後會返回一個stamp票據變數用來表示該鎖的版本,當釋放該鎖時候需要unlockWrite並傳遞引數stamp。
- 悲觀讀鎖readLock,是個共享鎖,在沒有執行緒獲取獨佔寫鎖的情況下,同時多個執行緒可以獲取該鎖,如果已經有執行緒持有寫鎖,其他執行緒請求獲取該讀鎖會被阻塞。這裡講的悲觀其實是參考資料庫中的樂觀悲觀鎖的,這裡說的悲觀是說在具體操作資料前悲觀的認為其他執行緒可能要對自己操作的資料進行修改,所以需要先對資料加鎖,這是在讀少寫多的情況下的一種考慮,請求該鎖成功後會返回一個stamp票據變數用來表示該鎖的版本,當釋放該鎖時候需要unlockRead並傳遞引數stamp。
- 樂觀讀鎖tryOptimisticRead,是相對於悲觀鎖來說的,在操作資料前並沒有通過CAS設定鎖的狀態,如果當前沒有執行緒持有寫鎖,則簡單的返回一個非0的stamp版本資訊,獲取該stamp後在具體操作資料前還需要呼叫validate驗證下該stamp是否已經不可用,也就是看當呼叫tryOptimisticRead返回stamp後到到當前時間間是否有其他執行緒持有了寫鎖,如果是那麼validate會返回0,否者就可以使用該stamp版本的鎖對資料進行操作。由於tryOptimisticRead並沒有使用CAS設定鎖狀態所以不需要顯示的釋放該鎖。該鎖的一個特點是適用於讀多寫少的場景,因為獲取讀鎖只是使用與或操作進行檢驗,不涉及CAS操作,所以效率會高很多,但是同時由於沒有使用真正的鎖,在保證資料一致性上需要拷貝一份要操作的變數到方法棧,並且在操作資料時候可能其他寫執行緒已經修改了資料,而我們操作的是方法棧裡面的資料,也就是一個快照,所以最多返回的不是最新的資料,但是一致性還是得到保障的。
下面通過JDK8註釋裡面的一個例子講解來加深對上面講解的理解。
class Point { // 成員變數 private double x, y; // 鎖例項 private final StampedLock sl = new StampedLock(); // 排它鎖-寫鎖(writeLock) void move(double deltaX, double deltaY) { long stamp = sl.writeLock(); try { x += deltaX; y += deltaY; } finally { sl.unlockWrite(stamp); } } // 樂觀讀鎖(tryOptimisticRead) double distanceFromOrigin() { // 嘗試獲取樂觀讀鎖(1) long stamp = sl.tryOptimisticRead(); // 將全部變數拷貝到方法體棧內(2) double currentX = x, currentY = y; // 檢查在(1)獲取到讀鎖票據後,鎖有沒被其他寫執行緒排它性搶佔(3) if (!sl.validate(stamp)) { // 如果被搶佔則獲取一個共享讀鎖(悲觀獲取)(4) stamp = sl.readLock(); try { // 將全部變數拷貝到方法體棧內(5) currentX = x; currentY = y; } finally { // 釋放共享讀鎖(6) sl.unlockRead(stamp); } } // 返回計算結果(7) return Math.sqrt(currentX * currentX + currentY * currentY); } // 使用悲觀鎖獲取讀鎖,並嘗試轉換為寫鎖 void moveIfAtOrigin(double newX, double newY) { // 這裡可以使用樂觀讀鎖替換(1) long stamp = sl.readLock(); try { // 如果當前點在原點則移動(2) while (x == 0.0 && y == 0.0) { // 嘗試將獲取的讀鎖升級為寫鎖(3) long ws = sl.tryConvertToWriteLock(stamp); // 升級成功,則更新票據,並設定座標值,然後退出迴圈(4) if (ws != 0L) { stamp = ws; x = newX; y = newY; break; } else { // 讀鎖升級寫鎖失敗則釋放讀鎖,顯示獲取獨佔寫鎖,然後迴圈重試(5) sl.unlockRead(stamp); stamp = sl.writeLock(); } } } finally { // 釋放鎖(6) sl.unlock(stamp); } } }
如上程式碼Point類裡面有兩個成員變數,和三個操作成員變數的方法,另外例項化了一個StampedLock物件用來保證操作的原子性。
首先分析下move方法,該函式作用是在新增增量,改變當前point座標的位置,程式碼先獲取到了寫鎖,然後對point座標進行修改,然後釋放鎖。該鎖是排它鎖,這保證了其他執行緒呼叫move函式時候會被阻塞,直到當前執行緒顯示釋放了該鎖,也就是保證了對變數x,y操作的原子性。
然後看下distanceFromOrigin方法,該方法作用是計算當前位置到原點的距離,程式碼(1)首先嚐試獲取樂觀讀鎖,如果當前沒有其它執行緒獲取到了寫鎖,那麼(1)會返回一個非0的stamp用來表示版本資訊,程式碼(2)拷貝變數到本地方法棧裡面,程式碼(3)檢查在(1)獲取到的票據是否還有效,之所以還要在此校驗是因為程式碼(1)獲取讀鎖時候並沒有通過CAS操作修改鎖的狀態而是簡單的通過與或操作返回了一個版本資訊,這裡校驗是看在在獲取版本資訊到現在的時間段裡面是否有其他執行緒持有了寫鎖,如果有則之前獲取的版本資訊就無效了。這裡如果校驗成功則執行(7)使用本地方法棧裡面的值進行計算然後返回。需要注意的是在程式碼(3)校驗成功後,程式碼(7)計算中其他執行緒可能獲取到了寫鎖並且修改了x,y的值,而當前執行緒執行程式碼(7)進行計算時候採用的才是對修改前值的拷貝,也就是操作的值是對之前值的一個拷貝,並不是新的值。另外還有個問題,程式碼(2)和(3)能否互換,答案是不能,假設位置換了,那麼首先執行validate,假如驗證通過了,要拷貝x,y值到本地方法棧,而在拷貝的過程中很有可能其他執行緒已經修改了x,y中的一個,這就造成了資料的不一致性了。那麼你可能會問,那不交換(2)和(3)時候在拷貝x,y值到本地方法棧裡面時候也會存在其他執行緒修改了x,y中的一個值那,這個確實會存在,但是,別忘了拷貝後還有一道validate,如果這時候有執行緒修改了x,y中的值,那麼肯定是有執行緒在呼叫validate前sl.tryOptimisticRead後獲取了寫鎖,那麼validate時候就會失敗。現在應該明白了吧,這也是樂觀讀設計的精妙之處也是使用時候容易出問題的地方。下面繼續分析validate失敗後會執行程式碼(4)獲取悲觀讀鎖,如果這時候騎行執行緒持有寫鎖則程式碼(4)會導致的當前執行緒阻塞直到其它執行緒釋放了寫鎖。獲取到讀鎖後,程式碼(5)拷貝變數到本地方法棧,然後就是程式碼(6)釋放了鎖,拷貝的時候由於加了讀鎖在拷貝期間其它執行緒獲取寫鎖時候會被阻塞,這保證了資料的一致性。最後程式碼(7)使用方法棧裡面資料計算返回,同理這裡在計算時候使用的資料也可能不是最新的,其它寫執行緒可能已經修改過原來的x,y值了。
最後一個方法moveIfAtOrigin方法作用是如果當前座標為原點則移動到指定的位置。程式碼(1)獲取悲觀讀鎖,保證其它執行緒不能獲取寫鎖修改x,y值,然後程式碼(2)判斷如果當前點在原點則更新座標,程式碼(3)嘗試升級讀鎖為寫鎖,這裡升級不一定成功,因為多個執行緒都可以同時獲取悲觀讀鎖,當多個執行緒都執行到(3)時候只有一個可以升級成功,升級成功則返回非0的stamp,否非返回0,這裡假設當前執行緒升級成功,然後執行步驟(4)更新stamp值和座標值然後退出迴圈,如果升級失敗則執行步驟(5)首先釋放讀鎖然後申請寫鎖,獲取到寫鎖後在迴圈重新設定座標值。最後步驟(6)釋放鎖。
使用樂觀讀鎖還是很容易犯錯誤的,必須要小心,必須要保證如下的使用順序:
long stamp = lock.tryOptimisticRead(); //非阻塞獲取版本資訊
copyVaraibale2ThreadMemory();//拷貝變數到執行緒本地堆疊
if(!lock.validate(stamp)){ // 校驗
long stamp = lock.readLock();//獲取讀鎖
try {
copyVaraibale2ThreadMemory();//拷貝變數到執行緒本地堆疊
} finally {
lock.unlock(stamp);//釋放悲觀鎖
}
}
useThreadMemoryVarables();//使用執行緒本地堆疊裡面的資料進行操作
總結: 相比ReentrantLock讀寫鎖,StampedLock通過提供樂觀讀鎖在多執行緒多寫執行緒少的情況下提供更好的效能,因為樂觀讀鎖不需要進行CAS設定鎖的狀態而只是簡單的測試狀態。