基于jdk11从源码角度剖析AQS 抽象同步其的核心原理

news2024/11/18 13:40:55

在高并发的情况下,使用基于CAS自旋实现的轻量级锁存在恶性空自旋浪费CPU 资源和导致“总线风暴”两大问题, 解决CAS恶性空自旋的有效方法是空间换时间,常见解决方法有分散操作热点和使用队列削峰。JUC并发包使用的是队列削峰的方案解决CAS的性能问题,并提供了一个基于双向队列的削峰基类——抽象基础类AbstractQueuedSynchronizer(抽象同步器类,简称为AQS)。

AQS 简介

AQS是JUC提供的一个用于构建锁和同步容器的基础类,是CLH队列的一个变种。它实现了锁的基本抽象功能,支持独占锁与共享锁两种方式。
AQS的类图如下:
在这里插入图片描述

AQS队列内部维护的是一个FIFO的双向链表,其内部通过节点head和tail记录队首和队尾元素,队列元素的类型为Node。其中Node中的thread变量用来存放进入AQS队列里面的线程;Node节点内部的SHARED用来标记该线程是获取共享资源时被阻塞挂起后放入AQS队列的,EXCLUSIVE用来标记线程是获取独占资源时被挂起后放入AQS队列的;waitStatus记录当前线程等待状态,可以为CANCELLED(线程被取消了)、SIGNAL(线程需要被唤醒)、CONDITION(线程在条件队列里面等待)、PROPAGATE(释放共享资源时需要通知其他节点);prev记录当前节点的前驱节点,next记录当前节点的后继节点。
FIFO双向链表的特点是每个数据结构都有两个指针,分别指向直接的前驱节点和直接的后继节点。每个节点其实是由线程封装的,当线程争抢锁失败后会封装成节点加入AQS队列中;当获取锁的线程释放锁以后,会从队列中唤醒一个阻塞的节点(线程)。其内部结果如下:
在这里插入图片描述
AQS有个内部类ConditionObject,用来结合锁实现线程同步。ConditionObject可以直接访问AQS对象内部的变量,比如state状态值和AQS队列。ConditionObject是条件变量,每个条件变量对应一个条件队列(单向链表队列),其用来存放调用条件变量的await方法后被阻塞的线程。

AQS 的成员

AQS根据“分离变与不变”的原则基于模板模式实现。

状态标志位

   /**
     * 同步状态,
     */
    private volatile int state;
     /**
     * 获取同步的状态
     */
    protected final int getState() {
        return state;
    }
    
    /**
     * 设置同步状态
     */
    protected final void setState(int newState) {
        state = newState;
    }

AQS使用int类型的state标示锁的状态,可以理解为锁的同步状态。AQS 提供了getState()、setState()来获取和设置同步状态。

由于setState()无法保证原子性,因此AQS给我们提供了compareAndSetState()方法调用的是VarHandle.compareAndSet()方法,是具有CAS原性的操作,被@HotSpotIntrinsicCandidate修饰,在HotSpot中有一套高效的实现,该高效实现基于CPU指令,运行时,HotSpot维护的高效实现会替代JDK的源码实现,从而获得更高的效率。其代码如下:

private static final VarHandle STATE;
/**
* 通过CAS设置同步的状态
**/
protected final boolean compareAndSetState(int expect, int update) {
    return STATE.compareAndSet(this, expect, update);
}
 public final native
    @MethodHandle.PolymorphicSignature
    @HotSpotIntrinsicCandidate
    boolean compareAndSet(Object... args);

队列节点类

static final class Node {
        /**
         * 标识节点在抢占共享锁
         * 表示线程是因为获取共享资源时阻塞而被添加到队列中的
         * */
        static final Node SHARED = new Node();
        /**
         * 标识节点在抢占独占锁
         * 线程是因为获取独占资源时阻塞而被添加到队列中的。
         * */
        static final Node EXCLUSIVE = null;

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

        /**
         * 节点状态:值为SIGNAL、CANCELLED、CONDITION、PROPAGATE、0
         * 普通的同步节点的初始值为0,条件等待节点的初始化值为CANCELLED
         */
        volatile int waitStatus;

        /**
         * 前驱节点,当前节点会在前驱节点上自旋,循环检查前驱节点的waitStatus状态
         */
        volatile Node prev;

        /**
         * 后继节点
         */
        volatile Node next;

        /**
         * 节点所对应的线程,为抢占线程或者条件等待线程
         */
        volatile Thread thread;

        /**
         * 若当前NOde不是普通节点而是条件等待节点,则节点处于某个条件的等待队列上,
         * 此属性指向下一个条件等待节点,即其条件队列上的后继节点
         */
        Node nextWaiter;
        ...
        }

FIFO 双向同步队列

AQS通过内置的FIFO双向队列来完成线程的排队工作,内部通过节点head和tail记录队首和队尾元素,元素的节点类型为Node类型。

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

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

AQS的首节点和尾节点都是懒加载的。在需要的时候才真正创建。只有在线程竞争失败的情况下,有新线程加入同步队列时,AQS才创建一个head节点。head节点只能被setHead()方法修改,并且节点的waitStatus不能为CANCELLED。尾节点只在有新线程阻塞时才被创建。

AQS中的钩子方法

自定义同步器时,AQS中需要重写的钩子方法大致如下:
(1)tryAcquire(int):独占锁钩子,尝试获取资源,若成功则返回true,若失败则返回false。
(2)tryRelease(int):独占锁钩子,尝试释放资源,若成功则返回true,若失败则返回false。(3)tryAcquireShared(int):共享锁钩子,尝试获取资源,负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
(4)tryReleaseShared(int):共享锁钩子,尝试释放资源,若成功则返回true,若失败则返回false。
(5)isHeldExclusively():独占锁钩子,判断该线程是否正在独占资源。只有用到condition条件队列时才需要去实现它。

AQS锁抢占的原理

acquire是AQS封装好的获取资源的公共入口,它是AQS提供的利用独占的方式获取资源的方法,源码实现如下:

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

通过源码可以发现,acquire(arg)至少执行一次tryAcquire(arg)钩子方法。tryAcquire(arg)方法AQS默认抛出一个异常,具体的获取独占资源state的逻辑需要钩子方法来实现。若调用tryAcquire(arg)尝试成功,则acquire()将直接返回,表示已经抢到锁;若不成功,则将线程加入等待队列。

在acquire()方法中,如果钩子方法tryAcquire尝试获取同步状态失败的话,就构造同步节点(独占式节点模式为Node.EXCLUSIVE),通过addWaiter(Node node,int args)方法将该节点加入同步队列的队尾。

 private Node addWaiter(Node mode) {
       // 创建新节点
        Node node = new Node(mode);

        for (;;) { //自旋
            //加入队列尾部,将目前的队列tail作为自己的前驱节点oldTail
            Node oldTail = tail;
            //如果队列不为空时
            i{
                node.setPrevRelaxed(oldTail);
                //先尝试通过AQS 方式修改尾节点为最新的节点
                //如果修改成功, 将节点加入队列的尾部
                if (compareAndSetTail(oldTail, node)) {
                    oldTail.next = node;
                    return node;
                }
            }f (oldTail != null)  else {
                initializeSyncQueue();
            }
        }
    }

addWaiter()第一次尝试在尾部添加节点失败,意味着有并发抢锁发生,需要进行自旋。enq()方法通过CAS自旋将节点添加到队列尾部。

 private Node enq(Node node) {
        for (;;) { //自旋入队
            Node oldTail = tail;
            if (oldTail != null) {
                //队列不为空,将新节点插入队列尾部
                node.setPrevRelaxed(oldTail);
                if (compareAndSetTail(oldTail, node)) {
                    oldTail.next = node;
                    return oldTail;
                }
            } else {
                //如果队列为空,初始化尾节点和头节点作为新节点
                initializeSyncQueue();
            }
        }
    }
   /**
   * Initializes head and tail fields on first contention.
   * 队列为空,初始化尾节点和头结点为新节点
   */
  private final void initializeSyncQueue() {
      Node h;
      if (HEAD.compareAndSet(this, null, (h = new Node())))
          tail = h;
  }

 /**
 * CASes tail field.
 * CAS操作tail指针,仅仅被enq()使用
 */
private final boolean compareAndSetTail(Node expect, Node update) {
    return TAIL.compareAndSet(this, expect, update);
}

节点在第一次入队失败后,就会开始自旋入队,分为以下两种情况:
(1)如果AQS的队列非空,新节点入队的插入位置在队列的尾部,并且通过CAS方式插入,插入之后AQS的tail将指向新的尾节点。
(2)如果AQS的队列为空,新节点入队时,AQS通过CAS方法将新节点设置为头节点head,并且将tail指针指向新节点。

在节点入队之后,启动自旋抢锁的流程。acquireQueued()方法的主要逻辑:当前Node节点线程在死循环中不断获取同步状态,并且不断在前驱节点上自旋,只有当前驱节点是头节点时才能尝试获取锁,原因是:
(1)头节点是成功获取同步状态(锁)的节点,而头节点的线程释放了同步状态以后,将会唤醒其后继节点,后继节点的线程被唤醒后要检查自己的前驱节点是否为头节点。
(2)维护同步队列的FIFO原则,节点进入同步队列之后,就进入了自旋的过程,每个节点都在不断地执行for死循环。

final boolean acquireQueued(final Node node, int arg) {
        boolean interrupted = false;
        try {
            //自旋检查当前节点的前驱节点是否为头节点,才能获取锁
            //在前驱节点上自旋
            for (;;) {
                //获取节点的前缀节点
                final Node p = node.predecessor();
                //节点中的线程循环地检查自己的前驱节点是否为head节点
                //前驱节点是head时,进一步调用子类tryAcquire(...)实现
                if (p == head && tryAcquire(arg)) {
                   //tryAcquire()成功后,将当前节点设置为头节点,移除之前的头节点。
                    setHead(node);
                    p.next = null; // help GC
                    return interrupted;
                }
                //检查前一个节点的状态,预判当前获取锁失败的线程是否要挂起,
                // 如果需要挂起,调用parkAndCheckInterrupt()方法挂起当前线程,直到被唤醒
                if (shouldParkAfterFailedAcquire(p, node))
                    interrupted |= parkAndCheckInterrupt();
            }
        } catch (Throwable t) {
            //抛出异常时,取消请求,将当前节点从队列中移除。
            cancelAcquire(node);
            if (interrupted)
                selfInterrupt();
            throw t;
        }
    }

acquireQueued()方法不断在前驱节点上自旋(for死循环),如果前驱节点是头节点并且当前线程使用钩子方法tryAcquire(arg)获得了锁,就移除头节点,将当前节点设置为头节点。

调用acquireQueued()方法的线程一定是node所绑定的线程,该线程也是最开始调用lock()方法抢锁的那个线程,在acquireQueued()的死循环(自旋)中,该线程可能重复进行阻塞和被唤醒。

AQS释放锁唤醒后继线程的代码如下:

  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) {
   //获取节点状态,释放所得的节点,即头节点
   //CANCELLED(1),SIGNAL(-1),CONDITION(-2),PROPAGATE(-3)
   int ws = node.waitStatus;
   // 若头节点状态小于0,则将其置为0,表示初始状态
   if (ws < 0)
       node.compareAndSetWaitStatus(ws, 0);

   //后继节点
   Node s = node.next;
   if (s == null || s.waitStatus > 0) {
     //如果新节点已经被取消CANCELLED(1)
       s = null;
       //从队列尾部开始,往前去找最前面的一个waitStatus小于0的节点
       for (Node p = tail; p != node && p != null; p = p.prev)
           if (p.waitStatus <= 0)
               s = p;
   }
   if (s != null)
       // 唤醒后继节点的线程
       LockSupport.unpark(s.thread);
}

unparkSuccessor()唤醒后继节点的线程后,后继节点的线程重新执行方法acquireQueued()中的自旋抢占逻辑。

acquireQueued()自旋在阻塞自己的线程之前会进行挂起预判。shouldParkAfterFailedAcquire()方法的主要功能是:将当前节点的有效前驱节点(是指有效节点不是CANCELLED类型的节点)找到,并且将有效前驱节点的状态设置为SIGNAL,之后返回true代表当前线程可以马上被阻塞了。具体可以分为三种情况:
(1)如果前驱节点的状态为-1(SIGNAL),说明前驱的等待标志已设好,返回true表示设置完毕。
(2)如果前驱节点的状态为1(CANCELLED),说明前驱节点本身不再等待了,需要跨越这些节点,然后找到一个有效节点,再把当前节点和这个有效节点的唤醒关系建立好:调整前驱节点的next指针为自己。
(3)如果是其他情况:-3(PROPAGATE,共享锁等待)、?2(CONDITION,条件等待)、0(初始状态),那么通过CAS尝试设置前驱节点为SIGNAL,表示只要前驱节点释放锁,当前节点就可以抢占锁了。

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
   //获取前驱节点的状态,如果前驱节点状态WieSIGNAL(-1)就直接返回
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)
        return true;
    if (ws > 0) {
      // 前驱节点以及取消CANCELLED(1)
        do {
            //将pred记录前驱的前驱,调整当前节点的prev指针,保持为前驱的前驱
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        //调整前驱节点的next指针
        pred.next = node;
    } else {

         // 如果前驱状态不是CANCELLED,也不是SIGNAL,就设置为SIGNAL,
        //设置前驱状态之后,此方法返回值还是false,表示线程不可用,被阻塞

        pred.compareAndSetWaitStatus(ws, Node.SIGNAL);
    }
    return false;
}

在独占锁的场景中,shouldParkAfterFailedAcquire()方法是在acquireQueued()方法的死循环中被调用的,由于此方法返回false时acquireQueued()不会阻塞当前线程,只有此方法返回true时当前线程才阻塞,因此在一般情况下,此方法至少需要执行两次,当前线程才会被阻塞。

acquireQueued()方法中调用parkAndCheckInterrupt()方法暂停当前线程,源码如下:

  private final boolean parkAndCheckInterrupt() {
        //调用park()使线程进入waiting状态
        LockSupport.park(this);
        // 如果被唤醒,查看自己是否已经被中断
        return Thread.interrupted();
    }

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

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

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

相关文章

YOLOv7升级换代:EfficientNet骨干网络助力更精准目标检测

目录 一、EfficientNet骨干网络1、EfficientNet架构2、EfficientNet在目标检测中的应用3、EfficientNet分辨率的缩放4、EfficientNet深度与宽度的缩放 二、YOLOv7结构1、YOLOv7网络架构2、YOLOv7骨干网络3、YOLOv7使用了EfficientNet作为骨干网络&#xff0c;具有以下几个优点&…

如何基于异步消息队列进行深度学习模型推理预测?distributed inference with pytorch celery huey sqlite

文章目录 celery 简介celery in pytorchwindows 平台下使用celery 的一些问题参考文献与学习路径一些类似消息队列多进行推理预测的实现参考celery and sqlitecelery vs hueycelery 简介 先说一下celery 不支持windows 【或者说支持的不好】 pypi https://pypi.org/project/…

docker-安装prometheus

概述 什么是Prometheus 如果对Prometheus不熟悉的, 可以先了解一下Prometheus的官网或者文档; Prometheus是一个开源的系统监控和报警系统&#xff0c;现在已经加入到CNCF基金会&#xff0c;成为继k8s之后第二个在CNCF托管的项目&#xff0c;在kubernetes容器管理系统中&…

基于java和go-cqhttp实现QQ机器人

目录 yh-qqrobot机器人简介go-cqhttp搭建1.下载应用2.生成bat文件3. 初始化项目4. 配置5. 运行项目 yh-qqrobot搭建搭建后端1. 导入sql文件2. 配置文件3. 导入到idea 搭建前端 yh-qqrobot机器人简介 yh-qqrobot是一个基于若依框和go-cqhttp集成的系统&#xff0c;一开始我只是揣…

ACM 1010 | 利润计算

文章目录 0x00 前言 0x01 题目描述 0x02 问题分析 0x03 代码设计 0x04 完整代码 0x05 运行效果 0x06 总结 0x00 前言 C 语言网不仅提供 C 语言&#xff0c;还包括 C 、 java 、算法与数据结构等课程在内的各种入门教程、视频录像、编程经验、编译器教程及软件下载、题解博…

day2_内存区域2垃圾回收算法

文章目录 方法区1.StringTable2.StringTable的位置3.StringTable的调优 垃圾回收1. 判断垃圾2. 5种引用3. 垃圾回收算法 方法区 前面提到了方法区中的组成&#xff0c;它的组成主要是: class(例如它的属性&#xff0c;方法等)常量池(StringTable等)类加载器 在jdk 1.8中&…

【python学习】基础篇-文件与系统-文件信息获取与目录操作

python内置文件高级操作函数 删除文件 Python 没有内置删除文件的函数&#xff0c;但是在内置的 os 模块中提供了删除文件的 remove()函数&#xff0c;语法格式如下: os.remove(path) 其中&#xff0c;path 为要删除的文件路径&#xff0c;可以使用相对路径&#xff0c;也可以…

P1058 [NOIP2008 普及组] 立体图

题目描述 小渊是个聪明的孩子&#xff0c;他经常会给周围的小朋友们讲些自己认为有趣的内容。最近&#xff0c;他准备给小朋友们讲解立体图&#xff0c;请你帮他画出立体图。 小渊有一块面积为 &#xfffd;&#xfffd;mn 的矩形区域&#xff0c;上面有 &#xfffd;&#x…

number类型超出16位的问题(前端、后端处理)

目录 1、前端解决方案 1.1 甩链接 1.2 接口返回数据过程中将数据处理为字符串&#xff08;过过嘴瘾&#xff09; 1.3 对返回的json字符串进行数据预处理代码如下 2、后端解决方案 2.1 toString、String、 、new String() 自己悟、就是要改的地方多。 2.2拦截器 (可能超出…

为什么越来越多的企业选择云计算?

一、前言 1.当下企业信息化的痛点 企业信息化&#xff0c;这也算是一个老生常谈的话题了&#xff0c;整个中国业内前前后后应该喊了有十多年了。不过到目前为止&#xff0c;我国很多企业公司都还没真正形成一个完整的信息化框架&#xff0c;或者只是运用了一个简单财务或客户…

Vue3组件通信 含有详细的步骤和解释

提示&#xff1a;写完文章后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 文章目录 前言一、父传子 defineProps1.使用运行时声明2.使用针对类型的声明 二、子传父 defineEmits1.使用运行时声明2.使用针对类型的声明 总结 前言 Vue2的父子组件通信是…

PMP课堂模拟题目及解析(第4期)

31. 首席执行官要求项目经理提供绩效报告。项目经理应该在这份报告中包含哪些内容&#xff1f; A. 已完成百分比和经验教训 B. 问题的当前状态以及更新后的干系人参与评估矩阵 C. 项目风险的绩效测量指标 D. 已完成的工作和关键绩效指标 32. 在一个项目的最终收尾活动期…

九联UNT413A-S905L3A-免拆U盘卡刷固件包-当贝纯净桌面-内有教程

九联UNT413A-S905L3A-免拆U盘卡刷固件包-当贝纯净桌面-内有教程 特点&#xff1a; 1、适用于对应型号的电视盒子刷机&#xff1b; 2、开放原厂固件屏蔽的市场安装和u盘安装apk&#xff1b; 3、修改dns&#xff0c;三网通用&#xff1b; 4、大量精简内置的没用的软件&#…

迪赛智慧数——饼图(玫瑰饼图):菜品味道受欢迎程度

效果图 大家最爱吃的竟是它&#xff01;咸鲜占比高达23.53%&#xff01; 民以食为天&#xff0c;你最喜欢的美食口味是什么呢&#xff1f; 好吃的太多&#xff0c;你应该很难确切地评出你心中的第一名吧。据数据调查显示&#xff0c;咸鲜口味最受欢迎&#xff0c;其次是麻辣、…

SUNTANS模型学习(9)——学习Tidal forcing算例

学习Tidal forcing算例 简介网格配置与地形定解条件设置初始条件设置边界条件设置开边界处的通量计算&#xff08;OpenBoundaryFluxes&#xff09;开边处的速度、水位&#xff08;BoundaryVelocities&#xff09; 其它参数配置模拟结果 简介 SUNTANS中 tidal forcing 算例的全…

数据脱敏的几种方案

文章目录 什么是数据脱敏&#xff1f;数据脱敏在生活中的应用静态脱敏与动态脱敏数据脱敏的几种方案sql数据脱敏java代码实现脱敏mybatis-mate实现脱敏springCloud网关拦截响应体实现脱敏openGauss 动态数据脱敏解决方案 什么是数据脱敏&#xff1f; 数据脱敏也叫数据的去隐私…

5款办公必备的好软件,你值得拥有

随着网络信息技术的发展&#xff0c;越来越多的人在办公时需要用到电脑了。如果你想提高办公效率&#xff0c;那么就少不了工具的帮忙&#xff0c;今天给大家分享5款办公必备的好软件。 1.文件管理工具——TagSpaces TagSpaces 是一款开源的文件管理工具,它可以通过标签来组织…

测试20K要什么水平?25岁测试工程师成功斩下offer(附面试题)

年少不懂面试经&#xff0c;读懂已是测试人。 大家好&#xff0c;我是一名历经沧桑&#xff0c;看透互联网行业百态的测试从业者&#xff0c;经过数年的勤学苦练&#xff0c;精钻深研究&#xff0c;终于从初出茅庐的职场新手成长为现在的测试老鸟&#xff0c;早已看透了面试官…

@Test单测方法和main方法的区别

区别1:常量池的符号引用 有所不同 今天在下面链接中 学习并测试 string在不同创建方式下 产生几个对象的问题 流程图详解 new String(“abc“) 创建了几个字符串对象_"new string(\"abc\")创建了几个对象"_程序员囧辉的博客-CSDN博客 看下面例子(取自上…

家居家具行业外贸软件解决方案

家具行业&#xff0c;属于装饰行业范畴&#xff0c;如果出售是零售行业&#xff0c;如果生产属于加工行业&#xff0c;如果是带加工&#xff0c;也可以叫二次加工行业。家居行业&#xff0c;泛指家具、床上用品、厨卫用具、室内配饰及日常生活需要的商品&#xff0c;属于消费产…