多线程与高并发(五)—— 源码解析 ReentrantLock

一、前言

ReentrantLock 是基于 AQS 实现的同步框架,关于 AQS 的源码在 这篇文章 已经讲解过,ReentrantLock 的主要实现都依赖AQS,因此在阅读本文前应该先了解 AQS 机制。本文并不关注 ReentrantLock 如何使用,只叙述其具体实现。

二、ReentrantLock 的继承体系以及特点

AQS 是基于模板方法模式设计的,理解该设计模式可以帮助阅读 ReentrantLock 源码,当然不熟悉该设计模式并不影响下文的阅读。
首先我们来看 ReentrantLock 的类结构,该类实现了 Lock 接口,该接口定义了一些需要实现的方法,这些方法是提供给应用程序员编写并发程序使用时的 API。ReentrantLock 如何提供这些 API的支持则依赖 AQS 框架,在其内部定义了一个类 Sync ,在 AQS 一文中提到过要使用 AQS 构建同步工具,主要是实现自己的 tryAccquire 和 tryRealease 方法,Sync 类即是 AQS 的模板方法具体实现,对加锁解锁方法进行了重新实现,但稍有不同的是,其具体实现又继续向下委托,在对 FairSync 和 NonfairSync 类中皆有各自的实现,对应的是公平锁和非公平锁的实现。

public class ReentrantLock implements Lock, java.io.Serializable {
    abstract static class Sync extends AbstractQueuedSynchronizer {
    }
    static final class FairSync extends Sync {
    }
    static final class NonfairSync extends Sync {
    }
}

Lock 接口

Lock 接口体现了 ReentrantLock 的特性,我们看看定义了哪些方法,然后在源码解析篇依次对各个功能实现详细讲解。

public interface Lock {
    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition();
}
  • lock() 加锁操作
  • unlock() 解锁操作
  • lockInterruptibly() 可中断锁
  • tryLock() 尝试加锁,只尝试一次,不阻塞等待锁
  • tryLock(long time, TimeUnit unit) 可超时锁
  • newCondition() 支持条件队列

三、源码解析

3.1 加锁过程

我们首先来看下调用链,图中只画出 reentrantLock 的逻辑,关于 acquire() 中的后续逻辑见 AQS 篇。

3.1.1 非公平锁加锁

ReentrantLock 的默认构造器实现的是非公平锁,因此我们也先来看非公平锁的实现。lock() 即加锁的入口,使用时调用 lock.lock() 方法,在非公平锁模式下其最终交由 NonfairSync 来实现。

首先尝试 CAS 修改锁状态,即修改 AQS 中 State 的值,如果修改成功则表明加锁成功,接下来修改 exclusiveOwnerThread 为当前线程,表明当前线程独占该锁。如果修改失败,表明已经有线程获取了锁,此时调用 AQS 中的方法 acquire(1) 再次尝试获取锁。

该方法在 AQS 篇章有讲过,首先是调用 tryAcquire() 尝试获取锁,我们看其最终调用的逻辑 nonfairTryAcquire()。

  1. 获取当前锁的状态,并判断锁状态

  2. 锁处于初始化状态则说明锁现在是可被使用状态,能进这个逻辑,说明刚才持有锁的线程已经释放了锁,同 Sync.lock() 中的逻辑进行加锁操作,因为 State 为0,因此此次加锁必然能成功。

  3. 如果锁状态不为 0,说明锁已经被一个线程所持有,判断下是否为当前线程,是的话进入重入逻辑,不是的话返回 false 表明加锁失败,进入 acquireQueued() 逻辑去走入队过程。

3.1.2 公平锁加锁

公平锁的调用链与非公平锁大同小异,非公平锁与公平锁的加锁逻辑区别有二。

  • Lock()中的实现逻辑,非公平锁在调用 acquire() 方法之前先进行了一次加锁尝试

  • tryAcquire() 逻辑,公平锁多一个排队的过程,hasQueuedPredecessors()

这两处不同即是两种锁思想的具体体现:线程在获取锁之前是否需要排队?

排队的含义其实不准确,这个定义很宽泛,很难准确描述 hasQueuedPredecessors 所涉及的情形,但是我们可以用这个通俗的意义来直觉的感受这个过程,即当前线程在加锁之前首先需要判断 CLH 队列中有没有其他线程优先级比我高,如果有的话,那我就去队列等待(加锁失败,走acquire 剩余逻辑入队),如果没有优先级比我高的线程,那么我就有资格获取锁,走 CAS 逻辑去加锁。

有人可能不明白 CLH 队列就是先进先出队列,为何会出现要排队的情况,然而抢占锁的线程实际上有两种,一是在队列中阻塞的线程,二是新来的一个线程,还没进入队列排队。因此即使 CLH 队列是先进先出的,这个此时正好来竞争锁的线程就需要判断自己是否有资格获取锁,也就是队列中有没有线程优先级比自己高。

非公平锁则没有这个排队的过程,新来的这个线程直接就有资格获取锁,因此在请求锁时直接去 CAS 抢占锁。可以看到非公平锁的抢占和操作系统的进程抢占是有不同的,并不是所有线程被唤醒去随机抢占,而是通常意义上也有个排队的队列,只是这个新来的线程可以插队,直接高过队头线程的优先级去加锁。

那么为什么 lock() 中尝试一次加锁,tryAcquire() 中又一次加锁呢?其实只保留一处加锁即可,在 CAS 失败后再尝试一次,我猜测可能只是为了优化,多一次加锁成功的机会。

3.2 hasQueuedPredecessors() 解析

/**
     * Queries whether any threads have been waiting to acquire longer
     * than the current thread.
     * 查找是否有其他线程比当前线程等待了更久的时间
     *
     * 这个方法有点类似下面伪代码的逻辑,但是更有效
     * <p>An invocation of this method is equivalent to (but may be
     * more efficient than):
     *  <pre> {@code
     * getFirstQueuedThread() != Thread.currentThread() &&
     * hasQueuedThreads()}</pre>
     *
     * 注意由于线程可能在任何时候因为中断或者超时被取消,返回 true 并不能保证其他线程会在当前线程之前获取锁。
     * 因此,其他线程也可能在当前线程返回 false 之后由于队列为空而赢得竞争去入队
     * <p>Note that because cancellations due to interrupts and
     * timeouts may occur at any time, a {@code true} return does not
     * guarantee that some other thread will acquire before the current
     * thread.  Likewise, it is possible for another thread to win a
     * race to enqueue after this method has returned {@code false},
     * due to the queue being empty.
     *
     * <p>This method is designed to be used by a fair synchronizer to
     * avoid <a href="AbstractQueuedSynchronizer#barging">barging</a>.
     * Such a synchronizer's {@link #tryAcquire} method should return
     * {@code false}, and its {@link #tryAcquireShared} method should
     * return a negative value, if this method returns {@code true}
     * (unless this is a reentrant acquire).  For example, the {@code
     * tryAcquire} method for a fair, reentrant, exclusive mode
     * synchronizer might look like this:
     *
     *  <pre> {@code
     * protected boolean tryAcquire(int arg) {
     *   if (isHeldExclusively()) {
     *     // A reentrant acquire; increment hold count
     *     return true;
     *   } else if (hasQueuedPredecessors()) {
     *     return false;
     *   } else {
     *     // try to acquire normally
     *   }
     * }}</pre>
     *
     * @return {@code true} if there is a queued thread preceding the
     *         current thread, and {@code false} if the current thread
     *         is at the head of the queue or the queue is empty
     *         如果队列中有比当前线程处于更靠前的节点返回 true,如果当前队列为空或者当前线程为队列第一个节点返回 false
     * @since 1.7
     */
    public final boolean hasQueuedPredecessors() {
        // 如果当前线程为队列中的第一个节点,那么正确性取决于头节点是否先于尾节点被初始化以及头节点的 next 指针是正确的
        // The correctness of this depends on head being initialized
        // before tail and on head.next being accurate if the current
        // thread is first in queue.
        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());
    }

这个方法很简短,但却是最复杂的,我们一段一段分析这些逻辑判断。该方法的注释我上面已经翻译,需要注意的是在调用该方法的时候使用了取反逻辑,当该方法返回 true 时,说明需要排队,!hasQueuedPredecessors() 为false所以不能直接加锁,当该方法返回 false 时,才有资格加锁。

我们下面分段的去看这些与或逻辑,看什么情况下满足不需要排队的条件,也就是返回 false 的情况。

h != t

  • head == null, tail == null,,队列还未初始化,返回 false

  • head != null, tail == null,这种情况发生在初始化过程中,具体情况如下图, 返回 true

  • head != null, tail != null,分情况讨论,如下

    1. head == tail,此时队列中只有一个 dummy 节点, 无论是队列刚初始化完毕创建了一个 dummy 节点还是锁更替导致,只要队列中只唯一存在一个 dummy 节点时,head 才会等于 tail。如图为 T1 释放了锁后 T2 抢占锁成功的队列图(一定要先看 AQS 那篇文章,参照 acquireQueued() 源码)。返回 false。

    2. head != tail,下图为 T2 抢占锁成功,T3 线程还在队列中的情形,也就是当队列中节点数大于1时(此时队列中节点数为2,初始化创建出来的哑节点指针已断开,与队列无关系),head != null。返回 true。

  • Head == null, tail != null,这种情况不会出现,忽略

因此队列还未初始化时,以及队列中没有实际节点时(只有 dummy 节点),返回 false。

返回 true 有两种情况,队列中有实际节点或者队列正在初始化还没初始化完毕。

(s = h.next) == null

  • 队列中只有一个哑节点,条件成立,返回 true。
  • 队列中有两个节点,next 指针为 null, 返回 true, 在 AQS 那篇讲过 enq() 方法不能保证 next 指针一定正确,存在空档期该指针为 null,返回 true。
  • 队列中有两个节点,next 指针指向实际节点,返回 false。

因此当队列中有两个节点时,h != t && ((s = h.next) == null 为 false,接下来是或逻辑,因此我们还需要保证 s.thread != Thread.currentThread() 为 false。

s.thread != Thread.currentThread()

s.thread 即哑节点的下一个节点(实际节点),即实际节点

  • 如果 s.thread 为 null,返回 true。
  • 如果当前节点为实际节点,也就是重入,返回 false。
  • 当前节点不是实际节点,说明有其他更高优先级的线程,返回 true。

因此整体逻辑返回 false 的情况就是当前线程就是该队列中的第二个节点,也就是优先级最高的节点。那么有同学可能有疑问,这里仅考虑了队列中的线程,队列还没初始化时,线程竞争的情况下都不用排队吗?这个问题下面分场景来分析。

PS:插些题外话,为了分析清楚这段代码的作用,我采用的方法是分段去分析逻辑,再倒推这些逻辑判断具体是为了做什么事情。分析完毕后我发现存在两个弊端,一是要将逻辑整合起来理解比较难以厘清(主要是难以讲述),二是这有通过代码逆推思路的嫌疑,如果要理解别人编码上的巧妙,应该从场景递推,也就是我们写代码时的编码思维,思考自己会怎么写,对比 Doug Lea 怎么写。不过如果从场景递推,我想关于这段代码我不一定能对所有情况考虑周全,因而上面的分析过程我没有删除,接下来我再从场景开始递推分析为什么要这样设计,首先我们来看整个流程图。

  1. 场景1,队列还没有初始化,此时 head == null, tail == null,队列都没被初始化,自然不用考虑队列中的节点,整段逻辑 (后面就以整段逻辑代指这段完整的bool逻辑,不再每次列出)h != t && ((s = h.next) == null || s.thread != Thread.currentThread()) 返回 false,不用排队。那么如果此时两个线程都执行了该段逻辑不需要排队,并不影响公平锁的特性,两个线程现在的资格是平等的,都尝试去 CAS 加锁,当锁被占用的时候,两个线程都加锁失败,都开始进行入队过程,如果锁此时已经被释放了,那么只会有一个线程加锁成功,另一个线程则进行入队过程。

  2. 场景2,队列初始化过程中,对应的就是 head != null, tail == null 。也就是场景 1 导致的至少有一个线程开始了入队过程,那么此时进来的这个线程优先级必然比这个已经开始入队的线程低,因此要去排队。h != t 为true,该结果为true, 接下来判断 (s = h.next) == null, 此时队列中只有头节点,因此该逻辑为 true,后面是|| 逻辑无需判断,整段逻辑返回 true,表示需要排队

  3. 场景3,队列初始化完毕了,队列中没有实际节点,只有哑节点,这个哑节点无论是初次构造的还是后来线程替换的,怎么生成的哑节点不影响排队结果,只要有哑节点存在,那么当前线程就需要排队,无论此时是否有其他线程在入队过程中。如下面举例假设此时线程 T1 创建出了哑节点,即将在第二轮循环中执行链表维护工作,也就是执行下面代码 1、2、3,我们分段分析。

    • 如果 T1 线程现在还没来的及执行 cas 设置尾部代码,那么 head 和 tail 都还是指向哑节点的,整段逻辑是 false && true || true,最终结果为 true 需要排队。
    • 如果 T1 执行完毕了 CAS 设置了尾部,还没有执行 t.next = node,也就是此时 next 指针为 null,那么整段逻辑是 true && true || true,也是结果为 true,需要排队。
    • 如果 T1 执行完了语句1、2、3,完整的将 T1 入队了,此时整段逻辑是 true && false || true,结果为 true,需要排队。

    如果当前没有其他线程在入队,队列中的情形和 T1 还没开始执行代码 1 是一致的,因此返回 true,也需要排队。

    可见,只要队列中有一个哑节点,那么当前线程就必须排队。

  1. 场景4,队列中的节点数大于1,也就是包含了实际节点。上面场景3 包含了线程入队过程中要不要排队的场景,因此场景4中关注的队列是链表已经维护完毕的队列。如果队列中节点数大于 1,那么或之前的逻辑 h != t 为 true,(s = h.next) == null 此时是 false, 因此现在要判断当前线程是不是重入,也就是当前线程等不等于队列中的第二个节点。s.thread != Thread.currentThread(),即如果该条件为 false,说明当前线程是重入线程,无需排队。
  2. 场景5,同场景4,只是当前线程不是重入线程,s.thread != Thread.currentThread() 该条件为 true,需要排队。

以上就对当前线程是否需要排队的所有场景分析完毕。公平锁与非公平锁之间的不同就已经讲完了,其余逻辑一致,在非公平锁已经讲过,接下来看解锁流程。

3.3 解锁过程


不管是公平锁还是非公平锁,在解锁时都是调用 AQS 中的 realease 方法,在 AQS 篇已经讲解过,不重复讲了。

3.4 tryLock()

public boolean tryLock() {
        return sync.nonfairTryAcquire(1);
    }

对比下 lock 的源码


重点是 acquire 方法,lock() 方法在调用 tryAcquire 尝试加锁失败后就开始走入队的逻辑,也就是如果当前线程获取不到锁那么就需要阻塞。而 tryLock() 方法调用 nonfairTryAcquire() 尝试加锁,成功则返回 true,失败则返回 false,没有入队的逻辑。注意的是 tryLock() 只有非公平锁的实现,因此我们可以理解为这个方法用于插队获取锁,也就是说该方法用于公平锁的时候实际上破坏了公平原则。如果要维持公平原则,可以使用tryLock(0, TimeUnit.SECONDS) 等效 tryLock。另外该方法也不支持打断,带超时的 tryLock() 才支持打断,这也很好理解,tryLock 去抢占锁也不进行入队,结果很快就能返回,中断对该方法来说毫无意义。

3.5 tryLock(long timeout, TimeUnit unit)

public boolean tryLock(long timeout, TimeUnit unit)
            throws InterruptedException {
        return sync.tryAcquireNanos(1, unit.toNanos(timeout));
    }

我们先看源码:首先该方法支持中断,如果被设置了中断标记,抛出中断异常。在调用该方法的时候首先会尝试一次加锁,即 tryAcquire(arg),如果加锁成功就整个方法返回 true,加锁失败了则进入 doAcquireNanos 逻辑。

public final boolean tryAcquireNanos(int arg, long nanosTimeout)
            throws InterruptedException {
        // 如果被中断则直接抛出中断异常
        if (Thread.interrupted())
            throw new InterruptedException();
        // 获取锁失败就执行 doAcquireNanos 入队,直到超时返回 false
        return tryAcquire(arg) ||
            doAcquireNanos(arg, nanosTimeout);
    }

doAcquireNanos 才是该方法的核心,该方法中再次尝试一次加锁,加锁成功的话返回结果 true,如果加锁失败那么判断下设定的超时时间有没有到,时间到了的话直接返回 false 表明加锁失败。接下来调用 shouldParkAfterFailedAcquire 方法判断当前节点是否需要阻塞,如果要阻塞的话还需要当前时间大于 1000 ns,如果时间小于这个值,那么就不阻塞了。这么设置也很有道理,如果时间太短,刚被 park 阻塞就要唤醒,这因上下文切换浪费的 CPU 时间片不如直接进行下一次循环尝试加锁。如果不用阻塞就进行下一轮循环重复之前流程再次尝试加锁。

private boolean doAcquireNanos(int arg, long nanosTimeout)
            throws InterruptedException {
        if (nanosTimeout <= 0L)
            return false;
        // 当前时间 + 超时时间,即中断线程的最后期限
        final long deadline = System.nanoTime() + nanosTimeout;
        final Node node = addWaiter(Node.EXCLUSIVE);
        boolean failed = true;
        try {
            for (;;) {
                final Node p = node.predecessor();
                // 尝试加锁
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    // 加锁成功则返回
                    return true;
                }
                // 加锁失败,判断需要阻塞多久
                // 剩余时间
                nanosTimeout = deadline - System.nanoTime();
                // 时间不足, 直接返回 false,不阻塞
                if (nanosTimeout <= 0L)
                    return false;
                // 判断是否需要阻塞,且剩余时间需大于 1000L,时间太短那就没有阻塞的意义了, 不需要阻塞的话进入下一轮循环再次尝试加锁
                if (shouldParkAfterFailedAcquire(p, node) &&
                    nanosTimeout > spinForTimeoutThreshold) // spinForTimeoutThreshold 的值为 1000L
                    // 阻塞线程,限定了阻塞时长为剩余时间
                    LockSupport.parkNanos(this, nanosTimeout);
                // 如果线程被打断,抛出异常
                if (Thread.interrupted())
                    throw new InterruptedException();
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

parkNanos(Object blocker, long nanos) 方法阻塞线程,在指定的时间到达之后唤醒线程。
可知,该方法的作用就是在指定的时间内不断尝试加锁,加锁成功则提前返回,时间到了后无论当前线程是处于运行态还是阻塞态都需要返回结果 false,如果是阻塞态则还需要唤醒线程。

3.5 lockInterruptibly()

public void lockInterruptibly() throws InterruptedException {
        sync.acquireInterruptibly(1);
    }

该方法如下:支持中断,当然这毫无疑问,该方法就是支持中断的加锁操作。

public final void acquireInterruptibly(int arg)
            throws InterruptedException {
        // 线程被中断,抛出中断异常
        if (Thread.interrupted())
            throw new InterruptedException();
        // 尝试获取锁,获取失败则执行 doAcquireInterruptibly 入队,逻辑与acquireQueued大体一致,唯一的区别是在被中断时立马抛出异常,而不是修改标记等待下一次循环返回标记
        if (!tryAcquire(arg))
            doAcquireInterruptibly(arg);
    }

看该方法与 lock() 默认实现 acquireQueued 的对比,主要的区别就是该方法遇到中断时直接抛出中断异常,而 acquireQueued 中只是在下一轮循环中将中断标记返回出去。
结合该方法整个源码来看,两次处理异常,第一次处理即如果当前线程的中断标识已经被修改,那么不尝试加锁,直接抛出异常;第二次处理,如果阻塞过程中,线程被 interrupt() 中断那么该线程恢复运行态,抛出异常。

3.6 newCondition()

该方法的实现依赖接口 Condition,这里就不讲了,关于 Condition 是否有源码解析,待我看过再说。

其他

  1. 回看 tryAcquire() 方法,有些同学可能会疑问为什么判断排队时还需要确定当前线程是否重入呢?重入逻辑不是在 tryAcquire 中有实现吗?

这就要对两个重入应对的场景区分,tryAcquire 中的重入处理是针对当前线程已经成功获得锁,排队中的逻辑是针对锁状态为可用的时候,也就是锁此时都可用了,当前线程作为队列中第一个实际节点,自然有资格直接竞争锁,何况该线程已经在队列中,更无需再次重复入队。

试看以下场景,线程 T 在加锁的过程中因竞争锁失败将自己成功入队成为队列中第一个实际节点,接下来因递归等操作该线程再次尝试加锁,此时正好其他线程释放了锁,再次调用 hasQueuedPredecessors 逻辑就需要返回一个 false 来表明当前线程无需排队,因此 s.thread != Thread.currentThread() 条件是不可或缺的。


2. 为什么要设置哑节点
3. 为什么只要队列中有一个哑节点,那么当前线程就必须排队?
4. 为什么 AQS 中要将当前节点状态保存在前一个节点?

结语

以上三个疑问,有的看到过一些回答,但不是特别有说服力,有些暂时没想法,先搁置吧。JUC 源码暂且先分析至此,其他的我先研究一遍,得了闲再来写博客。

posted @ 2022-08-10 17:48  onAcorner  阅读(587)  评论(1编辑  收藏  举报