好长一段时间前,某些场景需要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; } public void run() { testLock.set( "射不进去,怎么办?"); } } class ReadThread implements Runnable { private TestLock testLock; public ReadThread(TestLock testLock) { this.testLock = testLock; } public void run() { testLock.getString(); } } } |
我们用jstack
查看一下线程,看到读线程2和写线程1确实处于WAITING的状态。
排查项目后,业务代码并没有问题,转而看下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/