同事有话说:JDK5版本读写锁的死锁是怎么发生的|牛气冲天新年征文

844 阅读4分钟

先摊牌了,这次这个同事是我自己 小丑竟是我自己

问题的来源是《Java并发编程实战》一书,其在介绍读写锁时提到了JDK5版本的读写锁会有死锁问题,书中原话是这样子的:

java 5 中,读取锁的行为更类似于一个Semaphore而不是锁,它只维护活跃的读线程的数量,而不考虑它们的标识。在 java 6 中修改了这个行为:记录哪些线程已经获得了读取锁。做出这种修改的一个原因是:在 java 5 的锁实现中,无法区别一个线程是首次请求读取锁,还是可重入锁请求,从而可能使公平的读-写锁发生死锁。

看到这里时一直想不明白这个死锁是如何发生的,后来把问题抛到公司群,有几个大佬给了思路并在源码层面上作出了解释,才算是把这个问题彻底弄明白了,这里把思考的过程整理下做个总结。

复现死锁

让我们先尝试复现下书中所描述的会发生死锁的场景,基本思路就是运行两个线程,假设分别为A和B,读写锁设置为公平锁,线程A率先拿到读锁,而后线程B尝试获取写锁,在线程B的动作发生之后线程A尝试再次获取读锁,代码如下所示:

public class Jdk5DeadLockTest {
    public static void main(String[] args) {
        ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true);
        ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
        ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();

        // 当前线程获取读锁
        readLock.lock();
        System.out.println("main thread get read lock.");

        // 新起线程获取写锁
        Thread threadA = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("thread A try to get write lock.");
                writeLock.lock();
                System.out.println("thread A get write lock.");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("thread A release write lock.");
            }
        });
        threadA.start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 当前线程再次获取读锁
        System.out.println("main thread try to get read lock again.");
        readLock.lock();
        System.out.println("main thread get read lock again.");

        readLock.unlock();
        readLock.unlock();
        System.out.println("main thread release read lock.");
    }
}

上述代码基于JDK5版本下的ReentrantReadWriteLock,在执行了main()方法后,输出结果如下图: 通过控制台打印可以发现线程A尝试获取写锁后就阻塞了,而main线程也阻塞在了重入获取读锁的地方,jstack查看下线程日志可以看到两处地方都是在等待ReentrantReadWriteLock$FairSync这个资源。书中描述的死锁现象就被我们复现出来了。

死锁是如何发生的:JDK5版本读写锁的lock()方法

先来看第一处线程等待的地方:writeLock.lock(),因为是公平锁,所以其实际是调用了ReentrantReadWriteLock$FairSync$wlock()方法,我们来看一看JDK5版本下FairSync$wlock()的源码:

// ReentrantReadWriteLock$FairSync$wlock()
final void wlock() { // no fast path
    acquire(1);
}

// AbstractQueuedSynchronizer$acquire()
public final void acquire(int arg) {
    // tryAcquire()获取锁失败的话,当前线程会被封装成EXCLUSIVE属性的Node节点放入等待队列中进行自旋
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

// ReentrantReadWriteLock$FairSync$tryAcquire(int)
protected final boolean tryAcquire(int acquires) {
    // mask out readlocks if called from condition methods
    acquires = exclusiveCount(acquires);
    Thread current = Thread.currentThread();
    Thread first;
    int c = getState();
    int w = exclusiveCount(c);
    if (w + acquires >= SHARED_UNIT) {
        throw new Error("Maximum lock count exceeded");
    }
    // 在上述复现代码模拟的场景里,此处的w=0(第一次有线程获取写锁),c!=0(main线程已经获取了一次读锁),
    // 所以这里if判断为真,return false,即获取写锁失败。
    if ((w == 0 || current != owner) &&
            (c != 0 ||
                    ((first = getFirstQueuedThread()) != null &&
                            first != current))) {
        return false;
    }
    if (!compareAndSetState(c, c + acquires)) {
        return false;
    }
    owner = current;
    return true;
}

其主要流程如下图,由前面的运行结果可得知获取写锁的线程被挂起了:

接着来看线程等待的第二处地方:在main线程中执行的第二个readLock.lock(),同样因为是公平锁,所以其实际是调用了ReentrantReadWriteLock$FairSync$acquireShared()方法,我们来看一看JDK5版本下FairSync$acquireShared()的源码:

// AQS的acquireShared()方法,如果tryAcquireShared()方法返回结果小于0,即获取读锁失败,
// 那么当前获取读锁的线程将被封装放入AQS等待队列,并自旋重新获取读锁,期间可能被挂起
public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}

// ReentrantReadWriteLock$FairSync$tryAcquireShared(int)
protected final int tryAcquireShared(int acquires) {
    Thread current = Thread.currentThread();
    for (;;) {
        int c = getState();
        // 调用exclusiveCount(c)可以获取到当前获取到写锁的线程数量
        // 在我们复现代码的场景里,线程A没能获取到写锁,所以这里exclusiveCount(c)=0,这里条件判定为false
        if (exclusiveCount(c) != 0) {
            if (owner != current) {
                return -1;
            }
        } else {
            // 调用getFirstQueuedThread()可以获取到当前AQS等待队列中的队头线程
            // 在我们复现代码的场景里,这个队头线程就是获取写锁失败的线程A
            Thread first = getFirstQueuedThread();
            // 获取到的first线程为线程A,而当前current线程是main主线程,所以这里条件判断为true,tryAcquireShared()方法返回-1
            if (first != null && first != current) {
                return -1;
            }
        }
        int nextc = c + (acquires << SHARED_SHIFT);
        if (nextc < c) {
            throw new Error("Maximum lock count exceeded");
        }
        if (compareAndSetState(c, nextc)) {
            return 1;
        }
        // Recheck count if lost CAS
    }
}

// 这个方法用来获取AQS的等待队列的头节点线程
public final Thread getFirstQueuedThread() {
    // fullGetFirstQueuedThread()方法就不贴出来了,感兴趣的童鞋翻下AQS源码,代码不多
    return (head == tail) ? null : fullGetFirstQueuedThread();
}

其主要流程跟前面的获取写锁的流程很像,如下图所示,而由运行结果来看,在第二次获取读锁时,main线程也被挂起了。

现在让我们结合上述两个过程来看死锁是如何发生的。首先AQS挂起线程是调用了LockSupport.park(Object blocker)方法,在我们的测试场景里是传入了ReentrantReadWriteLock$FairSync这个对象作为参数blocker线程A获取写锁失败被挂起等待FairSync资源释放,接着main线程第二次获取读锁失败被挂起也在等待FairSync资源被释放,然而释放资源的代码readLock.unlock()发生在第二次获取读锁之后,main线程阻塞导致FairSync资源无法被释放,于是死锁就发生了。

如何修复死锁问题:JDK8版本读写锁的lock()方法

既然JDK5版本公平的读写锁可能发生死锁的原因被我们找到了,那么怎么修复这个问题呢?前面给出的Jdk5DeadLockTest测试类在JDK8版本下能够正常运行(ps.实际在JDK6就解决了,不过我本地用的JDK8,我们就直接看JDK8吧),不会发生上述死锁现象(运行结果如下图),所以让我们来看看官方是如何解决这个问题的。

JDK8ReentrantReadWriteLock相比JDK5版本做了不小的修改,但我们可以重点看新版的公平读写锁在获取读锁时,针对线程重入获取的情况是如何处理的,我直接贴代码了:

// ReentrantReadWriteLock$ReadLock$lock()
public void lock() {
    sync.acquireShared(1);
}

// AbstractQueuedSynchronizer$acquireShared(int)
// 获取读锁失败则进入等待队列自旋
public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}

// ReentrantReadWriteLock$Sync$tryAcquireShared(int)
protected final int tryAcquireShared(int unused) {
    Thread current = Thread.currentThread();
    // 省略部分代码,与我们这次分析的问题无关
    ......
    // 这个方法内部会专门处理重入获取读锁的情况,正是我们关注的重点
    return fullTryAcquireShared(current);
}

// ReentrantReadWriteLock$Sync$fullTryAcquireShared(Thread)
final int fullTryAcquireShared(Thread current) {
    HoldCounter rh = null;
    for (;;) {
        int c = getState();
        // 在我们的测试场景里,还没有线程获取到写锁,所以exclusiveCount(c)=0,这里条件判定为false
        if (exclusiveCount(c) != 0) {
            if (getExclusiveOwnerThread() != current)
                return -1;
            // else we hold the exclusive lock; blocking here
            // would cause deadlock.
        } else if (readerShouldBlock()) {
            // readerShouldBlock()方法检测当前线程对等待队列中是否是头节点
            // 在我们的测试代码模拟的场景中,这里返回true
            
            // firstReader变量记录着第一个成功获取到读锁的线程
            // 在我们的测试场景中,这里返回true
            if (firstReader == current) {
                // assert firstReaderHoldCount > 0;
            } else {
                // 处理非相同线程重入获取读锁的情况
                if (rh == null) {
                    rh = cachedHoldCounter;
                    if (rh == null || rh.tid != getThreadId(current)) {
                        rh = readHolds.get();
                        if (rh.count == 0)
                            readHolds.remove();
                    }
                }
                if (rh.count == 0)
                    return -1;
            }
        }
        // 检测获取读锁的次数是否已经超过了上限
        if (sharedCount(c) == MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        // CAS更新状态变量state
        if (compareAndSetState(c, c + SHARED_UNIT)) {
            if (sharedCount(c) == 0) {
                // 如果还没有线程获取过读锁,则将当前获取读锁的线程记录到firstReader变量
                firstReader = current;
                // firstReaderHoldCount记录着firstReader线程持有的读锁数量
                firstReaderHoldCount = 1;
            } else if (firstReader == current) {
                // 在我们的测试场景中,firstReader == current 条件将判定为true,因为我们是同一线程重入获取读锁
                // 对应增加firstReader线程持有的读锁数量
                firstReaderHoldCount++;
            } else {
                // 这一块主要是计算并为每个成功获取读锁的线程保存各自获取到的读锁的数量
                if (rh == null)
                    // cachedHoldCounter记录着最后一个成功获取到读锁的线程持有的读锁的数量
                    rh = cachedHoldCounter;
                if (rh == null || rh.tid != getThreadId(current))
                    // readHolds继承自ThreadLocal
                    // 利用线程隔离的原理,保存着每个成功获取读锁的线程各自获取到的读锁的数量
                    rh = readHolds.get();
                else if (rh.count == 0)
                    readHolds.set(rh);
                rh.count++;
                cachedHoldCounter = rh; // cache for release
            }
            // 线程重入获取读锁成功,返回一个非负数 1
            return 1;
        }
    }
}

// ReentrantReadWriteLock$FairSync$readerShouldBlock()
final boolean readerShouldBlock() {
    return hasQueuedPredecessors();
}

// AbstractQueuedSynchronizer$hasQueuedPredecessors()
// 这个方法可以检测当前线程对等待队列中是否是头节点,是则返回false,否则返回true
public final boolean hasQueuedPredecessors() {
    Node t = tail; // Read fields in reverse initialization order
    Node h = head;
    Node s;
    return h != t &&
        ((s = h.next) == null || s.thread != Thread.currentThread());
}

JDK8中针对同一线程重入式获取读锁的主要源码在上面已经给出来了,关键的处理逻辑也用注释标注出来了。主要思路就是引入firstReaderfirstReaderHoldCountreadHoldscachedHoldCounter等变量来存储每个获取读锁线程持有的读锁的数量等信息,以便处理重入获取读锁这类特殊情况。

后记

感谢公司大佬们提供的思路和解答,解了我困惑许久的问题。最后还是要照例给一下参考到的资源:

  • 《Java并发编程实战》
  • JDK5/JDK8 ReentrantReadWriteLock源码
  • JDK5源码下载