前言

这篇文章写完放着也蛮久的了,今天终于发布了,对于拖延症患者来说也真是不容易~哈哈哈。

言归正传,其实吧。。我觉得对于大部分想了解 AQS 的朋友来说,明白 AQS 是个啥玩意儿以及为啥需要 AQS,其实是最重要的。就像我一开始去看 AQS 的时候,抱着代码就啃,看不懂就去网上搜。。但是网上文章千篇一律。。大部分都是给你逐行分析下代码然后就没了。。。但其实对我们来说我知道为啥要这么干其实也相当重要。。

嗯。。所以就有了这篇文章。。笔者会先给你介绍下 AQS 的作者为啥要整这个东西。。然后笔者再结合自身感悟给你划了划重点。。如果你认真读了。。肯定会有所收获的哦

一、AQS 是什么?为什么需要 AQS ?

试想有这么一种场景:有四个线程由于业务需求需要同时占用某资源,但该资源在同一个时刻只能被其中唯一线程所独占。那么此时应该如何标识该资源已经被独占,同时剩余无法获取该资源的线程又该何去何从呢?

这里就涉及到了关于共享资源的竞争与同步关系。对于不同的开发者来说,实现的思路可能会有不同。这时如果能够有一个较为通用的且性能较优同步框架,那么可以在一定程度上帮助开发人员快速有效的完成多线程资源同步竞争方面的编码。

AQS 正是为了解决这个问题而被设计出来的。AQS 是一个集同步状态管理、线程阻塞、线程释放及队列管理功能与一身的同步框架。其核心思想是当多个线程竞争资源时会将未成功竞争到资源的线程构造为 Node 节点放置到一个双向 FIFO 队列中。被放入到该队列中的线程会保持阻塞直至被前驱节点唤醒。值得注意的是该队列中只有队首节点有资格被唤醒竞争锁。

如果希望更具体的了解 AQS 设计初衷与原理,可以看下链接中的翻译版论文《The java.util.concurrent Synchronizer Framework》
https://www.cnblogs.com/dennyzhangdd/p/7218510.html

如果你能耐心看完上面这篇论文,接着再从以下几个点切入翻阅 AQS 源码,那就相当如鱼得水了:

  • 同步状态的处理
  • FIFO 队列的设计,如何处理未竞争到资源的线程
  • 竞争失败时线程如何处理
  • 共享资源的释放

后面的章节主要会结合 AQS 源码,介绍下独占模式下锁竞争及释放相关内容。

二、同步状态的处理

private volatile int state;

翻阅下 AQS 源码,不难发现有这么一个 volatile 类型的 state 变量。通俗的说这个 state 变量可以用于标识当前锁的占用情况。打个比方:当 state 值为 1 的时候表示当前锁已经被某线程占用,除非等占用的锁的线程释放锁后将 state 置为 0,否则其它线程无法获取该锁。这里的 state 变量用 volatile 关键字保证其在多线程之间的可见性。

protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();
}
protected boolean tryRelease(int arg) {
    throw new UnsupportedOperationException();
}

同时,我们发现 AQS 预留了个口子可以供开发人员按照自身需求进行二次重构。因此也就出现了类似与 ReentrantLock 可重入锁、CountDownLatch 等实现。

三、AQS 灵魂队列的设计

对于整个 AQS 框架来说,队列的设计可以说重中之重。那么为什么 AQS 需要一个队列呢?

对于一个资源同步竞争框架来说,如何处理没有获取到锁的线程是非常重要的,比方说现在有 ABCD 四个线程同时竞争锁,其中线程 A 竞争成功了。那么剩下的线程 BCD 该咋办呢?

我们可以尝试试想下自己会如何解决:

  1. 线程自旋等待,不断重新尝试获取锁。这样虽然可以满足需求,但是众多线程同时自旋等待实际上是对 CPU 资源的一种浪费,这么做不太合适。
  2. 将线程挂起,等待锁释放时唤醒,再竞争获取。如果等待的线程比较多,同时被唤醒可能会发生“惊群”问题。

上面两种方法的可行性其实都不太高,对于一个同步框架来说,当有多个线程尝试竞争资源时,我们并不希望所有的线程同时来竞争锁。而且更重要的是,能够有效的监控当前处于等待过程中的线程也十分必要。那么这个时候借助 FIFO 队列管理线程,既可以有效的帮助开发者监控线程,同时也可以在一定程度上减少饥饿问题出现的概率(线程先入先出)。

除此之外 AQS 中用于存放线程的队列还有以下几点考量:

  1. Node 节点的设计
  • 前驱、后继节点,分别保存当前节点在队列中的前驱节点和后继节点
  • 节点状态:节点拥有不同的状态可以帮助我们更好的管理队列中的线程。在本文中我们只讨论 SIGNAL 和 CANCEL 状态。当前驱节点的状态为 SIGNAL 时,表示当前节点可以被安全挂起,锁释放时当前线程会被唤醒去尝试重新获取锁;CANCEL 状态表示当前线程被取消,无需再尝试获取锁,可以被移除队列
  // 线程被取消
  static final int CANCELLED =  1;
  // 后续线程在锁释放后可以被唤醒
  static final int SIGNAL    = -1;
  // 当前线程在 condition 队列中
  static final int CONDITION = -2;
  // 没有深入体会,表示下一次共享式同步状态获取将会无条件被传播下去
  static final int PROPAGATE = -3;
  1. AQS 中的双向线程队列
    由于 Node 前驱和后继节点的存在。这里保存 Node 的队列实际上是一个双向队列。在这个队列里前驱节点的存在会更重要些:当前新节点被插入到队列中时,如果前驱节点状态为取消状态。我们可以通过前驱节点不断往前回溯,完成一个类似滑动窗口的功能,跳过无效线程,从而帮助我们更有效的管理等待队列中线程。而且上面也提过了,等待线程都放在队列中,一方面可以管控等待线程,另一方面也可以较少饥饿现象发生的概率。

  2. HEAD 和 TAIL
    HEAD 和 TAIL 节点分别指向队列的首尾节点。当第一次往队列中塞入一个新的节点时会构造一个虚拟节点作为 HEAD 头节点。为什么需要虚拟的 HEAD 头节点呢?因为在 AQS 的设计理念中,当前节点能够安心自我阻塞的前提条件是前驱节点在释放锁资源时,能够唤醒后继节点。而插入到第一个队列中的节点,没有前驱节点怎么办,我们就构造一个虚拟节点来满足需求

同时 HEAD 和 TAIL 节点的存在加上双向队列的设计,整体的队列就显的非常灵活。

四、资源竞争(获取锁)

这一章节开始我们将结合源码对 AQS 获取锁的流程进行讨论。

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

acquire 方法用于获取锁,这里可以拆解为三步:

  • tryAcquired: 看名字就知道用于尝试获取所,并不保证一定可以获取锁,具体逻辑由子类实现。如果在这一步成功获取到了锁,后面的逻辑也就没有必要继续执行了。
  • addWaiter尝试竞争锁资源失败后,我们就要考虑将这个线程构造成一个节点插入到队列中了。这里的 addWaiter() 方法会将当前线程包装成一个 Node 节点后,维护到 FIFO 双向队列中。
 private Node addWaiter(Node mode) {
    // 将当前线程包装成一个 Node 节点
    Node node = new Node(Thread.currentThread(), mode);
    Node pred = tail;
    // 如果 tail 节点不为空:新节点的前驱指向 tail,原尾节点的后继指向当前节点,当前节点成为新的尾节点
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    // 第一次往队列中新增节点时,会执行 enq 方法
    enq(node);
    return node;
}
private Node enq(final Node node) {
    for (;;) {
     // head 和 tail 在初始情况下都为 null
        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;
            }
        }
    }
}

这段逻辑不复杂:

  1. 当我们处理第一个节点时,此时 tail 节点为 null,因此会执行 enq() 方法。可以看到 enq 方法实际是一个死循环,只有当节点成功被插入到队列后,才能跳出去循环。那这么做的目的是什么呢?其实不难看出,这里是为了应对多线程竞争而采取的妥协之策。多个线程同时执行这段逻辑时,只有一个线程可以成功调用 compareAndSetHead() 并将 head 头指向一个新的节点,此时的 head 和 tail 都指向一个空节点。这个空节点的作用前面已经提过了,用于帮助后继节点可以在合适的场景下自我阻塞等待被唤醒。其它并发执行的线程执行 compareAndSetHead() 方法失败后,发现 tail 已经不为 null 了,依次将自己插入到 tail 节点后。
  2. 当 tail 节点不为空时,表示此时队列中有数据。因此我们借助 CAS 将新节点插入到尾节点之后,同时将 tail 指向新节点
  • 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)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

这里又是一个死循环

  • 这里需要注意的是只有前驱节点为 head 时,我们才会再次尝试获取锁。也就是在当前队列中,只有队首节点才会尝试获取锁。这里也体现了如何降低饥饿现象发生的概率。如果成功获取到了锁:将 node 节点设置为头节点,同时将前驱节点的 next 设置为 null 帮助 gc。

  • 如果 node 节点前驱节点不为 head 或者获取锁失败,执行 shouldParkAfterFailedAcquire() 方法判断当前线程是否需要阻塞,如果需要阻塞则会调用 parkAndCheckInterrupt() 方法挂起当前线程

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)
        /*
         * 当前驱节点状态为 SIGNAL 时,表示调用 release 释放前驱节点占用的锁时,
         * 前驱会唤醒当前节点,可安全挂起当前线程等待被唤醒
         */
        return true;
    if (ws > 0) {
        /*
         * 前驱节点处于取消状态,我们需要跳过这个节点,并且重试
         */
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        // waitStatus 为 0 或 PROPAGATE 走的这里。后文会分析下什么时候 waitStatus 可能为 0
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

当节点状态为 SIGNAL 时,表示当前线程可以被安全挂起。waitStats 大于0表示当前线程已经被取消,我们需要往前回溯找到有效节点。

在开始阅读这段代码时,一直想不通在哪些场景下 waitStatus 的状态可能为 0,在参阅了其它笔者分析的文章再加上自己的理解后,总结出以下两种场景:

  1. 当我们往队列中新插入一个节点时。队尾节点的 waitStatus 值应为初始状态 0。此时执行 shouldParkAfterFailedAcquire() 方法会执行最后一个判断条件将前驱 waitStatus 状态更新为 SIGNAL,同时方法返回 false 。然后会继续执行一次 acquireQueued() 中的死循环,此时前驱节点的状态已经被更新为 SIGNAL,再次执行 shouldParkAfterFailedAcquire() 方法会返回 true,当前线程即可放心的将自己挂起,等待被线程唤醒。
  2. 当调用 release() 方法释放锁时,会将占用锁的节点的 waitStatus 状态更新为 0,同时会调用 LockSupport.unpark() 方法唤醒后继节点。当后继节点被唤醒之后,会继续执行被挂起之前执行的 acquireQueued() 方法中的 for 循环再次尝试获取锁。但是被唤醒并不代表一定可以获取到锁,如果获取不到锁则会再次执行 shouldParkAfterFailedAcquire() 方法。

为什么说被唤醒的线程不一定可以获取到锁呢?

对于基础的 acquire 方法来说,没有任何规则规定队首节点一定可以获取到锁。当我们在唤醒队列中的第一个有效线程时,此时如果出现了一个线程 A 尝试获取锁,那么该线程会调用 acquire() 方法尝试获取锁,如果运气不错,线程 A 完全有可能会窃取当前处于队列头中的线程获取锁的机会。因此基础的 acquire 方法实际上是不公平的。那么为什么这么做?

如果队列头处于解除阻塞过程中,这一段时间实际上没有线程可以获取资源,属于一种资源浪费。所以这里只能认为是有一定概率的公平。

五、资源释放(释放锁)

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}
private void unparkSuccessor(Node node) {
    // 当状态小于 0 时,更新 waitStatus 值为 0
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);
    // 如果后继节点为 null 或者状态为取消,从尾结点向前查找状态不为取消的可用节点
    Node s = node.next;
    if (s == null || s.waitStatus > 0) {
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    // 唤醒被阻塞的线程
    if (s != null)
        LockSupport.unpark(s.thread);
}

release 整体流程比较简单。需要我们注意的就是为什么此时需要把 head 节点的状态更新为 0,主要是便于唤起后续节点,这个问题第四章节也已经聊过了,就不赘述了。

另外,当前节点的后继为 null 或者 后继节点的状态为 CANCEL,那么会从尾节点开始,从后往前寻找队列中最靠前的有效节点。


如果你觉得文章写的还不错,快给笔者点个赞吧,你的鼓励是笔者创作最大的支持!!!!!!

【干货!!】十分钟带你搞懂 Java AQS 核心设计与实现!!!的更多相关文章

  1. 少啰嗦!一分钟带你读懂Java的NIO和经典IO的区别

    1.引言 很多初涉网络编程的程序员,在研究Java NIO(即异步IO)和经典IO(也就是常说的阻塞式IO)的API时,很快就会发现一个问题:我什么时候应该使用经典IO,什么时候应该使用NIO? 在本 ...

  2. 十分钟带你读懂《增长黑客》zz

    背景 “If you are not growing, then you are dying. ”(如果企业不在增长,那么就是在衰亡!) 这句话适用于企业,也适用于个人.人生毕竟不像企业,是非成败,似 ...

  3. 3分钟带你搞懂ES6 import 和 export

    如下语句是 default import: // B.js import A from './A' 且只在A存在 default export 时生效: // A.js export default ...

  4. 一文彻底搞懂Java中的环境变量

    一文搞懂Java环境变量 记得刚接触Java,第一件事就是配环境变量,作为一个初学者,只知道环境变量怎样配,在加上各种IDE使我们能方便的开发,而忽略了其本质的东西,只知其然不知其所以然,随着不断的深 ...

  5. 一文搞懂Java引用拷贝、浅拷贝、深拷贝

    微信搜一搜 「bigsai」 专注于Java和数据结构与算法的铁铁 文章收录在github/bigsai-algorithm 在开发.刷题.面试中,我们可能会遇到将一个对象的属性赋值到另一个对象的情况 ...

  6. 一文搞懂Java引用拷贝、深拷贝、浅拷贝

    刷题.面试中,我们可能会遇到将一个对象的属性赋值到另一个对象的情况,这种情况就叫做拷贝.拷贝与Java内存结构息息相关,搞懂Java深浅拷贝是很必要的! 在对象的拷贝中,很多初学者可能搞不清到底是拷贝 ...

  7. 五分钟学Java:一篇文章带你搞懂spring全家桶套餐

    原创声明 本文首发于微信公众号[程序员黄小斜] 本文作者:黄小斜 转载请务必在文章开头注明出处和作者. 本文思维导图 什么是Spring,为什么你要学习spring? 你第一次接触spring框架是在 ...

  8. 【 全干货 】5 分钟带你看懂 Docker !

    欢迎大家前往腾讯云社区,获取更多腾讯海量技术实践干货哦~ 作者丨唐文广:腾讯工程师,负责无线研发部地图测试. 导语:Docker,近两年才流行起来的超轻量级虚拟机,它可以让你轻松完成持续集成.自动交付 ...

  9. 轻松搞懂Java中的自旋锁

    前言 在之前的文章<一文彻底搞懂面试中常问的各种“锁”>中介绍了Java中的各种“锁”,可能对于不是很了解这些概念的同学来说会觉得有点绕,所以我决定拆分出来,逐步详细的介绍一下这些锁的来龙 ...

随机推荐

  1. SpringBoot整合日志log4j2

    SpringBoot整合日志log4j2 一个项目框架中日志都是必不可少的,随着技术的更新迭代,SpringBoot被越来越多的企业所采用.这里简单说说SpringBoot整合log2j2日志. 一. ...

  2. 【Azure Redis 缓存 Azure Cache For Redis】当使用Jedis客户端连接Redis时候,遇见JedisConnectionException: Could not get a resource from the pool / Redis connection lost

    问题情形 当在执行Redis一直指令时,有可能会遇见如下几种错误: 1) redis.clients.jedis.exceptions.JedisConnectionException: Could ...

  3. Spring Cloud Alibaba Sentinel

    一.介绍(sentinel 1.7.0) 1,官网地址 https://github.com/alibaba/Sentinel 中文地址:https://github.com/alibaba/Sent ...

  4. 模板c++

    #define _CRT_SECURE_NO_WARINGS #include <iostream> using namespace std; int main(void) { retru ...

  5. 实战二:nacos服务注册与发现,openfeign服务调用

    一,参照上一篇创建好微服务结构后,按业务需求编写各微服务逻辑 二,服务注册 1,安装nacos:下载,解压,运行startup.cmd 2,访问 http://localhost:8848/nacos ...

  6. scrapy和scrapy-redis 详解一 入门demo及内容解析

    架构及简介 Scrapy是用纯Python实现一个为了爬取网站数据.提取结构性数据而编写的应用框架,用途非常广泛. Scrapy 使用了 Twisted(其主要对手是Tornado)异步网络框架来处理 ...

  7. C# stopwatch的简单使用(计算程序执行时间)

    首先添加引用 using System.Diagnostics;//stopwatch的引用 //声明变量 Stopwatch a=new Stopwatch();//PS:这里一定要new(实例化) ...

  8. 手写Express.js源码

    上一篇文章我们讲了怎么用Node.js原生API来写一个web服务器,虽然代码比较丑,但是基本功能还是有的.但是一般我们不会直接用原生API来写,而是借助框架来做,比如本文要讲的Express.通过上 ...

  9. 追根溯源之Linq与表达式树

    一.什么是表达式树?   首先来看下官方定义(以下摘录自巨硬官方文档)   表达式树表示树状数据结构中的代码,其中每个节点都是表达式,例如,方法调用或诸如的二进制操作x < y.   您可以编译 ...

  10. 【Luogu】P6232 [eJOI2019]挂架 题解

    这道题跟CSP/S 2019 D1T1有点像. 我们先来模拟一下 \(n=4\) 的情况, 不难得出,最后的衣架挂钩顺序: 下标: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 1 ...