1. 程式人生 > >【Java併發】JUC—ReentrantReadWriteLock有坑,小心讀鎖!

【Java併發】JUC—ReentrantReadWriteLock有坑,小心讀鎖!

好長一段時間前,某些場景需要JUC的讀寫鎖,但在某個時刻內讀寫執行緒都報超時預警(長時間無響應),看起來像是鎖競爭過程中出現死鎖(我猜)。經過排查專案並沒有能造成死鎖的可疑之處,因為業務程式碼並不複雜(僅僅是一個計算過程),經幾番折騰,把注意力轉移到JDK原始碼,正文詳細說下ReentrantReadWriteLock的隱藏坑點。


過程大致如下:

  • 若干個讀寫執行緒搶佔讀寫鎖
  • 讀執行緒手腳快,優先搶佔到讀鎖(其中少數執行緒任務較重,執行時間較長)
  • 寫執行緒隨即嘗試獲取寫鎖,未成功,進入雙列表進行等待
  • 隨後讀執行緒也進來了,要去拿讀鎖

問題:優先得到鎖的讀執行緒執行時間長達73秒,該時段寫執行緒等待是理所當然的,那讀執行緒也應該能夠得到讀鎖才對,因為是共享鎖,是吧?但預警結果並不是如此,超時任務執行緒中大部分為讀。究竟是什麼讓讀執行緒無法搶佔到讀鎖,而導致響應超時呢?

把場景簡化為如下的測試程式碼:讀——寫——讀 執行緒依次嘗試獲取ReadWriteLock,用空轉替換執行時間過長。

執行結果:控制檯僅打印出Thread[讀執行緒 -- 1,5,main],既是說讀執行緒 -- 2並沒有搶佔到讀鎖,跟上訴的表現似乎一樣。

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 public class ReadWriteLockTest { public static void main(String[] args) { ReadWriteLockTest readWriteLockTest = new ReadWriteLockTest(); }   public ReadWriteLockTest() { try { init(); } catch (InterruptedException e) {
e.printStackTrace(); } }   void init() throws InterruptedException { TestLock testLock = new TestLock(); Thread read1 = new Thread(new ReadThread(testLock), "讀執行緒 -- 1"); read1.start(); Thread.sleep( 100); Thread write = new Thread(new WriteThread(testLock), "寫執行緒 -- 1"); write.start(); Thread.sleep( 100); Thread read2 = new Thread(new ReadThread(testLock), "讀執行緒 -- 2"); read2.start(); }   private class TestLock {   private String string = null; private ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); private Lock readLock = readWriteLock.readLock(); private Lock writeLock = readWriteLock.writeLock();   public void set(String s) { writeLock.lock(); try { // writeLock.tryLock(10, TimeUnit.SECONDS); string = s; } finally { writeLock.unlock(); } }   public String getString() { readLock.lock(); System.out.println(Thread.currentThread()); try { while (true) {   } } finally { readLock.unlock(); } } }   class WriteThread implements Runnable {   private TestLock testLock; public WriteThread(TestLock testLock) { this.testLock = testLock; }   @Override public void run() { testLock.set( "射不進去,怎麼辦?"); } }   class ReadThread implements Runnable {   private TestLock testLock; public ReadThread(TestLock testLock) { this.testLock = testLock; }   @Override public void run() { testLock.getString(); } } }

我們用jstack檢視一下執行緒,看到讀執行緒2和寫執行緒1確實處於WAITING的狀態。

jstackjstack

排查專案後,業務程式碼並沒有問題,轉而看下ReentrantReadWriteLock或AQS是否有什麼問題被我忽略的。

第一時間關注共享鎖,因為獨佔鎖的實現邏輯我確定很清晰了,很快我似乎看到自己想要的方法。

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 public static class ReadLock implements Lock, java.io.Serializable { public void lock() { //if(tryAcquireShared(arg) < 0) doAcquireShared(arg); sync.acquireShared( 1); } } abstract static class Sync extends AbstractQueuedSynchronizer { protected final int tryAcquireShared(int unused) { Thread current = Thread.currentThread(); int c = getState(); //計算stata,若獨佔鎖被佔,且持有鎖非本執行緒,返回-1等待掛起 if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current) return -1; //計算獲取共享鎖的執行緒數 int r = sharedCount(c); //readerShouldBlock檢查讀執行緒是否要阻塞 if (!readerShouldBlock() && //執行緒數必須少於65535 r < MAX_COUNT && //符合上訴兩個條件,CAS(r, r+1) compareAndSetState(c, c + SHARED_UNIT)) { //下面的邏輯就不說了,很簡單 if (r == 0) { firstReader = current; firstReaderHoldCount = 1; } else if (firstReader == current) { firstReaderHoldCount++; } else { HoldCounter rh = cachedHoldCounter; if (rh == null || rh.tid != getThreadId(current)) cachedHoldCounter = rh = readHolds.get(); else if (rh.count == 0) readHolds.set(rh); rh.count++; } return 1; } return fullTryAcquireShared(current); } }

 

嗯,沒錯,方法readerShouldBlock()十分矚目,幾乎不用看上下文就定位到該方法。因為預設非公平鎖,所以直接關注NonfairSync。

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 static final class NonfairSync extends Sync { final boolean writerShouldBlock() { return false; } final boolean readerShouldBlock() { return apparentlyFirstQueuedIsExclusive(); } } //下面方法在ASQ中 final boolean apparentlyFirstQueuedIsExclusive() { Node h, s; return (h = head) != null && //head非空 (s = h.next) != null && //後續節點非空 !s.isShared() && //後續節點是否為寫執行緒 s.thread != null; //後續節點執行緒非空 }

 

apparentlyFirstQueuedIsExclusive什麼作用,檢查持鎖執行緒head後續節點s是否為寫鎖,若真則返回true。結合tryAcquireShared的邏輯,如果true意味著讀執行緒會被掛起無法共享鎖。

這好像就說得通了,當持鎖的是讀執行緒時,跟隨其後的是一個寫執行緒,那麼再後面來的讀執行緒是無法獲取讀鎖的,只有等待寫執行緒執行完後,才能競爭。

這是jdk為了避免寫執行緒過分飢渴,而做出的策略。但有坑點就是,如果某一讀執行緒執行時間過長,甚至陷入死迴圈,後續執行緒會無限期掛起,嚴重程度堪比死鎖。為避免這種情況,除了確保讀執行緒不會有問題外,儘量用tryLock,超時我們可以做出響應。

當然也可以自己實現ReentrantReadWriteLock的讀寫鎖競爭策略,但還是算了吧,遇到讀遠多於寫的場景時,寫執行緒飢渴帶來的麻煩更大,表示踩過坑,別介。

 

from: http://huangzehong.me/2018/07/02/20180702%20-%E3%80%90Java%E5%B9%B6%E5%8F%91%E3%80%91JUC%E2%80%94ReentrantReadWriteLock%E6%9C%89%E5%9D%91%EF%BC%8C%E5%B0%8F%E5%BF%83%E8%AF%BB%E9%94%81%EF%BC%81/