滴水恩情 发表于 2022-9-16 17:17:17

多线程与高并发(五)—— 源码解析 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 篇。
https://img2022.cnblogs.com/blog/1409544/202208/1409544-20220810150118159-1759018717.svg
3.1.1 非公平锁加锁

ReentrantLock 的默认构造器实现的是非公平锁,因此我们也先来看非公平锁的实现。lock() 即加锁的入口,使用时调用 lock.lock() 方法,在非公平锁模式下其最终交由 NonfairSync 来实现。
https://img2022.cnblogs.com/blog/1409544/202208/1409544-20220808170016003-37822951.png
首先尝试 CAS 修改锁状态,即修改 AQS 中 State 的值,如果修改成功则表明加锁成功,接下来修改 exclusiveOwnerThread 为当前线程,表明当前线程独占该锁。如果修改失败,表明已经有线程获取了锁,此时调用 AQS 中的方法 acquire(1) 再次尝试获取锁。
https://img2022.cnblogs.com/blog/1409544/202208/1409544-20220808171830211-2130722812.png
该方法在 AQS 篇章有讲过,首先是调用tryAcquire()尝试获取锁,我们看其最终调用的逻辑 nonfairTryAcquire()。
https://img2022.cnblogs.com/blog/1409544/202208/1409544-20220810150142213-1736316325.png

[*]获取当前锁的状态,并判断锁状态
[*]锁处于初始化状态则说明锁现在是可被使用状态,能进这个逻辑,说明刚才持有锁的线程已经释放了锁,同 Sync.lock() 中的逻辑进行加锁操作,因为 State 为0,因此此次加锁必然能成功。
[*]如果锁状态不为 0,说明锁已经被一个线程所持有,判断下是否为当前线程,是的话进入重入逻辑,不是的话返回 false 表明加锁失败,进入 acquireQueued() 逻辑去走入队过程。
3.1.2 公平锁加锁

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

[*]Lock()中的实现逻辑,非公平锁在调用 acquire() 方法之前先进行了一次加锁尝试
https://img2022.cnblogs.com/blog/1409544/202208/1409544-20220810150202767-1571155600.svg

[*]tryAcquire() 逻辑,公平锁多一个排队的过程,hasQueuedPredecessors()
https://img2022.cnblogs.com/blog/1409544/202208/1409544-20220810150219297-480026726.svg
这两处不同即是两种锁思想的具体体现:线程在获取锁之前是否需要排队?
排队的含义其实不准确,这个定义很宽泛,很难准确描述 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 target="_blank" href="https://www.cnblogs.com/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 时,才有资格加锁。
https://img2022.cnblogs.com/blog/1409544/202208/1409544-20220810145553849-1428256032.png
我们下面分段的去看这些与或逻辑,看什么情况下满足不需要排队的条件,也就是返回 false 的情况。
https://img2022.cnblogs.com/blog/1409544/202208/1409544-20220810145615181-495300414.png
h != t


[*]head == null, tail == null,,队列还未初始化,返回 false
[*]head != null, tail == null,这种情况发生在初始化过程中,具体情况如下图, 返回 true
https://img2022.cnblogs.com/blog/1409544/202208/1409544-20220810145712258-1886947234.png
[*]head != null, tail != null,分情况讨论,如下

[*]head == tail,此时队列中只有一个 dummy 节点,无论是队列刚初始化完毕创建了一个 dummy 节点还是锁更替导致,只要队列中只唯一存在一个 dummy 节点时,head 才会等于 tail。如图为 T1 释放了锁后 T2 抢占锁成功的队列图(一定要先看 AQS 那篇文章,参照 acquireQueued() 源码)。返回 false。
https://img2022.cnblogs.com/blog/1409544/202208/1409544-20220810145730514-1407761626.svg
[*]head != tail,下图为 T2 抢占锁成功,T3 线程还在队列中的情形,也就是当队列中节点数大于1时(此时队列中节点数为2,初始化创建出来的哑节点指针已断开,与队列无关系),head != null。返回 true。
https://img2022.cnblogs.com/blog/1409544/202208/1409544-20220810145744363-1529883094.svg

[*]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 怎么写。不过如果从场景递推,我想关于这段代码我不一定能对所有情况考虑周全,因而上面的分析过程我没有删除,接下来我再从场景开始递推分析为什么要这样设计,首先我们来看整个流程图。
https://img2022.cnblogs.com/blog/1409544/202208/1409544-20220810145800856-1463210079.svg

[*]场景1,队列还没有初始化,此时 head == null, tail == null,队列都没被初始化,自然不用考虑队列中的节点,整段逻辑 (后面就以整段逻辑代指这段完整的bool逻辑,不再每次列出)h != t && ((s = h.next) == null || s.thread != Thread.currentThread()) 返回 false,不用排队。那么如果此时两个线程都执行了该段逻辑不需要排队,并不影响公平锁的特性,两个线程现在的资格是平等的,都尝试去 CAS 加锁,当锁被占用的时候,两个线程都加锁失败,都开始进行入队过程,如果锁此时已经被释放了,那么只会有一个线程加锁成功,另一个线程则进行入队过程。
[*]场景2,队列初始化过程中,对应的就是 head != null, tail == null 。也就是场景 1 导致的至少有一个线程开始了入队过程,那么此时进来的这个线程优先级必然比这个已经开始入队的线程低,因此要去排队。h != t 为true,该结果为true, 接下来判断 (s = h.next) == null, 此时队列中只有头节点,因此该逻辑为 true,后面是|| 逻辑无需判断,整段逻辑返回 true,表示需要排队
[*]场景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,也需要排队。
可见,只要队列中有一个哑节点,那么当前线程就必须排队。

https://img2022.cnblogs.com/blog/1409544/202208/1409544-20220810145815680-633309889.png

[*]场景4,队列中的节点数大于1,也就是包含了实际节点。上面场景3 包含了线程入队过程中要不要排队的场景,因此场景4中关注的队列是链表已经维护完毕的队列。如果队列中节点数大于 1,那么或之前的逻辑 h !=t 为 true,(s = h.next) == null 此时是 false,因此现在要判断当前线程是不是重入,也就是当前线程等不等于队列中的第二个节点。s.thread != Thread.currentThread(),即如果该条件为 false,说明当前线程是重入线程,无需排队。
[*]场景5,同场景4,只是当前线程不是重入线程,s.thread != Thread.currentThread() 该条件为 true,需要排队。
以上就对当前线程是否需要排队的所有场景分析完毕。公平锁与非公平锁之间的不同就已经讲完了,其余逻辑一致,在非公平锁已经讲过,接下来看解锁流程。
3.3 解锁过程

https://img2022.cnblogs.com/blog/1409544/202208/1409544-20220810161401247-1814392880.png
不管是公平锁还是非公平锁,在解锁时都是调用 AQS 中的 realease 方法,在 AQS 篇已经讲解过,不重复讲了。
3.4 tryLock()

public boolean tryLock() {
      return sync.nonfairTryAcquire(1);
    }对比下 lock 的源码
https://img2022.cnblogs.com/blog/1409544/202208/1409544-20220810162001622-1794662063.png
https://img2022.cnblogs.com/blog/1409544/202208/1409544-20220810162114564-1995292474.png
重点是 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
页: [1]
查看完整版本: 多线程与高并发(五)—— 源码解析 ReentrantLock