ReentrantLock 原理解析

348 阅读6分钟

「这是我参与2022首次更文挑战的第20天,活动详情查看:2022首次更文挑战」。

ReentrantLock 源码解析

ReentrantLock 的核心是通过 AQS 实现的,具备了 AQS 的特征。

ReentrantLock 加锁过程

我们先说一下这例子: 以银行办理业务的案例来模拟我们的 AQS 是如何进行线程的管理和通知唤醒机制:
环境:有一个业务窗口 3 个排队的顾客;
类比(对号入座):3 个线程来模拟 3个排队的顾客,然后锁表示银行的窗口。

再来一个直观一点的示意图: image.png

通过这个例子大家可以进行锁的调试和辅助理解,接下来我就以 ReentrantLock 中具体的方法来分析加锁、解锁过程。

代码例子:

// 以银行办理业务的案例来模拟我们的 AQS 是如何进行线程的管理和通知唤醒机制
// 3 个线程来模拟银行网点,受理窗口办理业务的顾客

// A 顾客就是第一个顾客,此时受理窗口没有任何人, A可以直接去办理
Lock lock = new ReentrantLock();
new Thread(() -> {
    lock.lock();
    System.out.println("  -------> A Thread come in");
    //  暂停几秒钟线程
    try { TimeUnit.MINUTES.sleep(20); } catch (InterruptedException e) {e.printStackTrace();}
    lock.unlock();
}, "A").start();

// 第2个顾客,由于业务窗口只有一个(只能有一个线程持有锁),此时 B进行等待
// 进入候客区
new Thread(() -> {
    lock.lock();
    System.out.println("  -------> B Thread come in");
    try { TimeUnit.MINUTES.sleep(20); } catch (InterruptedException e) {e.printStackTrace();}
    lock.unlock();
}, "B").start();

// 第3个顾客,由于业务窗口只有一个(只能有一个线程持有锁),此时 C进行等待
// 进入候客区
new Thread(() -> {
    lock.lock();
    System.out.println("  -------> C Thread come in");
    try { TimeUnit.MINUTES.sleep(20); } catch (InterruptedException e) {e.printStackTrace();}
    lock.unlock();
}, "C").start();

ReentrantLock 核心方法

ReentrantLock 的方法和结构如下图所示,不得不说 Doug Lea 大神的水平还是非常高的,方法和命名上面其实都是见名知意的。还有就是这块公平锁(FairSync)、非公平锁(NonfairSync) 都是依赖 AbstractQueuedSynchronizer 这个模板方法实现的。

image.png

lock()

是加锁方法,默认为非公平锁。

构造方法如下所示:

image.png

lock 方法如下所示: image.png

公平锁 lock 方法实现

image.png

非公平锁 lock 方法实现

image.png

特别说明一下:下面会涉及到非公平/公平锁的解析,在解析之前我会加粗说是那种方式的锁。

acquire()

acquire 方法在 AbstractQueuedSynchronizer 类中,该方法就是去尝试获取锁,如果获取失败了,就会尝试再次获取,或者进入 AQS 队列中。

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

主要有三条流程(本质就是 3 个核心方法) tryAcquireaddWaiteracquireQueued

FairSync#tryAcquire

1、公平锁tryAcquire 是由子类 FairSync 实现, 这里和非公平锁的区别就在于调用了 hasQueuedPredecessors 方法判断是否有线程在排队。

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
            
        if (
            // hasQueuedPredecessors 这里就是公平锁的体现,如果人在排队就不能获取锁,需要先进入排队,所以性能上来说比非公平锁要一些。
            !hasQueuedPredecessors() &&
            // cas 获取锁
            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;
}

2、addWaiter 操作,顾名思义就是当前线程尝试获取锁失败,然后进入将当前 Thread 封装为一个 AQS Node 节点,进入等待队列排队

private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    // Try the fast path of enq; backup to full enq on failure
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            // 后续通过这里返回
            return node;
        }
    }
    // 首次进入
    enq(node);
    return node;
}

如果没有尾节点会调用 enq进行初始化头节点和入队操作:

private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        // 首次初始化
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            // 加入当前节点
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

3、调用acquireQueued 方法是在,当前获取锁的线程进入 AQS 之后进入阻塞之前执行的方法,其实就是在进入等待之前做最后一次 “抢救” 尝试能否获取锁。还有一个逻辑就是在 AQS 唤醒过后,当前获取到锁的节点就成了头节点,会将 "旧" 头节点丢弃掉。

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

NonfairSync#tryAcquire()

1、 本次走非公平锁 NonfairSync, 非公平锁对比公平锁没有是否有队列的排队的判断,只要是锁获取的参与者,都可以直接进行锁的竞争。

// 首先我们先看 `tryAcquire()` 方法
protected final boolean tryAcquire(int acquires) {
    return nonfairTryAcquire(acquires);
}

// 核心是调用 `nonfairTryAcquire(acquires)` 
final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    // 如果可以获取锁
    if (c == 0) {
        // 修改 state 
        if (compareAndSetState(0, acquires)) {
            // 设置锁的持有者(持有线程)
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // 重入支持
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

2、 addWaiter(Node.EXCLUSIVE) addWaiter方法实现

image.png

如果前面没有排队线程将调用 enq方法

image.png

双向链表中,第一个节点为虚节点(也叫做哨兵节点),其实并不存储任何信息,只是占位,真正的第一个有数据的节点,是从第二个节点开始的。

acquireQueued(addWaiter(Node.EXCLUSIVE))

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        // 循环
        for (;;) { 
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                // 哨兵节点出队
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            // shouldParkAfterFailedAcquire 第 1 次返回 false
            // 第 2 次返回 false
            if (shouldParkAfterFailedAcquire(p, node) &&
                // 内部会阻塞线程调用 LockSupport.park(this);
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

// setHead 方法
// 就是把当前获取锁的节点设置为哨兵节点,然后之前旧哨兵节点设置为 null
private void setHead(Node node) {
    head = node;
    node.thread = null;
    node.prev = null;
}

node.predecessor(); 方法获取前节点,之前我们有看到 aqs 是必须要初始化的,通常情况下都不是 null 的。

final Node predecessor() throws NullPointerException {
    Node p = prev;
    if (p == null)
        throw new NullPointerException();
    else
        return p;
}

shouldParkAfterFailedAcquire()

shouldParkAfterFailedAcquire(p, node) 方法, 简单的讲就是把[node] 的有效前驱(有效是指node不是CANCELLED的)找到,并且将有效前驱的状态设置为SIGNAL,之后便返回true代表马上可以阻塞了。

    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        // 获取前驱节点的状态
        int ws = pred.waitStatus;
        // 如果是 SIGNAL 状态,即等待被占用的资源释放,直接返回 true
        // 准备继续调用 parkAndCheckInterrupt 方法
        if (ws == Node.SIGNAL)
            return true;
        // ws 大于 0 说明是 cancelled 状态
        if (ws > 0) {
            // 循环判断节点的前驱节点是否也是 cancelled 状态,忽略该节点重新链接队列
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            // 将当前节点的前驱节点设置为 SIGNAL 状态,用于后续唤醒操作
            // 程序第一次执行到这里返回为 false,还会进行外层第二次循环,最终从代码第 7 行返回
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

parkAndCheckInterrupt(),

parkAndCheckInterrupt()阻塞当前线程, 就是当线程多次获取锁失败后,进入 AQS 阻塞队列中进入阻塞等待,等持有者释放资源,激活当前线程。

private final boolean parkAndCheckInterrupt() {
    // 线程挂起,程序不会继续向下珍惜i给你
    LockSupport.park(this);

    // 根据 park 方法 api 描述,程序下面三情况会继续向下执行
    // 1. 调用 unpark
    // 2. 被中断 (interrupt)
    // 3. 其他不合逻辑的返回才会继续向下执行
    
    // 因上述三种情况程序执行至此,返回当前线程的中断状态,并清空中断状态
    // 如果由于被中断,该方法会返回 true
    return Thread.interrupted();
}

unlock 方法

非公平锁方式解锁

解锁过程:
1、 unlock 方法源代码入,内部调用 release 方法并且传入一个 1 ,其实可以理解为归还/释放一个这把锁。

image.png

2、release 方法中有两个操作:第一步是执行 tyRelease 方法进行真正的解锁,如果解锁失败返回 false 返回, 第二部如果解锁成功,会去判断 AQS 中是否有等待的节点(即等待的线程)如果有就调用 unparkSuccessor 去 unpark 队列中第一个等待的线程。

image.png

3、我们先来看 tryRelease 方法, 首先是获取 state 变量的值,然后通过 getState() - releases 来获取解锁后的值,再此之前还有一个判断就是判断的当前锁的持有者是否是当前线程,如果不是将抛出:IllegalMonitorStateException 异常;然后解锁后 state == 0 这个状态我们可以理解为 “完全解锁“ (其实这个是相对于锁重入来说的)。如果完全解锁就清空当前锁的线程持有者。然后在通过 cas 修改 state 的值。最终返回接锁后的布尔值。 其实这里有个小细节就是只有在 state = 0 的时候解锁的布尔值才会返回 true.

protected final boolean tryRelease(int releases) {
    // state -1
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        // 清空锁持有者
        setExclusiveOwnerThread(null);
    }
    // cas 修改 state
    setState(c);
    return free;
}

4、unparkSuccessor(h);方法主要是实现 AQS 中节点的唤醒操作, 它接受的一个参数就是我们在第二个步骤的时候传入的 node 节点,其实就是 AQS 的对头节点。这里通常就在头节点的下一个节点 , 如果没有找到会通过尾节点向前查找。最终确定需要唤醒的排队节点 s 执行 LockSupport.unpark(s.thread) 方法进入前面的抢锁的逻辑。

    private void unparkSuccessor(Node node) {
        /*
         * If status is negative (i.e., possibly needing signal) try
         * to clear in anticipation of signalling.  It is OK if this
         * fails or if status is changed by waiting thread.
         */
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);

        /*
         * Thread to unpark is held in successor, which is normally
         * just the next node.  But if cancelled or apparently null,
         * traverse backwards from tail to find the actual
         * non-cancelled successor.
         */
        Node s = node.next;
        if (s == null || s.waitStatus > 0) {
            s = null;
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        if (s != null)
            // 排队节点唤醒
            LockSupport.unpark(s.thread);
    }

ReentrantLock 总结

流程图梳理:

并发框架 AQS.png 其实我刚开始看这块的时候,还事比较乱的,大家可以按照自己的思路,结合 ReentrantLock 源码尝试去分析,才能逐步的清晰和掌握。

参考资料