【JUC 并发编程】— ReentrantLock 源码解析

可重入性

概念

在说 ReentrantLock 之前,先了解下什么是可重入。假如有如下代码


private synchronized static void testA() {
    testB();
}

private synchronized static void testB() {
    // do sth
}

代码很简单,有两个同步方法,testA 方法中调用 testB 方法。线程进入 testA 之前获取当前类的锁,然后调用 testB,但是进入 testB 方法也需要获取锁,然后貌似就发生死锁了。然而事实上并不会这样,线程进入 testB 方法获取锁的时候,检测到当前锁的持有者就是线程自己,那么进入 testB 就不需要再次获取了,这就是锁的可重入性。

测试

下面代码是 synchronized、ReentrantLock 和 Mutex (Mutex 代码看这里) 的可重入性测试


/**
 * 可重入性测试
 * synchronized
 * ReentrantLock
 * Mutex 通过 AQS 自定义的锁,不具备可重入性
 *
 * @author LBG - 2017/11/1 0001
 */
public class ReentrantTest {

    private static ReentrantLock lock = new ReentrantLock();
    private static Mutex mutex = new Mutex();

    public static void main(String[] args) {
        // synchronized
        syncA();

        // ReentrantLock
        lockA();

        // Mutex
        mutexA();
    }

    private synchronized static void syncA() {
        System.out.println("do sth in syncA!");
        syncB();
    }

    private synchronized static void syncB() {
        System.out.println("do sth in syncB!");
    }

    private static void lockA() {
        lock.lock();
        try {
            System.out.println("do sth in lockA!");
            lockB();
        } finally {
            lock.unlock();
        }
    }

    private static void lockB() {
        lock.lock();
        try {
            System.out.println("do sth in lockB!");
        } finally {
            lock.unlock();
        }
    }

    private static void mutexA() {
        mutex.lock();
        try {
            System.out.println("do sth in mutexA!");
            // 由于 Mutex 不具备可重入性,线程会一直阻塞在 mutexB()
            mutexB();
        } finally {
            mutex.unlock();
        }
    }

    private static void mutexB() {
        mutex.lock();
        try {
            System.out.println("do sth in mutexA!");
        } finally {
            mutex.unlock();
        }
    }
}


控制台打印如下

do sth in lockA!
do sth in lockB!
do sth in mutexA!
do sth in syncA!
do sth in syncB!

mutexA 方法始终不能执行,程序也不能正常结束。

ReentrantLock

entrant 是进入的意思,reentrant 就是重复进入,ReentrantLock 便是可重入锁。

ReentrantLock 是基于 AQS 实现,前面几篇已经详细分析了 AQS 内部实现。中断获取和超时获取本篇不在赘述,下面重点分析 ReentrantLock 的可重入公平性

可重入

其实上面也说到过,重入无非就是判断锁的持有者是不是当前线程,ReentrantLock 中亦是如此。ReentrantLock 的标准使用形式如下


lock.lock();
try {
    // do sth
} finaly {
    lock.unlock();
}

现在有个问题,假如方法 A 中调用方法 B,两个方法都需要锁,由于 ReentrantLock 的可重入,方法 B 可顺利进入。方法 B 执行完会释放锁,这时候其他线程又可以开始竞争获取锁,但是方法 A 中的代码并没有执行完,这该如何是好。

ReentrantLock 解决这个问题办法简单,每次加锁,AQS 中的 state 加 1,释放锁就减 1。当 state 等于 0 就表示锁被真正的释放,其它线程就可以再次竞争获取了。

下面是非公平性的获取代码


final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    // 锁没有被占用  
    if (c == 0) {
        // 尝试改变状态
        if (compareAndSetState(0, acquires)) {
            // 获取成功,设置持有者线程
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // 锁已经被占用,判断当前线程是否是锁持有者线程
    else if (current == getExclusiveOwnerThread()) {
        // 是,加锁次数加 1
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        // 改变状态
        setState(nextc);
        return true;
    }
    return false;
}

锁释放代码


protected final boolean tryRelease(int releases) {
    // 加锁次数减 1
    int c = getState() - releases;
    // 锁持有线程不是当前线程,肯定有毛病,抛异常
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    // 是否空闲
    boolean free = false;
    // 等于 0,当然空闲
    if (c == 0) {
        free = true;
        // 设置锁的持有者线程为 null
        setExclusiveOwnerThread(null);
    }
    // 改变状态
    setState(c);
    return free;
}

可见 ReentrantLock 把 AQS 的状态玩的很 6 啊~~

公平性

在谈公平性之前,先看看 ReentrantLock 内部结构,看下图

image

类图如下

image

首先按照惯例 ReentrantLock 有一个内部类 Sync 继承 AbstractQueuedSynchronizer,实现常见的 AQS 相关的方法。除这之外,还有个两个继承 Sync 的内部类 NonfairSync 和 FairSync,分别表示非公平和公平。上面 nonfairTryAcquire() 方法便是非公平式获取,公平获取代码如下


protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

该方法就比非公平获取多了一个方法判断 hasQueuedPredecessors(),代码如下


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());
}

这个方法获取头节点的后继节点,判断该节点的线程是不是当前线程。也就是说只有当前线程所在节点是头节点的下一个节点时,才能进行获取。正如方法名说的:是否有前驱节点。

回头看看 acquire 方法


public final void acquire(int arg) {
    // 只要不在队列中的线程肯定获取失败,然后入队
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

所以锁的获取顺序符合线程请求的绝对时间顺序,也就是 FIFO。

公平和非公平

当执行加锁操作时,公平性将由于在挂起线程和恢复线程时存在的开销而极大的降低性能。在大多数情况下,非公平性锁的性能要高于公平性锁的性能。

在激烈竞争的情况下,非公平锁的性能高于公平锁的性能的一个原因是:在恢复一个线程与该线程真正执行之间存在着严重的延迟。假设线程 A 持有一个锁,并且线程 B 请求这个锁。由于这个锁已被线程 A 持有,因此线程 B 将被挂起。当 A 释放时,B 将被唤醒,因此会再次获取锁。与此同时,如果线程 C 也请求这个锁,那么 C 很可能会在 B 被完全唤醒之前获得、使用以及释放锁。这样的情况是一种“双赢”的局面:B 获取锁的时刻并没有推迟,C 更早地获取锁,并且吞吐量也得到了提高

——来自《Java并发编程实战》

posted @ 2022-06-08 18:30  Tailife  阅读(37)  评论(0编辑  收藏  举报