【源码角度】为什么AQS这样设计

news2024/12/23 14:31:04

AQS(AbstractQueuedSynchronizer,抽象同步队列器)是 一个基于 FIFO的双端队列。它分为独占模式和共享模式,本文主要围绕独占模式进行讲解,共享模式的原理和独占模式相似,最后会提一嘴。

场景代入

其实AQS模型在我们的生活中经常见到:坐火车时,一节车厢上只有一个厕所,如果厕所里有人,其他乘客只能排队,这些排队的乘客一般会有两种心情:

  1. 排在队头的,厕所的人怎么还不出来,我随时准备进去
  2. 队头后面的其他乘客,厕所的人出来也到不了我,不用急着进去,再刷会短视频,等我是队头再做好准备也不晚。

这句不是废话哦,我们怎么判断厕所里有没有人呢,可以推门试试能不能推开或者问话,是这样吗?不至于。火车上厕所门锁处有提示:如果乘客在里面上锁会变红,否则为绿色。

这其实就是AQS的设计思想,只不过AQS还考虑了模板和解耦。下面,上源码!

从源码看AQS

为什么state使用int类型

AQS 使用一个 volatile 修饰的 int 类型的成员变量 state 来表示同步状态(state用来说明厕所里面有没有人)。如果想要用一个变量来表示厕所有没有人,为什么不使用boolean类型呢,这不比int类型更省空间吗。这是因为AQS还有共享模式,共享模式下state可能的取值就不是2个了。

再来了解一下Node节点

Node是什么呢,其实就是在排队的乘客,一个Node就是一个在排队的乘客。我们知道,乘客不会一直排队,可以归为这样三种状态:排队中;进入厕所;并没有进入厕所但因为某些原因离开队伍。
所以Node也要有相应的状态,如源码中所示:
在这里插入图片描述
那如何利用这个先进先出的队列和 state 变量来管理多线程的同步状态呢?这些操作被封装成方法。有这么两种场景:
1) 尝试获取锁,获取不到立即返回;AQS 类中的 tryAcquire 方法
2) 获取锁,获取不到则进入同步队列直到获取锁。AQS 类中的 acquire 方法

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

在 AQS 类的源码中,tryAcquire 方法被 protected 修饰,返回值是一个 boolean 类型代
表是否成功获得锁,tryAcquire 方法需要被子类重写这个方法,否则会抛出异常。这样设计
的目的是向上层提供方便,上层可以重写这个方法然后自由编写业务逻辑。

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

acquire 方法被 final 修饰,所以不能被子类重写。

注 意 上 面 最 后 一 行 的 selfInterrupt ( ) , selfInterrupt 执行的前提是
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)方法返回 true。这个方法返回的是线程在
获取锁的过程中是否发生过中断,返回 true 则证明发生过中断。所以 acquire 中的 selfInterrupt
其实是对获取锁的过程中发生过的中断的补充。

为什么不直接用 isInterrupt()判断,是因为在获取锁的过程中,是通过park+死循环实现 的。每次 park 被唤醒之后都会重置中断状态,所以拿到锁的时候中断状态都是被重置后的。 上图的 addWaiter方法的作用:如果尝试获取锁失败,就讲当前线程封装成节点并插入同步队列,返回值为当前节点。插入同步队列的过程很神奇:首先会执行快速入队操作(其实是不完整的入队操作:相对于完整的入队操作,缺少了尾结点为空的处理)如果队列的尾节点为空或者第一次尝试 CompareAndSetTail()操作失败,那么调用 enq 方法(这是完整的 入 队 操 作 : 会 对 当 前 队 列 进 行 初 始 化 并 自 旋 地 通 过 CompareAndSetTail ()或CompareAndSetHead()将当前节点插入直到入队成功),

private Node addWaiter(Node mode) {
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
        Node pred = tail;
        if (pred != null) {
            node.prev = pred;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
    }
private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) { // Must initialize
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

上面第一张图片是快速入队,第二张图片是完整入队 enq。两者相比确实只是少了对尾结点
为空时的处理。如果尾结点为空,会调用 compareAndSetHead(),这是一个 CAS 操作:

private final boolean compareAndSetHead(Node update) {
        return unsafe.compareAndSwapObject(this, headOffset, null, update);
    }
inal 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);
        }
    }

cancelAcquire 方法是将 node 的 waitStatue 修改为 CANCEL。

  1. 第 1 个 if语句: if(当前节点的前置节点是头结点&&当前线程 尝试获取锁成功)
    假如当前 node 本来就不是队头或者就是tryAcquire(arg) 没有抢赢别人,就是走到下面 。注意:AQS 的 FIFO队列中头结点其实是虚节点(意思是说虚节点并不是当前需要去拿锁的节点,只是一个充当占位的摆设,第二个节点才是真正要去拿锁的节点,当第二个节点拿到锁之后他就会变成新的头结点,原来的头结点出队,所以在 AQS 的源码中经常会看见“判断当前节点的前置节点是否为头结点的判断语句”,本质就是为了判断当前节点有没有资格去拿锁)
  2. 第 2 个 if 语句:if(boolean 当前线程需要被挂起(前驱节点,当前节点)&& parkAndCheckInterrupt挂起线程是否成功,这个方法会返回了 boolean Thread.Interrupted( ))
    为什么要将线程挂起呢,直接自旋直到当前节点获得锁然后直接返回不就好了?逻辑上没问题,但自旋是 CPU的操作,如果大量的线程在自旋等待一定会出现性能问题。所以我们需要将那些还没有轮到他出队的那个线程挂起,再在适合的时间把它们唤醒,这样就能避免大量的自旋操作,能够极大地提升性能

总结acquireQueued: 先初始化“是否发生过中断的标识”为 false。然后尝试获取锁,如果获取锁失败则会调用parkAndCheckInterrupt())方法 ,如果parkAndCheckInterrupt())返回了 true则证明发生过中断,将中断标记置为 true,最后会返回这个中断标记。

副本:Java中断机制

如果当前线程由于 wait 或 sleep 方法而处于等待状态,那么对该线程 interrupt将会使其抛出中断异常;如果该线程处于运行状态或者由于 park 方法挂起进入等待状态,那么 interrupt 只会改变这个 Thread 对象的一个中断状态值,并不会影响该线程继续运行。

回到AQS主线

如果 shouldParkAfterFailedAcquire(Node pred, Node node) 返回 true, 说明前驱节点的waitStatus==-1,是正常情况,那么当前线程需要被挂起,等待以后被唤醒,就等着前驱节点拿到锁,然后释放锁的时候叫你好了;如果返回 false, 说明当前不需要被挂起,为什么呢?仔细看 shouldParkAfterFailedAcquire(p, node)的源码,我们可以发现,其实第一次进来的时候,一般都不会返回 true 的,原因很简单,前驱节点的 waitStatus=-1 是依赖于后继节点设置的。也就是说,我都还没给前驱设置-1 呢,怎么可能是 true 呢,但是要看到,这个方法是套在循环里的,所以第二次进来的时候状态就是-1 了。

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
            /*
             * This node has already set status asking a release
             * to signal it, so it can safely park.
             */
            return true;
        if (ws > 0) {
            /*
             * Predecessor was cancelled. Skip over predecessors and
             * indicate retry.
             */
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            /*
             * waitStatus must be 0 or PROPAGATE.  Indicate that we
             * need a signal, but don't park yet.  Caller will need to
             * retry to make sure it cannot acquire before parking.
             */
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

我们再来解释下为什么 shouldParkAfterFailedAcquire(p, node) 返回 false 的时候不直接挂起线程?这是因为经过这个方法后,当前节点的前一个节点有可能因为超时或者中断而取消阻塞退出同步队列因此设置了新的父节点,这个父节点有可能就已经是 head 了,恍然大悟。

释放锁

上面都在讲获取锁(acquire),下面讲一下释放锁(release)。

与 acquire 和 tryAcquire 获取锁对应,释放锁有 tryRelease 和 release。同样 tryRelease
向上层提供方便,不能直接使用,需要被子类重写;release 也是 final 修饰,不可被子类
重写。

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

在 release 中,如果尝试释放锁成功,那么紧接着就要去等待队列的其他节点,主要看
一下 unparkSuccessor 方法:

private void unparkSuccessor(Node node) {
        /*
         * If status is negative (i.e., possibly needing signal) try
         * to clear in anticipation of signalling.  It is OK if this
         * fails or if status is changed by waiting thread.
         */
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);

        /*
         * Thread to unpark is held in successor, which is normally
         * just the next node.  But if cancelled or apparently null,
         * traverse backwards from tail to find the actual
         * non-cancelled successor.
         */
        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);
    }

传进来的 head 此时只是作为一个占位的虚节点,所以需要将它的 waitStatus 置为默认值 0.这样才不会影响其他函数的判断。接下来程序会从这个 FIFO 队列的尾结点开始搜索,找到除了 head 节点之外一个最靠前的并且 waitStatus<=0 的节点,并对其进行LockSupport.unpark(s.thread)操作,即唤醒这个之前被挂起的线程,让他起来争抢这个锁。被挂起的线程,一旦被唤醒,那么它将继续执行 acquireQueue 方法,自旋尝试获取锁

private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
    }

最后为什么要 return Thread.interrupt()呢?我们说过,如果线程被 prok 方法挂起而进入等待状态,那么 interrupt 只会改变这个 Thread 对象的一个中断状态值,并不会影响该线程继续运行,如果外部对这个线程调用 interrupt()是不会产生中断的,会改变这个Thread 对象的一个中断状态值,所以最后这句代码的作用就是:因为线程处于等待队列中无法响应外部的中断请求,只有当这个线程拿到锁之后,然后再根据这个线程的中断状态值进行响应。

AQS共享模式

上面讲了AQS的独占模式,其实共享模式和独占模式非常相似,它们的差异如下:

  • 独占模式下:acquire 方法是线程获取共享资源的入口方法。
  • 共享模式下:acquireShared 是线程获取共享资源的入口方法。

共享锁的 acquiredShared 方法其实与独占锁的 acquire 方法类似,tryAcquireShared 都是需要子类去实现,区别是独占锁的 tryAcquire 返回的是 boolean,而共享锁的tryAcquired 方法返回的是 int:
 如果该值<0,则代表获取共享锁失败
 如果该值=0,则代表获取共享锁成功,但随后其他线程获取共享锁会失败
 如果该值>0,则代表获取共享锁成功,并且后续线程获取共享锁也可能成功

还有一个setHeadAndPropagate方法需要注意:

private void setHeadAndPropagate(Node node, int propagate) {
        Node h = head; // Record old head for check below
        setHead(node);
        /*
         * Try to signal next queued node if:
         *   Propagation was indicated by caller,
         *     or was recorded (as h.waitStatus either before
         *     or after setHead) by a previous operation
         *     (note: this uses sign-check of waitStatus because
         *      PROPAGATE status may transition to SIGNAL.)
         * and
         *   The next node is waiting in shared mode,
         *     or we don't know, because it appears null
         *
         * The conservatism in both of these checks may cause
         * unnecessary wake-ups, but only when there are multiple
         * racing acquires/releases, so most need signals now or soon
         * anyway.
         */
        if (propagate > 0 || h == null || h.waitStatus < 0 ||
            (h = head) == null || h.waitStatus < 0) {
            Node s = node.next;
            if (s == null || s.isShared())
                doReleaseShared();
        }
    }

setHeadAndPropagate 方法主要是为了设置获取锁的节点为头节点,并接下去直接唤
醒后继节点。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/482004.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

云计算基础(持续更新)

文章目录 云计算云计算的定义第1关&#xff1a;云计算定义第2关&#xff1a;云计算的基本原理 云计算出现的背景第1关&#xff1a;云计算出现的背景第2关&#xff1a;云计算的特征第3关&#xff1a;云计算的优势与劣势 虚拟化的类型第1关&#xff1a;虚拟化的定义第2关&#xf…

第六章结构型模式—代理模式

文章目录 代理模式解决的问题概念结构 静态代理动态代理织入的概念JDK 动态代理JDK 动态代理分析 CGLIB 动态代理 三种代理的对比JDK 和 CGLIB 的区别动态代理和静态代理的对比代理模式的优缺点使用场景 结构型模式描述如何将类或对象按某种布局组成更大的结构&#xff0c;有以…

浅谈springboot启动过程

1. 知识回顾 为了后文方便&#xff0c;我们先来回顾一下spring的一些核心概念。 spring最核心的功能无非是ioc容器&#xff0c;这个容器里管理着各种bean。ioc容器反映在java类上就是spring的核心类ApplicationContext。ApplicationContext有众多的子接口和子类&#xff0c;不…

SAP重复制造入门到放弃系列之基本配置

目录 前言 主要配置清单&#xff1a; REM参数文件&#xff1a; 计划订单计划参数文件 维护输入项参数 维护行选择 确认和物流信息系统全局设置 定义确认处理 操作方法表 其他 前言 重复制造中的配置步骤包括创建重复制造配置文件、为运行计划数量&#xff08;计划订单&a…

Excel技能之打印,19+技巧超省纸

颜色太多&#xff0c;重新打印。 没有边框&#xff0c;重新打印。 少了几列&#xff0c;重新打印。 整个工作表打印出来&#xff0c;拿剪刀把自己需要的数据剪下来&#xff0c;用胶水贴到另一张新的A4纸。 你上班打印资料&#xff0c;浪费了多少纸&#xff0c;认真算一下&…

PyCharm with Remote Interpreters

文章目录 一: 利用 Docker 镜像搭建1.编写 Dockerfile2.构建并配置 Remote Interpreters3.结论0.FAQ 二: 利用 SSH Docker 镜像搭建1.编写 Dockerfile2.构建并运行3.构建镜像4.运行容器5.验证并配置0.FAQ 起因是需要在 M2 芯片的 Mac 上调试基于 Python3.6.1 构建的程序. 而 M…

每天一道算法练习题--Day18 第一章 --算法专题 --- ----------前缀树

前缀树 字典树也叫前缀树、Trie。它本身就是一个树型结构&#xff0c;也就是一颗多叉树&#xff0c;学过树的朋友应该非常容易理解&#xff0c;它的核心操作是插入&#xff0c;查找。删除很少使用&#xff0c;因此这个讲义不包含删除操作。 截止目前&#xff08;2020-02-04&a…

bootloader编写——MCU固件升级系列2(STM32)

本系列将从升级流程、boot代码编写、APP代码编写以及固件打包来介绍&#xff0c;硬件选用STM32F407ZGT6&#xff08;手里只有&#xff09;&#xff0c;来完成这系列教程。 前言 开发STM32固件升级并编写Bootloader时&#xff0c;需要注意以下几个关键点&#xff1a; 熟悉硬件和…

数据结构篇四:栈

文章目录 前言1.栈1.1 栈的概念及结构1.2 栈的实现 2.栈功能的解析及实现2.1 栈的创建2.2 初始化2.3 入栈2.4 出栈2.5 检查栈是否为空2.6 获取栈顶元素2.7 栈中的有效元素个数2.8 销毁 3.代码实现3.1 Stack.h3.2 Stack.c3.3 test.c 4.总结 前言 前面学习的一些结构都比较普通&a…

13 | visual studio与Qt的结合

1 前提 Qt 5.15.2 visual studio 2019 vsaddin 2.8 2 具体操作 2.1 visual studio tool 2.1.1 下载 https://visualstudio.microsoft.com/zh-hans/downloads/2.1.2 安装 开发

推荐算法实战项目:WideDeep原理以及案例实战(附完整 Python 代码)

本文要介绍的是Google于2016年提出的Wide&Deep模型&#xff0c;此模型的提出对业界产生了非常大的影响&#xff0c;不仅其本身成功地应用在多家一线互联网公司&#xff0c;而且其后续的改进工作也一直延续至今。 Wide&Deep模型正如其名&#xff0c;分别包含了Wide部分…

golang - 函数的使用

核心化编程 为什么需要函数&#xff1f; 代码冗余问题不利于代码维护函数可以解决这个问题 函数 函数&#xff1a;为完成某一功能的程序指令&#xff08;语句&#xff09;的集合&#xff0c;称为函数 在 Go 中&#xff0c;函数分为&#xff1a;自定义函数&#xff08;自己写…

Apache Solr Velocity模板注入RCE

Apache Solr Velocity模板注入RCE 一、Apache Solr介绍 Solr是一个独立的企业级搜索应用服务器,它对外提供类似于web-service的API接口,用户可以通过http请求,向搜索引擎服务器提交一定格式的XML文件,生成索引,也可以通过http get操作提出查找请求,并得到XML格式的返回结果。…

【openFrameworks】跨平台的 C++ 开源框架 | oF 文件结构 | 图形基础介绍

&#x1f4ad; 写在前面&#xff1a;本章我们将介绍一个非常好用的跨平台的 C 开源框架 —— openFrameworks。它是一个开源的跨平台的 C工具包&#xff0c;方便开发者创建出一个更简单和直观的框架&#xff0c;擅长开发图像和动画。 &#x1f4dc; 本章目录&#xff1a; 0x0…

SpringBoot集成SpringSecurity从0到1搭建权限管理详细过程(认证+授权)

前言 最近工作需要给一个老系统搭建一套权限管理&#xff0c;选用的安全框架是SpringSecurity&#xff0c;基本上是结合业务从0到1搭建了一套权限管理&#xff0c;然后想着可以将一些核心逻辑抽取出来写一个权限通用Demo&#xff0c;特此记录下。 文章目录 前言1、SpringSecuri…

CSS中4个定位设计与实现

1.相对定位 说明&#xff1a;相对原来元素的定位。开启定位后&#xff0c;元素层级高&#xff0c;会置于最上层 作用&#xff1a;用于元素的微调&#xff0c;不会脱离文档流 1.1代码实现 <!DOCTYPE html> <html lang"zh"> <head><meta charset…

外卖项目优化-01-redis缓存短信验证码、菜品数据、Spring Cache(注解开发缓存)、(注解开发)缓存套餐数据

文章目录 外卖项目优化-01课程内容前言1. 环境搭建1.1 版本控制解决branch和tag命名冲突 1.2 环境准备 2. 缓存短信验证码2.1 思路分析2.2 代码改造2.3 功能测试 3. 缓存菜品信息3.1 实现思路3.2 代码改造3.2.1 查询菜品缓存3.2.2 清理菜品缓存 3.3 功能测试3.4 提交并推送代码…

每日一题133——环形链表

给你一个链表的头节点 head &#xff0c;判断链表中是否有环。 如果链表中有某个节点&#xff0c;可以通过连续跟踪 next 指针再次到达&#xff0c;则链表中存在环。 为了表示给定链表中的环&#xff0c;评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置&#xff08;…

5. 操作系统基础

5. 操作系统基础 常考面试题 说说你对进程的理解⭐⭐⭐ 程序是指令、数据及其组织形式的描述,而进程则是程序的运行实例,包括程序计数器、寄存器和变量的当前值。 Linux的进程结构,一般分为三部分:代码段、数据段(.data与.bss)和堆栈段。 代码段用于存放程序代码,如果有…

【计算机图形学基础教程】面向对象程序设计基础

构造函数与析构函数 例1 设计一个长方形CRectangle类&#xff0c;调用类的成员函数计算长方形的周长和面积。 #include <iostream>class CRectangle { public:CRectangle(); // 声明默认构造函数CRectangle(int width, int height); // 声明带…