源码篇:ReentrantLock 源码分析上篇

1,592 阅读7分钟

引言

AQS ,全称「 AbstratcQueuedSynchronizer 」,它是 Java 显式锁实现的基础框架,本质是一种队列结构,以先进先出的方式维护线程的阻塞和唤醒。JDK 源码中,AbstratcQueuedSynchronizer 类定义的注释是这样写的:

Provides a framework for implementing blocking locks and related synchronizers (semaphores, events, etc) that rely on first-in-first-out (FIFO) wait queues. This class is designed to be a useful basis for most kinds of synchronizers that rely on a single atomic {@code int} value to represent state.

简单翻译一下,就是开题的内容:

  1. 一个阻塞锁框架
  2. 依赖先进先出的等待队列

本章节,一起来跟踪一下 ReentrantLock 的源码,它是基于 AQS 实现的可重入的互斥锁。如果把这个类的源码吃透了,我们就可以按照 AQS 注释上的示例自定义同步锁了。

整体结构

ReentrantLock 类总数 700 多行,特别赞的是,代码很优雅,每个方法都很简洁。 跟踪源码,笔者绘制出的类结构是这样的: ​​​​​​在这里插入图片描述 首先,看看 ReentrantLock 类,它包含一个同步器成员变量 sync ,三个方法lockunlocknewCondition。需要注意的是, lock 方法提供了几种获取锁方式:

  1. tryLock()tryLock(long ,TimeUnit) ,可轮询的、可定时地获取锁;
  2. lock() ,无条件地轮询获取锁;
  3. lockInterruptibly() ,可中断的锁获取方式,锁等待期间,线程可被中断。

其次,关注sync 这个成员变量,它是一个 AQS 抽象类的实例,在 ReentrantLock 中有两种实现子类 FairSyncNonfairSync,区别在于 lock 方法请求锁是否允许插队。

公平锁和非公平锁的差异

公平锁加锁时,不允许插队,直接执行 acquire 方法,源码为:

final void lock() {
     acquire(1);
} 

非公平锁在请求获取锁时,会先尝试 CAS 操作获取锁,尝试失败才进行排队。

 final void lock() {
        if (compareAndSetState(0, 1))
          setExclusiveOwnerThread(Thread.currentThread());
        else
          acquire(1);
}

在竞争激烈的应用场景中,非公平锁的性能要高于公平锁,因为:活动线程直接尝试获取锁的时间可能比恢复一个阻塞线程,并把锁分配给它的时间短的多。线程的唤醒需要额外时间,从唤起到线程真正运行之间存在着严重的时延。这也是前面提到的插队效益,这里不再赘述了!

非公平锁 NonfairSync

非公平锁的 lock 方法 ,过程很简单,先插队请求锁,失败后再走正规的 acquire 途径获取锁: 在这里插入图片描述 后面的 acquire 操作就是公平锁的执行流程了。

acquire,锁获取流程

公平锁和非公平锁最后都是走 acquire(1) 方法来获取锁的,它的源码,只有区区两行:

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

言简意赅:先尝试获取锁,如果失败,则将当前节点从队尾加入 AQS 队列等待。如果线程在等待过程时中断标识为真,则中断当前线程。 在这里插入图片描述

tryAcquire(1)

tryAcquire(1)acquire(1) 方法的第一个操作,它返回一个布尔值,标识是否成功获取到锁。跟踪源码,绘制出它的流程图: 在这里插入图片描述 流程图简述:

  1. 检查锁是否被占用,如果未被占用,则执行 CAS 并返回 CAS 的结果;如果锁被占用,则继续;
  2. 检查当前线程持是否是锁的持有者,如果是,说明是线程重入,将锁的重入次数累加一,返回 true
  3. 否则,获取操作失败,返回 false

addWaiter 入队流程

锁获取失败时,需要将当前线程加入条件队列排队,这就是 addWaiter 方法的工作。addWaiter 将当前线程封装成一个队列节点 Node 的实例,并以 for 循环的方式重复尝试将节点插入 AQS 队列,直到操作成功返回该 Node

AQS 维持了一个链表,具有 headtail 两个属性,初始时均为空。在添加第一个节点时,先创建一个虚拟的头节点【即 new Node() 没有任何信息的节点】,并将 tail 指向 head。新节点从队尾以 CAS 原子操作插入,插入操作在 for 循环中,能保证线程一定会被添加到等待队列里。

完整的流程图是这样的: 在这里插入图片描述 核心是,为当前线程创建一个等待节点,并成功加入等待队列。

acquireQueued 排队线程获取锁流程

acquireQueuedacquire 第三个步骤,它封装了排队线程获取锁的过程,绘制流程图如下: 在这里插入图片描述 获取锁失败的线程,被 addWaiter 方法加入等待队列后,会继续执行acquireQueued 方法,不断重试,直到线程被阻塞或者成功拿到锁为止。它的返回值是线程中断标识,即如果在等待锁过程中,该线程被中断,返回 trueacquire ,由 acquire 方法处理中断请求。

为什么会有这种判断呢?笔者的理解是:为了保证线程因等待锁而被阻塞的过程里,外部传递的中断信号不会被淹没。就是说,如果在该线程被 park 阻塞后,其他线程向它发送了中断信号,它是没办法响应该中断请求的。

所以 parkAndCheckInterrupt 操作就很有必要了,它在线程唤起时检查中断标志并通知该线程,由该线程自己去响应中断中断信号,对应 acquireselefIntrupt() 做的事情。

shouldParkAfterFailedAcquire

某个线程尝试获取锁,如果失败了,会调用 shouldParkAfterFailedAcquire 方法,根据前驱节点的状态判断是否需要挂起该线程,它返回一个布尔值,标识当前节点是否需要被挂起。

具体流程如下: 在这里插入图片描述 至此,锁获取操作流程分析结束。ReentrantLocklock 方法执行的结果是,要么线程被挂起,要么循环轮询获取锁直到成功设置状态为1(占用状态),然后被移除排队队列。

某个等待线程只有在其前驱节点的等待状态为 SIGNAL【前驱在等待条件队列执行唤醒操作】时,才会被阻塞,其他情况下都处于循环重试的过程中。

笔者认为这样根据前驱状态阻塞线程或者自旋重试,而不是直接挂起线程的处理很精妙。因为前驱还处于阻塞、等待唤醒的状态,说明自己获取锁无望,也就没有尝试的必要了。这样可以避免线程调度的资源消耗,毕竟,线程的挂起和唤醒是需要付出代价的。

unlock 操作分析

unlock 操作用来释放锁,只有锁的持有者才能调用,否则会抛出IllegalMonitorStateException 异常。代码也比较简单:

    public void unlock() {
        sync.release(1);
    }

还是直接调用同步器 sync 的 release 方法:

    public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

尝试释放锁,如果释放成功、且等待队列非空,则唤醒它的后继节点。 唤醒后继节点的条件是,后继节点非空且非取消状态:

  • 如果满足,则调用 LockSupportunpark 唤醒;
  • 否则,一直循环,直到找到一个可唤起的后继节点为止。

启示录

ReentrantLock 是 Java 大师的手笔,功力可见一斑。看源码,领会一两点编码技巧,以后可以应用到自己的开发工作中。

最后,总结一下这个类的编码艺术:

  1. 依赖抽象的 sync ,它是面向抽象的编程手法
  2. 巧妙用了队列这种数据结构
  3. 等待锁的过程中,如果某个节点的前驱节点处于阻塞状态时,当前节点也不再做无谓的挣扎
  4. 合理拆分方法,简洁优雅

笔者还是 2015 年看的 ReentrantLock 源码,本文的所有流程图和类图,都是当时跟踪源码时所绘的,为了编写专栏,而对一篇旧文的重新整理。再看一遍自己当时想明白的一些事情,还是很有启发的。

这也是记录的意义呀,虽然跨越了时空,有些感悟还是相通的!