AQS-ReentrantLock解析

前言

AQS( AbstractQueuedSynchronizer )是一个用来构建锁和同步器(所谓同步,是指线程之间的通信、协作)的框架,Lock 包中的各种锁(如常见的 ReentrantLock, ReadWriteLock), concurrent 包中的各种同步器(如 CountDownLatch, Semaphore, CyclicBarrier)都是基于 AQS 来构建,所以理解 AQS 的实现原理至关重要,AQS 也是面试中区分侯选人的常见考点。

正文

1.AQS实现原理

AQS 全称是 AbstractQueuedSynchronizer,是一个用来构建同步器的框架,它维护了一个共享资源 state 和一个 FIFO 的等待队列,底层利用了 CAS 机制来保证操作的原子性。

AQS 实现锁的主要原理如下:

在这里插入图片描述

以实现独占锁为例(即当前资源只能被一个线程占有),其实现原理如下:state 初始化 0,在多线程条件下,线程要执行临界区的代码,必须首先获取 state,某个线程获取成功之后, state 加 1,其他线程再获取的话由于共享资源已被占用,所以会到 FIFO 等待队列去等待,等占有 state 的线程执行完临界区的代码释放资源( state 减 1)后,会唤醒 FIFO 中的下一个等待线程(head 中的下一个结点)去获取 state。

state 由于是多线程共享变量,所以必须定义成 volatile,以保证 state 的可见性, 同时虽然 volatile 能保证可见性,但不能保证原子性,所以 AQS 提供了对 state 的原子操作方法,保证了线程安全。

另外 AQS 中实现的 FIFO 队列(CLH 队列)其实是双向链表实现的,由 head, tail 节点表示,head 结点代表当前占用的线程,其他节点由于暂时获取不到锁所以依次排队等待锁释放。

所以我们不难明白 AQS 的如下定义:

public abstract class AbstractQueuedSynchronizer
  extends AbstractOwnableSynchronizer
    implements java.io.Serializable {
    // 以下为双向链表的首尾结点,代表入口等待队列
    private transient volatile Node head;
    private transient volatile Node tail;
    // 共享变量 state
    private volatile int state;
    // cas 获取 / 释放 state,保证线程安全地获取锁
    protected final boolean compareAndSetState(int expect, int update) {
        // See below for intrinsics setup to support this
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }
    // ...
 }

2.AQS源码解析

ReentrantLock 是我们比较常用的一种锁,也是基于 AQS 实现的,所以接下来我们就来分析一下 ReentrantLock 锁的实现来一探 AQS 究竟。

首先我们要知道 ReentrantLock 是独占锁,也有公平和非公平两种锁模式,什么是独占与有共享模式,什么又是公平锁与非公平锁?

2.1 独占锁、共享锁?

独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。

共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。

2.2 公平锁、非公平锁?

公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

2.3 ReentrantLock的使用和源码解析

本文我们将会重点分析独占,非公平模式的源码实现,不分析共享模式与 Condition 的实现,因为剖析了独占锁的实现,由于原理都是相似的,再分析共享与 Condition 就不难了。

首先我们先来看下 ReentrantLock 的使用方法:

// 1. 初始化可重入锁
private ReentrantLock lock = new ReentrantLock();
public void run() {
    // 加锁
    lock.lock();
    try {
        // 2. 执行临界区代码
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        // 3. 解锁
        lock.unlock();
    }
}

第一步是初始化可重入锁,可以看到默认使用的是非公平锁机制

public ReentrantLock() {
    sync = new NonfairSync();
}

当然你也可以用如下构造方法来指定使用公平锁:

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

Tip:FairSync 和 NonfairSync 是 ReentrantLock 实现的内部类,分别指公平和非公平模式,ReentrantLock 的加锁(lock),解锁(unlock)在内部具体都是调用的 FairSync,NonfairSync 的加锁和解锁方法。

几个类的关系如下:

在这里插入图片描述

我们先来剖析下非公平锁(NonfairSync)的实现方式,来看上述示例代码的第二步:加锁,由于默认的是非公平锁的加锁,所以我们来分析下非公平锁是如何加锁的

在这里插入图片描述

可以看到 lock 方法主要有两步

  1. 使用 CAS 来获取 state 资源,如果成功设置 1,代表 state 资源获取锁成功,此时记录下当前占用 state 的线程 setExclusiveOwnerThread(Thread.currentThread());
  2. 如果 CAS 设置 state 为 1 失败(代表获取锁失败),则执行 acquire(1) 方法,这个方法是 AQS 提供的方法,如下:
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

tryAcquire 解析

首先 调用 tryAcquire 尝试着获取 state,如果成功,则跳过后面的步骤。如果失败,则执行 acquireQueued 将线程加入 CLH 等待队列中。

先来看下 tryAcquire 方法,这个方法是 AQS 提供的一个模板方法,最终由其 AQS 具体的实现类(Sync)实现,由于执行的是非公平锁逻辑,执行的代码如下:

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();

    if (c == 0) {
        // 如果 c 等于0,表示此时资源是空闲的(即锁是释放的),再用 CAS 获取锁
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        // 此条件表示之前已有线程获得锁,且此线程再一次获得了锁,获取资源次数再加 1,这也映证了 ReentrantLock 为可重入锁
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

此段代码可知锁的获取主要分两种情况

  1. state 为 0 时,代表锁已经被释放,可以去获取,于是使用 CAS 去重新获取锁资源,如果获取成功,则代表竞争锁成功,使用 setExclusiveOwnerThread(current) 记录下此时占有锁的线程,看到这里的 CAS,大家应该不难理解为啥当前实现是非公平锁了,因为队列中的线程与新线程都可以 CAS 获取锁啊,新来的线程不需要排队
  2. 如果 state 不为 0,代表之前已有线程占有了锁,如果此时的线程依然是之前占有锁的线程(current == getExclusiveOwnerThread() 为 true),代表此线程再一次占有了锁(可重入锁),此时更新 state,记录下锁被占有的次数(锁的重入次数),这里的 setState 方法不需要使用 CAS 更新,因为此时的锁就是当前线程占有的,其他线程没有机会进入这段代码执行。所以此时更新 state 是线程安全的。

acquireQueued解析

如果 tryAcquire(arg) 执行失败,代表获取锁失败,则执行 acquireQueued 方法,将线程加入 FIFO 等待队列

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

所以接下来我们来看看 acquireQueued 的执行逻辑,首先会调用 addWaiter(Node.EXCLUSIVE) 将包含有当前线程的 Node 节点入队, Node.EXCLUSIVE 代表此结点为独占模式

再来看下 addWaiter 是如何实现的

private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    Node pred = tail;
    // 如果尾结点不为空,则用 CAS 将获取锁失败的线程入队
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    // 如果结点为空,执行 enq 方法
    enq(node);
    return node;
}

这段逻辑比较清楚,首先是获取 FIFO 队列的尾结点,如果尾结点存在,则采用 CAS 的方式将等待线程入队,如果尾结点为空则执行 enq 方法:

private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) {
            // 尾结点为空,说明 FIFO 队列未初始化,所以先初始化其头结点
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            // 尾结点不为空,则将等待线程入队
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

首先判断 tail 是否为空,如果为空说明 FIFO 队列的 head,tail 还未构建,此时先构建头结点,构建之后再用 CAS 的方式将此线程结点入队。

使用 CAS 创建 head 节点的时候只是简单调用了 new Node() 方法,并不像其他节点那样记录 thread,为什么?

因为 head 结点为虚结点,它只代表当前有线程占用了 state,至于占用 state 的是哪个线程,其实是调用了上文的 setExclusiveOwnerThread(current) ,即记录在 exclusiveOwnerThread 属性里。

执行完 addWaiter 后,线程入队成功,现在就要看最后一个最关键的方法 acquireQueued:

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            // 如果前一个节点是 head,则尝试自旋获取锁
            if (p == head && tryAcquire(arg)) {
                //  将 head 结点指向当前节点,原 head 结点出队
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            // 如果前一个节点不是 head 或者竞争锁失败,则进入阻塞状态
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            // 如果线程自旋中因为异常等原因获取锁最终失败,则调用此方法
            cancelAcquire(node);
    }
}

从上面代码中可以看出,如果前一个节点是 head,则尝试自旋获取锁,如果前一个节点不是 head 或者竞争锁失败,则进入阻塞状态。最后,如果线程自旋中因为异常等原因获取锁最终失败,则调用cancelAcquire(node)方法。

这部分就不带大家详细解答了,更加详细的解答可以看文末的链接。

总结

本文,首先介绍了AQS的实现原理,然后介绍了AQS的一个具体实现ReentrantLock,通过分析它的非公平模式下的源码,让我们更加了解其底层的实现,也会给我们以后设计代码带来更大启发。

最后引用我很佩服的一个人经常说的话:你知道的越多,你不知道的越多!

文章参考:

https://mp.weixin.qq.com/s/iNz6sTen2CSOdLE0j7qu9A

https://mp.weixin.qq.com/s/trsjgUFRrz40Simq2VKxTA

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值