关于AQS的那些事

ps:好久没正儿八经地(吹水)写写技术文章了,本篇主要讲讲AQS。友善提醒,本篇篇幅有亿点点长,还请读者精心耐心阅读,希望对你有所帮助

一、前言

关于AQS,大家对它的认识可能是“AQS,抽象队列同步器嘛,主要是用来实现锁或同步器的基础类。”

这回答完全没问题,但是再进一步问一个问题:

  • (实用性)“如何基于AQS自定义一个锁”?
  • (本质思想)“AQS是如何解决CAS恶性空自旋(AQS的核心思想是什么)?”

是不是立马慌了?

image.png

为了读者能够更加容易地学习了解AQS,我们先从AQS的使用开始讲解。

此外,一开始不要过度局限于细节,先对整体上有所认识和把握,再从整体往细节深挖。本篇主旨也是帮助读者建立对AQS的整体理解,不是细扣细节 (很大原因上还是因为笔者目前水平不够)

文章结构:
- 基于AQS自定义独占锁(一把超级简单的锁,从整体上对AQS的使用有所了解)。
- 基于自定义锁进一步分析AQS获取锁方法原理,释放锁方法原理。
- 对照ReentrantLock进行扩展学习。
- 分析提取AQS核心思想。

二、基于AQS自定义独占锁

2.1 建锁

1、场景:王婆卖瓜

菜市场中有一位王婆在卖西瓜,一群大妈强着买瓜。请你帮王婆统计一下卖出瓜的数目。

2、思路:构建一把独占锁,每时刻只卖出一个西瓜并统计卖瓜数量。

3、设计:

image.png

(1)自定义锁实现锁接口,实现上锁和解锁方法(先忽略其他方法);
(2)定义具体类继承AQS,并重写钩子方法实现自定义上锁、解锁逻辑。(AQS运用了模板模式,模板模式的关键在于:父类提供框架性的公共逻辑,子类提供个性化的定制逻辑。 不懂的点击这里传送站);
(3)基于组合关系,在自定义锁中调用AQS相关模板方法(模板方法中会调用子类所重写的钩子方法)。

4、代码实现:

package com.watermelonhit.aqs;

import org.jetbrains.annotations.NotNull;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

/**
 * @Description 卖瓜独占锁
 * @Author watermelonhit
 * @Date 2023/9/1 23:31
 */
public class WatermelonLock implements Lock {
    /**
     * 同步器实例
     */
    private final Sync sync = new Sync();

    /**
     * 自定义的内部类:同步器
     * 直接使用 AbstractQueuedSynchronizer.state 值表示锁的状态
     * AbstractQueuedSynchronizer.state=1 表示锁没有被占用
     * AbstractQueuedSynchronizer.state=0 表示锁已经被占用
     */
    private static class Sync extends AbstractQueuedSynchronizer {

        private static final long serialVersionUID = -3684293842967646452L;

        // 上锁钩子方法
        @Override
        protected boolean tryAcquire(int arg) {
            // CAS更新状态值为1
            if (compareAndSetState(0, 1)) {
                // 标识持有锁的是当前线程
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }

        // 解锁钩子方法
        @Override
        protected boolean tryRelease(int arg) {
            // 如果当前线程不是占用锁的线程
            if (Thread.currentThread() != getExclusiveOwnerThread()) {
                //抛出非法状态的异常
                throw new IllegalMonitorStateException();
            }
            // 如果锁的状态为没有占用
            if (getState() == 0) {
                // 抛出非法状态的异常
                throw new IllegalMonitorStateException();
            }

            //接下来不需要使用CAS操作,因为下面的操作不存在并发场景
            setExclusiveOwnerThread(null);
            //设置状态
            setState(0);
            return true;
        }
    }

    /**
     * 显式锁的抢占方法
     */
    @Override
    public void lock() {
        // 委托给同步器的acquire()抢占方法
        sync.acquire(1);
    }

    /**
     * 显式锁的释放方法
     */
    @Override
    public void unlock() {
        // 委托给同步器的release()释放方法
        sync.release(1);
    }
    
    // ======可以忽略以下方法==========


    @Override
    public void lockInterruptibly() throws InterruptedException {

    }

    @Override
    public boolean tryLock() {
        return false;
    }

    @Override
    public boolean tryLock(long time, @NotNull TimeUnit unit) throws InterruptedException {
        return false;
    }

    @NotNull
    @Override
    public Condition newCondition() {
        return null;
    }
}


核心逻辑:Sync同步器继承了AQS,自定义了上锁、解锁逻辑。主要是修改AQS的锁状态标识:

  • state为1时,表示锁已被线程持有;
  • state为0时,表示当前锁处于空闲状态;

CAS操作state字段,将其值从0改为1,若成功,则表示锁未被占用,可成功占用,并且返回true;若失败,则获取锁失败,返回false。

# java.util.concurrent.locks.AbstractQueuedSynchronizer#state
    /**
     * The synchronization state.
     */
    private volatile int state;

通过重写两个钩子方法,我们便简单实现了一把自定义锁。这时对“AQS,是用来实现锁或同步器的基础类”是否有了进一步理解?

我们只需关注上锁和解锁逻辑,而抢锁线程的阻塞和唤醒均已被AQS抽象封装好了。AQS很好地体现了“分离变与不变原则”。

2.2 测锁

1、模拟:100位大妈(每位大妈买200个瓜)争抢买王婆的西瓜,统计卖瓜数量。

2、测试代码:

如果你不是很想看测试代码,那也没问题。
这里一句话总结一下测试代码的核心逻辑:一个整数基于锁的方式并发(多线程)自增。

/**
 * @Description 锁测试工具类
 * @Author watermelonhit
 * @Date 2023/9/2 0:02
 */
public class LockTest {

    @Test
    public void testWatermelonLock() {
        // 每个线程的执行轮数 (每位大妈买的西瓜数量)
        final int TURNS = 200;
        // 线程数(大妈数量)
        final int THREADS = 100;
        // 线程池,用于多线程模拟测试
        ExecutorService pool = Executors.newFixedThreadPool(THREADS);
        // 自定义的独占锁
        Lock lock = new WatermelonLock();
        // 倒数闩
        CountDownLatch countDownLatch = new CountDownLatch(THREADS);
        long start = System.currentTimeMillis();
        // 100个线程并发执行
        for (int i = 0; i < THREADS; i++) {
            pool.submit(() ->
            {
                try {
                    // 累加 200 次
                    for (int j = 0; j < TURNS; j++) {
                        // 传入锁,执行一次累加
                        IncrementData.lockAndFastIncrease(lock);
                    }
                    System.out.println("一位大妈完成抢买瓜流程");
                } catch (Exception e) {
                    e.printStackTrace();
                }
                // 线程执行完成,倒数闩减少一次
                countDownLatch.countDown();
            });
        }
        try {
            //等待倒数闩归零,所有线程结束
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        float time = (System.currentTimeMillis() - start) / 1000F;
        //输出统计结果
        System.out.println(("运行的时长为:" + time));
        System.out.println("累加结果为:" + IncrementData.sum);
    }

    static class IncrementData {
        public static int sum = 0;

        public static void lockAndFastIncrease(Lock lock) {
            lock.lock(); //step1:抢占锁
            try {
                //step2:执行临界区代码
                sum++;
            } finally {
                lock.unlock(); //step3:释放锁
            }
        }

    }

}

3、测试结果

100*200=20000,本次王婆卖瓜顺利结束,先自夸一波。

……
一位大妈完成抢买瓜流程
一位大妈完成抢买瓜流程
运行的时长为:0.017
累加结果为:20000
2.3 拆锁

在上述案例中,我们自定义的上锁、解锁逻辑:

  • tryAcquire(上锁):修改AQS的锁标识为1,并标识持有锁的线程为当前线程。
  • tryRelease(解锁):清空当前持有锁的线程,修改AQS的锁标识为0。

你是否好奇,这上锁,解锁的背后逻辑是什么?抢锁失败的线程如何处理?阻塞等待吗?那又是如何唤醒的?……

后面我们将基于上述案例进一步剖析上锁、解锁的背后逻辑,一步步揭晓谜底。

三、上锁、解锁的背后逻辑

3.1 上锁

1、总体流程图:

image.png

我们将围绕这流程图一步步展开讲解。

2.1 自定义上锁lock

1、逻辑:调用同步器Syn继承AQS的acquire模板方法。

2、代码:

 /**
     * 显式锁的抢占方法
     */
    @Override
    public void lock() {
        // 委托给同步器的acquire()抢占方法
        sync.acquire(1);
    }

3、小结:

自定义锁的最外层入口Lock方法内部直接调用AQS的acquire方法,基于AQS进行独占方式获取资源。

image.png

2.2 AQS 模板方法:acquire(arg)

1、逻辑:

acquire是AQS封装好的获取资源的公共入口,它是AQS提供的利用独占方式获取资源的方法。

在模板方法acquire中,若调用tryAcquire(arg)尝试成功,则acquire()将直接返回,表示已经抢到锁;若不成功,则将线程加入等待队列。

2、代码:

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

3、小结:

模板方法acquire中,会先调用子类自定义的tryAcquire(arg)去尝试获取锁,若获取锁成功则acquire()将直接返回。若不成功,则将线程加入等待队列。(失败情况的处理已被AQS抽象封装处理,这将是我们重点要探究的点)。

image.png

2.3 钩子方法:tryAcquire(…)

1、逻辑:

CAS操作state字段,将其值从0改为1,若成功,则表示锁未被占用,可成功占用,并且返回true;若失败,则获取锁失败,返回false。

2、代码:

// 上锁钩子方法
        @Override
        protected boolean tryAcquire(int arg) {
            // CAS更新状态值为1
            if (compareAndSetState(0, 1)) {
                // 标识持有锁的是当前线程
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }

3、小结:

AQS上锁流程.drawio.png

2.4 直接入队:addWaiter(…)

0、补充:AQS中的等待队列是双向链表进行模拟,双向链表基于队头、队尾指针表示。

/*首节点的引用*/
private transient volatile Node head;
/*尾节点的引用*/
private transient volatile Node tail;

数据结构图形化如下:

image.png

其中Node节点的核心字段如下(节点状态信息可先不细究,非本文讲解重点,背后逻辑有点复杂)

    static final class Node {
        //节点状态:值为SIGNAL、CANCELLED、CONDITION、PROPAGATE、0
       //普通的同步节点的初始值为0,条件等待节点的初始值为CONDITION(-2)
        volatile int waitStatus;
        //节点所对应的线程,为抢锁线程或者条件等待线程
        volatile Thread thread;
        //前驱节点,当前节点会在前驱节点上自旋,循环检查前驱节点的waitStatus状态
        volatile Node prev;
        //后驱节点
        volatile Node next;
        //如果当前Node不是普通节点而是条件等待节点,则节点处于某个条件的等待队列上
//此属性指向下一个条件等待节点,即其条件队列上的后驱节点
        Node nextWaiter;

        /**节点等待状态值1:取消状态*/
        static final int CANCELLED = 1;
        /**节点等待状态值-1:标识后继线程处于等待状态*/
        static final int SIGNAL = -1;
        /**节点等待状态值-2:标识当前线程正在进行条件等待*/
        static final int CONDITION = -2;
        /**节点等待状态值-3:标识下一次共享锁的acquireShared操作需要无条件传播*/
        static final int PROPAGATE = -3;
...
    }

1、逻辑(整体):

将抢锁失败的线程封装为Node,CAS修改队尾指向,将Node插入到队尾中。

2、代码:

    private Node addWaiter(Node mode) {
        //创建新节点,mode主要用于标识独占or共享
        Node node = new Node(Thread.currentThread(), mode);
        // 加入队列尾部,将目前的队列tail作为自己的前驱节点pred
        Node pred = tail;
        // 队列不为空的时候
        if (pred != null) {
            node.prev = pred;
        // 先尝试通过AQS方式修改尾节点为最新的节点
        // 如果修改成功,将节点加入到队列的尾部
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        //第一次尝试添加尾部失败,意味着有并发抢锁发生,需要进行自旋
        enq(node);
        return node;
    }

上述代码中,划分为了两步:
(1)若队列不为空则先进行一次CAS队尾修改操作,修改成功则return;
(2)若队列为空或cas修改失败,则进行自旋修改队尾操作,确保线程最终加入到等待队列中。

 private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) {
                //队列为空,初始化队尾节点和队首节点为新节点
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                // 队列不为空,将新节点插入队列尾部
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }
    /**
     * CAS操作head指针,仅仅被enq()调用
     */
    private final boolean compareAndSetHead(Node update) {
        return unsafe.compareAndSwapObject(this, headOffset, null, update);
    }
    /**
     * CAS操作tail指针,仅仅被enq()调用
     */
    private final boolean compareAndSetTail(Node expect, Node update) {
        return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
    }

一个小细节,enq方法返回的是旧队尾节点,即当前队尾节点的前驱节点。

3、小结:

如果看到这里觉得有点😵,请记住不要局限于细节中(具体如何插入到队列中),先记住下面的就行。

addWaiter(…)方法主要用于将抢锁失败的线程封装为Node,然后基于CAS方式(修改队尾指向)插入到队尾中。

未命名绘图.drawio.png

2.5 自旋抢占:acquireQueued(…)

问个灵魂问题,直到这一步,抢锁失败的线程是否仍在正常运行还是说已经陷入阻塞状态了?

如果你觉得已经陷入阻塞状态了,请重新回去看一遍!!!

抢锁失败的线程仍在正常运行,为了确保后续临界资源的线程安全,肯定不能就这么直接放行线程去运行后续的用户逻辑代码,起码得让它获取到锁后才放行。
后续处理中要么让它一直去争抢资源,要么让它阻塞等待。

1、逻辑:

当前Node节点线程在死循环中不断判断前驱节点是否为队首和获取同步状态,若均符合则设置当前节点为队首并跳出循环去执行后续用户逻辑代码。否则则进行阻塞状态或重新尝试。

结构图形化:
image.png

2、代码:

    final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            // 自旋检查当前节点的前驱节点是否为队首节点,才能获取锁
            for (; ; ) {
                // 获取节点的前驱节点
                final Node p = node.predecessor();
                // 节点中的线程循环的检查自己的前驱节点是否为head节点
                // 只有前驱节点是head时,进一步调用子类的tryAcquire(…)实现
                if (p == head && tryAcquire(arg)) {
                    // tryAcquire成功后,将当前节点设置为队首节点,移除之前的队首节点
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                // 检查前一个节点的状态,预判当前获取锁失败的线程是否要挂起
                // 如果需要挂起
                // 调用parkAndCheckInterrupt方法挂起当前线程,直到被唤醒
                if (shouldParkAfterFailedAcquire(p, node) &&
                        parkAndCheckInterrupt())
                    interrupted = true; // 若两个操作都是true,则为true
            }
        } finally {
            //如果等待过程中没有成功获取资源(如timeout,或者可中断的情况下被中断了)
            //那么取消节点在队列中的等待
            if (failed)
            //取消请求,将当前节点从队列中移除
                cancelAcquire(node);
        }
    }

为减少空自旋耗费CPU资源,自旋过程中会阻塞线程,等待前驱节点唤醒后才启动循环。

为了知识的完整性,这里对shouldParkAfterFailedAcquire方法和parkAndCheckInterrupt进行简单讲解。(可略过,记住acquireQueued的大体处理逻辑就够了,shouldParkAfterFailedAcquire主要是去修改节点状态顺带在阻塞前多去重试几次判断和获取同步状态;parkAndCheckInterrupt就是让当前线程进行阻塞)

  • 挂起预判:shouldParkAfterFailedAcquire
    1、逻辑:
    acquireQueued()自旋在阻塞自己的线程之前会进行挂起预判。
    shouldParkAfterFailedAcquire()方法的主要功能是:找到当前节点的有效前驱节点(是指有效节点不是CANCELLED类型的节点),并且将有效前驱节点的状态设置为SIGNAL,若当前前驱节点状态已为SIGNAL则返回true代表当前线程可被阻塞了。
    2、代码:
    private static boolean shouldParkAfterFailedAcquire(
            Node pred, Node node) {
        int ws = pred.waitStatus; // 获得前驱节点的状态
        if (ws == Node.SIGNAL) //如果前驱节点状态为SIGNAL(值为-1)就直接返回
            return true;
        if (ws > 0) { // 前驱节点以及取消CANCELLED(1)
            do {
                // 不断地循环,找到有效前驱节点,即非CANCELLED(值为1)类型节点
                //将pred记录前驱的前驱
                pred = pred.prev;
                //调整当前节点的prev指针,保持为前驱的前驱
                node.prev = pred;
            } while (pred.waitStatus > 0);
            //调整前驱节点的next指针
            pred.next = node;
        } else {
            //如果前驱状态不是CANCELLED,也不是SIGNAL,就设置为SIGNAL
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
            //设置前驱状态之后,此方法返回值还是为false,表示线程不可用,被阻塞
        }
        return false;
    }
  • 线程挂起:parkAndCheckInterrupt()
    1、逻辑:
    暂停当前线程,被唤醒后返回当前线程是否被中断。
    2、代码:
    private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this); // 调用park()使线程进入waiting状态
        return Thread.interrupted(); // 如果被唤醒,查看自己是否已经被中断
    }

AbstractQueuedSynchronizer会把所有的等待线程构成一个阻塞等待队列,当一个线程执行完lock.unlock()时,会激活其后驱节点,通过调用LockSupport.unpark(postThread)完成后继线程的唤醒。

3、小结:

addWaiter方法将抢锁失败的线程封装为node并加入到AQS等待队列中的队尾中,而acquireQueued方法主要用于进一步处理抢锁失败的线程,在死循环中要么重试判断和获取同步状态,成功则跳出。要么进行阻塞去等待前驱节点的唤醒。

未命名绘图.drawio.png

2.6 总结

现在对整个上锁流程进行一波总结。(为什么本篇文章进行大量总结?一方面刚接触AQS时确实不好理解,一方面本篇文章篇幅可能比较长,怕读者看着看着思路就断了,故采用了类似断点保存方式进行讲叙)

首先,AQS的独占上锁模板方法会去调用子类自定义的钩子方法,从而执行自定义的抢锁逻辑。

  • 若抢锁成功则直接return;
  • 若抢锁失败,则会将当前线程封装为Node,然后以CAS的方式将节点插入到AQS等待队列中的队尾。插入完成后,节点线程会在死循环中不断前驱节点是否为队首和获取同步状态,若成功则设置当前节点为队首并return,否则则进行阻塞等待前驱节点的唤醒。(前驱节点释放锁时会顺便唤醒后续节点)。

上述全程没出现方法名?为啥呢?说了也记不了几天,意义也不大,那还不如干脆不记了。

最后,再来个极简的流程图:

上锁流程图.drawio.png

3.2 解锁

1、总体流程图:

相对于上锁,解锁流程简单了许多。所以不再畏惧它,再把解锁看完。

image.png

2.1 自定义解锁unlock()

1、逻辑:

调用同步器Syn继承AQS的release模板方法。

2、代码:

 /**
     * 显式锁的释放方法
     */
    @Override
    public void unlock() {
        // 委托给同步器的release()释放方法
        sync.release(1);
    }
2.2 AQS模板方法:release(…)

1、逻辑:

release是AQS封装好的释放资源的公共入口。

在模板方法acquire中,若钩子方法tryRelease执行成功,则会去尝试唤醒队首节点的后续节点(若队首不为空且队首状态不为初始状态)。

2、代码:

public final boolean release(long arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }
2.3 钩子方法:tryRelease(…)

1、逻辑:

重置锁同步状态为0,清空当前持有锁的线程标识。

2、代码:

        // 解锁钩子方法
        @Override
        protected boolean tryRelease(int arg) {
            // 如果当前线程不是占用锁的线程
            if (Thread.currentThread() != getExclusiveOwnerThread()) {
                //抛出非法状态的异常
                throw new IllegalMonitorStateException();
            }
            // 如果锁的状态为没有占用
            if (getState() == 0) {
                // 抛出非法状态的异常
                throw new IllegalMonitorStateException();
            }

            //接下来不需要使用CAS操作,因为下面的操作不存在并发场景
            setExclusiveOwnerThread(null);
            //设置状态
            setState(0);
            return true;
        }
2.4 唤醒后驱:unparkSuccessor()

1、逻辑:

找到队首的有效后驱节点并唤醒。

2、代码:

    private void unparkSuccessor(Node node) {
        int ws = node.waitStatus; // 获得节点状态,释放锁的节点,也就是队首节点
        //CANCELLED(1)、SIGNAL(-1)、CONDITION (-2)、PROPAGATE(-3)
        //如果队首节点状态小于0,则将其置为0,表示初始状态
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);
        Node s = node.next; // 找到后面的一个节点
        if (s == null || s.waitStatus > 0) {
        // 如果新节点已经被取消CANCELLED(1)
            s = null;
        //从队列尾部开始,往前去找最前面的一个waitStatus小于0的节点
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0) s = t;
        }
        //唤醒后驱节点对应的线程
        if (s != null)
            LockSupport.unpark(s.thread);
    }

3、小结:

unparkSuccessor方法会从自队尾往队首找到最前面的有效节点(即队首的后驱节点)并唤醒,让后驱节点进行醒后重新自旋抢锁。

你可能会问为什么要采用从后往前的方式去寻找有效节点?

笔者个人认为是为了确保数据的安全准确性。假设采用从前往后方式寻找有效节点,若队首节点的next为空但是是有后续节点的,只是队首的next还没指向后续节点,这种情况不就没法正确唤醒后续节点了。

其实,上面说讲的问题主要取决于插入节点时所采用的方式。AQS中采用的方式是:新建一个Node节点,将Node节点的前驱指针指向tail节点,然后CAS修改tail指向,若修改成功再将旧队尾节点的next指向新节点。

插入逻辑代码如下:

 private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) {
                //队列为空,初始化队尾节点和队首节点为新节点
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                // 队列不为空,将新节点插入队列尾部
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

最后,再说明一个小点:当AQS队首节点释放锁之后,队首节点有没有被移除?若没有的话,队首节点是在哪里被移除?

释放锁过程中并没有移除队首节点,而是将移除操作延缓到了上锁过程中。还记得自旋抢占acquireQueued方法吗?在该方法中,线程会死循环去判断当前节点的前驱节点是否为前驱节点并尝试获取同步状态,若成功则将队首节点改为当前节点(即旧队首节点被移除了)。

这也是为什么要判断当前节点的前驱节点是否为队首节点的一部分原因。
总结一下原因:主要是为了维护同步队列的FIFO原则。队首节点是成功获取同步状态(锁)的节点,其次要获取同步状态的便是其后续节点,即当前节点的前驱节点为队首节点时才可以进行同步状态的获取。

2.5 总结

AQS独占解锁模板方法会调用执行子类自定义的解锁钩子方法,若执行成功则会尝试唤醒队首节点的有效后续节点,使其重新进入自旋抢占流程。

四、ReentrantLock的上锁、解锁

4.1 ReentrantLock 的抢锁流程

ReentrantLock有两种模式:

  • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁。
  • 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的。

ReentrantLock在同一个时间点只能被一个线程获取,ReentrantLock是通过一个FIFO的等待队列(AQS队列)来管理获取该锁所有线程的。ReentrantLock是继承自Lock接口实现的独占式可重入锁,并且ReentrantLock组合一个AQS内部实例完成同步操作。

4.1.1 ReentrantLock 非公平锁的抢占流程

1、总体流程图:

image.png

2、逻辑:

(1)非公平锁的同步器子类 NonfairSync

其显式锁获取方法lock()的源码如下:

static final class NonfairSync extends Sync {
        //非公平锁抢占
        final void lock() {
            if (compareAndSetState(0, 1))
                setExclusiveOwnerThread(Thread.currentThread());
            else
                acquire(1);
        }
        //省略其他
    }

首先用一个CAS操作,判断state是否是0(表示当前锁未被占用),如果是0就把它置为1,并且设置当前线程为该锁的独占线程,表示获取锁成功。当多个线程同时尝试占用同一个锁时,CAS操作只能保证一个线程操作成功,剩下的只能乖乖去排队。

ReentrantLock“非公平”性即体现在这里:如果占用锁的线程刚释放锁,state置为0,而排队等待锁的线程还未唤醒,新来的线程就直接抢占了该锁。

对比咱们自定义的锁,ReentrantLock在lock方法中多了一步提前抢占操作。

(2)非公平抢占的钩子方法:tryAcquire(arg)

如果非公平抢占没有成功,非公平锁的lock会执行模板方法acquire(),首先会调用到钩子方法tryAcquire(arg)。非公平抢占的钩子方法实现如下:

 static final class NonfairSync extends Sync {
        //非公平锁抢占的钩子方法
        protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }

    }

    abstract static class Sync extends AbstractQueuedSynchronizer {
        
        final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            // 先直接获得锁的状态
            int c = getState();
            if (c == 0) {
                // 如果任务队列首节点的线程完了,它会将锁的state设置为0
                // 当前抢锁线程的下一步就是直接进行抢占,不管不顾
                // 发现state是空的,就直接拿来加锁使用,根本不考虑后面后驱者的存在
                if (compareAndSetState(0, acquires)) {
                   // 1. 利用CAS自旋方式判断当前state确实为0,然后设置成acquire(1)
                    // 这是原子性的操作,可以保证线程安全
                    setExclusiveOwnerThread(current);
                    // 设置当前执行的线程,直接返回true
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                // 2. 当前的线程和执行中的线程是同一个,也就意味着可重入操作
                int nextc = c + acquires;
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                // 表示当前锁被1个线程重复获取了nextc次
                return true;
            }
            // 否则就是返回false,表示没有尝试成功获取当前锁,进入排队过程
            return false;
        }
        
        //省略其他
    }

在钩子方法中,会基于锁的同步状态和持有锁的线程标准完成锁的获取或重入,同样无视了线程在队列中的排队顺序。

3、总结:

非公平同步器ReentrantLock.NonfairSync的核心思想就是当前进程尝试获取锁的时候,如果发现锁的状态位是0,就直接尝试将锁拿过来,然后执行setExclusiveOwnerThread(),根本不管同步队列中的排队节点。

在看ReentrantLock上锁源码时,不用刻意去记住哪个方法具体在哪个类中,学习时把整体链路抽取出来形成一个大体印象先,不然很容易被绕进去了。

简单总结一下ReentrantLock非公平上锁流程:

上锁时,首先会基于CAS方式修改锁的同步状态(锁状态为0时表示当前锁处于空闲状态)。若修改失败才执行AQS的模板方法,其次去执行上锁钩子方法,基于锁的同步状态和持有锁的线程标识完成锁的获取或重入,若执行成功则直接返回。否则进入AQS所封装好的抢锁失败处理流程。

上锁流程图.drawio.png

4.1.2 ReentrantLock 非公平锁的抢占流程

1、总体流程图:

image.png

2、逻辑:

(1)公平锁的同步器子类 FairSync

ReentrantLock为公平锁实现了一个内部的同步器——FairSync,其显式锁获取方法lock的源码如下:

static final class FairSync extends Sync {
        //公平锁抢占的钩子方法
        final void lock() {
            acquire(1);
        }
        //省略其他
    }

公平同步器ReentrantLock.FairSync的核心思想是通过AQS模板方法去进行队列入队操作。

(2)公平抢占的钩子方法:tryAcquire(arg)

公平锁的lock会执行模板方法acquire,该方法首先会调用钩子方法tryAcquire(arg)。公平抢占的钩子方法实现如下:

 static final class FairSync extends Sync {
        //公平抢占的钩子方法
        protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState(); //锁状态
            if (c == 0) {
                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;
        }
    }

公平抢占的钩子方法中,首先判断是否有后驱节点,如果有后驱节点,并且当前线程不是锁的占有线程,钩子方法就返回false,模板方法会进入排队的执行流程,可见公平锁是公平的。

FairSync判断在当前线程之前是否有其他线程在排队的代码如下:

    public final boolean hasQueuedPredecessors() {
        Node t = tail;
        Node h = head;
        Node s;
        return h != t &&
                ((s = h.next) == null || s.thread != Thread.currentThread());
    }

简简单单三个逻辑判断,组合起来却不好理解,建议不细扣,可按照下面思路梳理一下。

首先该方法用于判断在当前线程之前是否有其它线程在排队中,如果没有则返回false,表示当前线程可以去抢锁了。如果有则返回true,表示有其它线程比当前线程先在排队中,得乖乖去后面排队去。

该方法判断逻辑简洁描述:
1)当h!=t不成立的时候,说明h队首节点、t尾节点要么是同一个节点,要么都是null,此时hasQueuedPredecessors()返回false,表示没有没有其它线程在排队中。

2)当h!=t不成立的时候:

  • 进一步检查head.next是否为null,如果为null,就返回true,表示有其它线程在排在当前线程前。
    (什么情况下h!=t同时h.next==null呢?
    有其他线程第一次正在入队时可能会出现。其他线程执行AQS的enq()方法,compareAndSetHead(node)完 成 , 还 没 执 行 tail=head 语 句 时 , 此 时 t=null 、head=new Node()、head.next=null)

  • 若head.next不为null,判断head.next是不是当前线程,如果是就返回false(表示当前线程就排在了最前面),否则返回true。

还是建议不细扣这逻辑,记住这方法在公平锁中的应用逻辑:判断在当前线程前是否有其它线程在排队。更具体点就是:判断当前线程节点是否是队首节点的有效后驱节点(从应用层面上这句话还是稍微准确的,细节上就不是很准确了。这有效是指广义上的,如若当前队列为空,那当前节点(尽管当前线程还没封装为node)也是有效的后驱节点。

如果你要细抠细节,读者在这里想提问一个问题:“ReentrantLock的公平锁模式是真的绝对公平吗?”

公平性主要依赖于hasQueuedPredecessors方法,该方法主要用于判断在当前线程之前是否有其它线程在排队中。再看一下这一逻辑判断:
 return h != t &…
如果h==t,返回true表示没有其它线程排在当前前面,现实真如此吗?

首先看一下加入队列的方法:enq

private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) {
                //队列为空,初始化队尾节点和队首节点为新节点
                if (compareAndSetHead(new Node()))
                    tail = head;  // *
            } else {
                // 队列不为空,将新节点插入队列尾部
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }
    
想一下这种情况:线程一、二、三同时执行hasQueuedPredecessors:
(1)线程一首先完成抢锁;
(2)线程二由于执行cas获取锁同步状态失败,封装为node加入到队列中,刚到执行到创建为空节点并赋值为队头队尾。(即上述*行代码)
(3)线程三在执行h != t,由于h和t都等于空节点,那线程三就无视线程二直接去抢锁了。这还公平吗?虽然这是一种极端情况。

这也只是笔者的思考,可能有一些错误的地方,如果你有不同的见解热烈欢迎联系笔者交流一下!

3、总结:

公平锁在抢锁时会先判断是否有其它线程排队在当前线程之前,若有则不进行争抢而加入到等待队列中;若没有则争抢锁。

上锁流程图.drawio.png

4.2 ReentrantLock 的解锁

ReentrantLock 公平模式和非公平模式的解锁流程都是一样的,直接调用了AQS的解锁模板方法release,代码如下:

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

我们主要看一下对应的钩子方法:

        protected final boolean tryRelease(int releases) {
            int c = getState() - releases;
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            if (c == 0) {
                free = true;
                setExclusiveOwnerThread(null);
            }
            setState(c);
            return free;
        }

解锁逻辑也比较简单,若持有锁的线程是当前线程则完成锁的递减或释放(释放则会清空当前持有锁的线程标识)。

五、AQS锁的凝练

看到这里,你觉得AQS的特性是什么?或者“AQS是如何解决CAS恶性空自旋?”

给个小提示,AQS中基本都是队列的应用。

或者回归一开始造锁的场景:
“一群大妈围着抢王婆的西瓜,如何比较高效准确卖出西瓜呢?”

通常情况下是:围着抢

抢瓜.drawio.png

一群大妈(线程)在同一时刻抢占买同一颗瓜(CAS自旋),在同一时刻只能有一位大妈(线程)能够顺利买到西瓜(抢到锁)。其他大妈不就就相当于陪跑吗?(其余线程空自旋耗费没必要的CPU资源)。这不就是CAS恶性空自旋吗?

那有没有好一点的做法?有,那就是AQS中所使用的队列削峰方案。化集中抢为线性阻塞等待抢。

未命名绘图.drawio.png

大妈们先去争抢排队,然后线性依赖阻塞等待买瓜,还没排到买瓜的就先睡觉,等待前一位买好的大妈唤醒。

稍微说专业点就是:
++线程不围绕某一个热点资源进行集中自旋争抢,而是转移分散热点,自旋争抢排队顺序,形成等待队列,线性阻塞排队获取资源。++

本文篇幅挺长的,希望对读者关于AQS知识这块能够有所帮助。


本文到此结束!

参考资料:
Java高并发核心编程(卷2)

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值