从ReentrantLock分析AQS的加锁过程

前言

在Java编程中,多线程并发编程是一个重要的话题。在多线程编程中,保证线程安全和避免死锁等问题是至关重要的。为了解决这些问题,Java提供了一些锁的工具来控制并发造成的问题。ReentrantLock是单机应用下常用的一种可重入锁工具。而AQS(AbstractQueuedSynchronizer)则是ReentrantLock的底层实现,它提供了一种基于FIFO等待队列的同步器框架。本文将通过ReentrantLock的实现探究剖析AQS的相关原理。帮助读者更好地理解并发编程中ReentrantLock加锁的过程。

1 ReentrantLock

1.1 ReentrantLock基本使用

下面通过伪代码,带大家简单回顾下ReentrantLock的基本使用

csharp 复制代码
// 初始化选择公平锁还是非公平锁,默认构造参数是非公平锁
ReentrantLock lock = new ReentrantLock(false);
lock.lock();
try {
    // 进行业务处理
    。。。
    // 支持多张加锁方式,灵活并且可重入
    try {
        if(lock.tryLock(1, TimeUnit.MILLISECONDS)) {
            // 进行业务处理
        }
    } finally {
        lock.unlock();
    }
} finally {
    // 释放锁
    lock.unlock();
}

1.2 ReentrantLock的公平锁和非公平锁

在ReentrantLock中,有两种模式:公平锁和非公平锁。

  • 公平锁:当有多个线程等待获取锁时,锁会按照它们的请求顺序来分配,即先来先得的原则,在队列中排队的方式等待获取锁。
  • 非公平锁:当多个线程加锁时都有机会先直接尝试获取锁,能抢到锁到直接占有锁,抢不到才会到等待队列中排队等待。(如果你不特别指定,默认在ReentrantLock中就是非公平的)

让我们看下代码上有什么不同:

可以看到,当们调用lock()方法的时候,实际执行的方法是Sync类的lock()方法,其中Sync类有两个子类,分别是FairSync(公平)和NofairSync(非公平)。对比一下他们两个具体实现上的不同:

java 复制代码
公平锁实现
static final class FairSync extends Sync {
    // 加锁
    final void lock() {
        acquire(1);
    }
    /*
     * acquire方法中最后会里调用tryAcquire方法,先不具体分析
     */
    protected final boolean tryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
        // 重点关注公平锁直接调用hasQueuedPredecessors()方法
        // 该方法主要做的事情:判断当前线程是否位于同步队列中的第一个。如果是则返回true,否则返回false。
            if (!hasQueuedPredecessors() &&
                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;
    }
}
--------------------分割线--------------------
非公平锁实现
static final class NonfairSync extends Sync {
        // 加锁
        final void lock() {
            // 和公平锁第一个不同
            // 会尝试cas方式加锁能否成功,成功就直接设置当前线程设置独占线程。获取锁成功
            if (compareAndSetState(0, 1))
                // 设置当前线程为独占锁线程
                setExclusiveOwnerThread(Thread.currentThread());
            else
                acquire(1);
        }

        protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }
    
        final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                // 和公平锁的区别只是在没有hasQueuedPredecessors
                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;
        }
}

通过上面的代码分析,我们可以看到公平锁和非公平锁在加锁流程里的相同点和不同点

  • 相同点:他们加锁的时候都会调用acquire()方法
  • 不同点:非公平锁在加锁时会优先尝试CAS(compareAndSetState方法)方式去设置变量State(同步状态)。如果CAS成功即意味着获取锁成功。

CAS(Compare and Swap)是一种原子操作,用于实现同步队列和锁的机制。它能够在多线程环境下确保对共享变量的原子性操作,从而实现线程安全的同步机制。

CAS操作包括三个参数:需要更新的内存位置、原始预期值和新值。通过比较内存位置的值和预期值,如果相等则更新为新值。确保只有一个线程能够成功地修改这个变量,其他线程需要重新尝试。达到避免多个线程同时修改变量导致的问题,实现了线程安全。

小结:公平锁通过同步队列来实现多个线程按照申请锁的顺序获取锁,从而实现公平性。而非公平锁则在加锁时不考虑排队等待问题,会直接尝试获取锁,可能导致后申请的线程先获得锁(即不公平)。

1.3 ReentrantLock和AQS关系

上面我们看到了公平锁和非公平锁的不同(它们大部分代码是相同的)。下面我们重点分析下非公平锁的加锁流程。找到和AQS的关系。

第一步:若通过CAS设置变量State成功,意味着获取锁成功,设置为独占线程。

第二步:若通过CAS设置State失败,意味着加锁失败,进入acquire方法

这里我们可以考虑下,如果我们自己实现一个加锁逻辑。当我们获取锁失败后应该做什么操作呢?

如果还没有学习和了解过AQS,我想大部分人的处理肯定是直接设置这次获取锁结果为失败状态,直接返回。这样的好处是简单粗暴,对于用户则是收到加锁失败的结果可以立刻再次发起请求尝试获取锁。

AQS框架内部却是提供了一个先进先出的队列来管理等待获取锁或者同步资源的线程。这样有以下的好处:

  • 公平性:先请求的线程可以在后面优先获取到锁
  • 避免饥饿:避免某些线程长期无法获得锁或资源的情况。我们可以试想一个极端高并发场景,100个线程请求1000次,如果没有队列来管理这些线程,可能某些线程因为被其他线程'插队'在1000次请求中1次也没办法获取到锁。而如果是在队列中等待,那么这些线程获取到锁的几率就提高了,也就是避免饥饿。
  • 提高并发性能:使用队列可以有效管理并发访问共享资源的线程,避免了竞争条件和混乱状态的发生。此外队列还可以实现线程的等待和通知机制,有助于合理调度线程的执行顺序,减少线程的上下文切换,提高了程序的并发执行效率。

我们看下非公平锁获取锁的逻辑如下:

2 AQS

2.1 AQS的数据结构

数据结构核心就是三个,state(状态),exclusiveOwnerThread(当前持有锁的线程),clh(队列)

2.1.1 state状态

AQS类中锁的状态 State(资源):AQS中state使用volatile修饰的int类型值表示同步状态,通过内置的 FIFO 线程等待/等待队列 来完成获取资源线程的排队工作。

arduino 复制代码
// 状态在AQS中使用volatile修饰
private volatile int state; 

一般当资源state为0表示锁没有被占用,state大于0表示当前已经有线程持有该锁。(如果是非可重入锁,那么state不会大于1,如果是可重入锁,那么以获取锁的线程再次获取锁便会把状态继续+1)

独占模式下用state控制同步状态简单描述如下(不考虑可重入):

2.1.2 exclusiveOwnerThrea持有锁的线程

java 复制代码
//是存储于AbstractQueuedSynchronizer的父类
public abstract class AbstractOwnableSynchronizer implements java.io.Serializable {
    private transient Thread exclusiveOwnerThread;
}

可以看到结构就是Thread对象,代表了当前持有锁的线程

2.1.3 clh队列

AQS 类的核心数据结构是一种名为 Craig, Landin, and Hagersten(下称 CLH)队列的变体,在AQS中的CLH队列的变体是虚拟的FIFO(first-in-first-out)双向队列。具体的结构如下:

可以看到CLH队列是以Node为载体的,根据前后指针关联得到的队列。

Node结构重点属性介绍:

方法和属性 含义
thread 当前节点对应的线程
waitStatus 当前节点的状态
prev 前驱指针,指向前一个节点
next 后继指针,指向下一个节点

其中,waitStatus有以下几个枚举

枚举 数值 描述
DEFAULT(实际代码没有) 0 节点Node初始化的时候int默认值
CANCELLED 1 获取锁请求取消。被中断或获取同步状态超时的线程将会被置为当前状态,且该状态下的线程不会再阻塞。重点关注
SIGNAL -1 线程已经准备就绪,就等资源释放。重点关注
CONDITION -2 当前节点在Condition中的等待队列上,节点等待唤醒,不多分析
PROPAGATE -3 和共享模式有关(多个线程可同时持有锁,如Semaphore/CountDownLatch),ReentrantLock是独占模式,不多分析

2.2 AQS流程

我们回到ReentrantLock加锁的方法,都会调用到acquire方法。这个方法也是AQS框架中的方法

scss 复制代码
// java.util.concurrent.locks.AbstractQueuedSynchronizer

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

如之前图里所示。AQS框架加锁一共做了3件大事,下面我们挨个分析

  1. tryAcquire方法
  2. acquireQueued方法
  3. selfInterrupt方法

2.2.1 tryAcquire方法 - 尝试获取锁

我们看下默认AQS框架中的tryAcquire方法

arduino 复制代码
protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();
}

可以发现AQS框架并没有实现这个方法,而是作为protected修饰的方法让子类去实现,如果子类没有实现那么在AQS框架中获取锁便会直接报错。

拓展一下,AbstractQueuedSynchronizer本身是一个抽象类,但是实际没有任何抽象方法。这么设计的一个好处就是当子类继承了这个类,无需实现所有的抽象方法。(往往具体开发的自定义锁的类不会用到AQS框架的所有方法)而如果调用了AQS框架的某个方法却没有实现它,系统会通过报错的方式直接提醒开发者。

由于ReentrantLock实现的tryAcquire方法比较简单,这边直接通过贴代码的方式进行解释。以非公平锁为例:

java 复制代码
// 非公平锁
static final class NonfairSync extends Sync {
protected final boolean tryAcquire(int acquires) {
    return nonfairTryAcquire(acquires);
    }
}
// sync类下的具体方法
final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    // 获取锁的状态
    int c = getState();
    // c为0说明当前锁还没被任何其他线程获取,那么此时的状态是可以获取的状态
    if (c == 0) {
        // 尝试通过cas的方式更新state的值,如果成功说明获取锁成功
        if (compareAndSetState(0, acquires)) {
            // cas更新状态值成功,说明此时已经获取到锁了,设置当前线程为占用锁的线程
            setExclusiveOwnerThread(current);
            return true;
            }
        }
    // c不为0,那么可能是大于0的值。先判断当前线程是否为占用锁的线程
    else if (current == getExclusiveOwnerThread()) {
        // 对state值加1,体现了可重入
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    // 加锁失败
    return false;
}

2.2.1 addWaiter方法 - 线程加入锁等待队列

先分析下具体代码实现

java 复制代码
private Node addWaiter(Node mode) {
    // 把当前线程和锁模式包装成一个节点
    Node node = new Node(Thread.currentThread(), mode);
    // 把pred指针指向尾节点
    Node pred = tail;
    // 如果pred不为空,说明CLH等待队列肯定存在尾节点,即队列不为空
    if (pred != null) {
        // 当前节点的前驱指针指向尾结点
        node.prev = pred;
        // 尝试通过cas方法设置当前节点为尾结点,成功即获取锁
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    // 指定到这有两种场景 1.不存在尾结点,即队列为空 2.cas更新尾节点为当前节点失败
    // 下面重点分析enq方法
    enq(node);
    return node;
}
private final boolean compareAndSetTail(Node expect, Node update) {
	return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
}

2.2.1.2 enq方法 - 线程进入队列

根据上面的方法,我们可以发现进入到enq这个方法的场景有两个。

  1. 等待队列为空,即队列还不存在任何节点
  2. 并发场景下,其他线程已经率先完成更新尾结点。导致当前线程通过CAS更新尾结点失败

那么这时候就需要将线程对应的节点进入等待队列。我们先看下enq的源码

java 复制代码
private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        // 尾结点存在空的情况,说明这个队列目前存在空的情况。
        // 懒加载的方式生成队列
        if (t == null) { // Must initialize
            // 这里new Node() 会生成一个空节点,从构造方法看出空节点是没有关联任何线程
            // 设置空节点做为头结点
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            // 到这说明队列已经不为空了
            // 步骤1 - 设置当前节点的前驱节点为尾结点
            node.prev = t;
            // 步骤2 - 循环中,通过CAS设置尾结点指向当前节点
            if (compareAndSetTail(t, node)) {
                // 步骤3 - 原来的尾结点将他的后驱节点指向当前节点
                t.next = node;
                return t;
            }
        }
    }
}

可见这个方法做了两件事情。1. 如果队列为空,会先设置一个空节点作为头结点完成队列的初始化(头结点不含线程信息)。2. 继续循环,最终将当前节点作为为尾节点加入队列。

我们重点分析下当队列不为空时加入队列的场景,通过代码里的三个步骤会发现一个有趣的现象。 正常我们的操作如下:

通过这三个步骤会将当前线程会加入到这个CLH变体队列中,但是由于第一步中设置当前节点的前驱节点为尾节点,这个操作不是加锁的。而只有到了第二步CAS设置尾结点才是加锁的(确保能只有1个线程能更新成功)。所以当在高并发场景时,存在大量线程同时完成了步骤一,但没有完成步骤二的操作。此时会出现一个尾分叉的现象:

可以发现在原来的尾结点,会存在很多节点的前驱节点会绑定了这个尾节点。但是只有一个节点可以成功作为原来尾结点的后驱节点。而其它节点会因为CAS操作失败而进行循环再次尝试绑定最新的尾结点。

但是由于在步骤一我们是通过无锁的方式给当前节点关联设置了前驱节点,这样会存在一个隐患:如果还没有节点完成步骤3的操作,将历史的尾节点指向新的节点。同时恰巧此时有线程在从头结点开始向后遍历整个队列,会发现遍历不到这个新来的尾结点。但是这是不合理的,因为我们已经完成了步骤2的操作设置更新了尾结点,那么这个节点实际已经入队。

解决的办法其实也很简单,我们通过尾结点开始向前遍历即可。因为在完成步骤二的前提是完成了步骤一,已经绑定好了前驱节点。这也是为什么在AQS的很多源码中,常常出现从尾结点开始向前遍历,而不是从头结点开始向后遍历。原因就是一个节点能入队,他的前驱节点一定是有值的,而它的后驱节点可能暂时还没有绑定。

总结:

  1. 我们的CLH队列是懒加载的队列,一般到这个方法才会生成队列。
  2. 我们的头节点是一个空节点,其本身不含任何线程信息,也不是当前节点。他的作用就是占位。他的后续节点才是真正包含线程信息的节点。
  3. addWaiter方法实现的就是向一个双端链表添加尾节点的操作。当enq方法完成,当前线程就完成了入队操作
  4. 什么是尾分叉的现象,以及什么时候我们应该逆向通过尾结点开始进行遍历。

2.2.2 acquireQueued - 队列中获取锁

当上一步我们将节点加入队列之后,便应该让队列中的线程开始获取锁或者将其挂起。我们看一下源码:

ini 复制代码
// java.util.concurrent.locks.AbstractQueuedSynchronizer

// 注意入参的node,是我们的当前节点,也是在上一个方法入队后的尾结点
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);
    }
}

private void setHead(Node node) { 	
    head = node; 	
    node.thread = null; 	
    node.prev = null; 
}

我们分场景来看。

场景1:当前节点(入队后的尾结点)的前驱节点就是头结点时,说明目前这个线程已经排在了队列的最前面。

根据FIFO队列的特性,此时这个线程应该最有机会获取到锁。为什么不是说一定呢?因为如果是非公平锁可能还是存在竞争(非公平锁线程会在刚开始进入获取锁的方法便尝试开始获取锁)。所以这里当前线程会尝试再次获取锁。假设此时获取锁成功,设置当前节点为头结点,并且将头结点的信息置空。变相的把原来的头结点出队列。

场景2:当前节点的前驱节点不是尾结点,或者尝试获取锁失败。那么会进入shouldParkAfterFailedAcquire方法判断当前节点是否应该被阻塞。

2.2.2.1 shouldParkAfterFailedAcquire - 判断当前线程是否应该被阻塞

我们分析下源码:

arduino 复制代码
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    // 获得前驱节点的waitStatus状态
    int ws = pred.waitStatus; 
    if (ws == Node.SIGNAL)
        // 前驱节点的状态已经是SIGNAL了,说明已经设定通知,直接返回
        return true;
    if (ws > 0) {
        // 当前节点的 ws > 0, 只有 Node.CANCELLED 状态是大于0的。说明前驱节点已经取消了等待锁(由于超时或者中断等原因)
        // 那就继续往前找, 直到找到非取消状态的前驱节点
        do {
            // 剔除取消节点绑定关系
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        // 在ReentrantLock下,如果走到这个分支说明此时ws值是初始值即0
        // CAS设置节点的前驱节点为通知信号状态
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

AQS里和ReentrantLock相关的waitStatus一共有三种。

  1. CANCELLED 值为1。 代表取消状态。(设置的场景有列如当线程等待超时,AQS会将该线程对应的等待状态设置为取消;亦或是当线程在等待获取锁的过程中被中断等)
  2. SIGNAL 值为-1。代表通知信号状态
  3. DEFAULT 不在代码的枚举中,值为int的默认值0。代表节点此状态还没有做处理

具体流程如下所示:

通过当前线程的前驱节点的waitStatus来判断是否应该挂起当前节点。换句话说waitStatus的作用不是给当前节点使用,而是给后继节点使用而设置的。当前的节点的前驱节点如果有设置为了通知信号的状态,那么当前节点便可以被放心的挂起了。因为到了某个指定的时间,自然会有前面的节点来通知唤起当前节点。

2.2.3 parkAndCheckInterrupt - 挂起线程

这是获取锁的最后一步,就是将满足挂起条件的线程挂起,避免轮训浪费cpu的资源。

arduino 复制代码
private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    return Thread.interrupted();
}

通过LockSupport.park(this)方法我们便将当前节点对应的线程挂起。

此后除非被前驱节点通知唤醒或者线程被中断,这个线程是不会醒来的。

总结

  1. AQS的核心思想就是使用一个共享的同步状态来控制对共享资源的访问,通过内部的队列和状态变量来实现线程的排队和争夺锁的操作
  2. AQS框架实际的核心内容其实就是CAS操作,CLH队列和状态。通过这三块核心内容协作构造完成了AQS框架的实现。
  3. 通过学习AQS,我们可以更好地理解并发编程的原理和技术,提高程序的性能和可靠性。
相关推荐
yaoxin5211237 分钟前
292. Java Stream API - 使用构建器模式创建 Stream
java·开发语言
阮松云14 分钟前
code-server 配置maven
java·linux·maven
木木木一19 分钟前
Rust学习记录--C11 编写自动化测试
java·学习·rust
bug总结22 分钟前
uniapp+动态设置顶部导航栏使用详解
java·前端·javascript
a努力。26 分钟前
字节跳动Java面试被问:一致性哈希的虚拟节点和数据迁移
java·开发语言·分布式·算法·缓存·面试·哈希算法
qq_3181215927 分钟前
互联网大厂Java面试故事:支付与金融服务微服务架构、消息队列与AI风控全流程解析
java·spring boot·redis·微服务·kafka·支付系统·金融服务
文慧的科技江湖33 分钟前
重卡的充电桩一般都是多少千瓦? - 慧知开源充电桩平台
java·开发语言·开源·充电桩开源平台·慧知重卡开源充电桩平台
短剑重铸之日42 分钟前
《7天学会Redis》Day 3 - 持久化机制深度解析
java·redis·后端·缓存
独自破碎E1 小时前
【前序+中序】重建二叉树
java·开发语言
LawrenceMssss1 小时前
由于创建一个完整的App涉及到多个层面(如前端、后端、数据库等),并且每种语言通常有其特定的用途(如Java/Kotlin用于Android开发,Swift/Objective-C用于iOS开发,Py
android·java·ios