写点什么

三千余字搞定 Java 并发框架 AQS,干货

作者:Geek_dd2241
  • 2022 年 7 月 08 日
  • 本文字数:3302 字

    阅读完需:约 11 分钟

那些“简单的”并发代码背后,隐藏着大量信息。。。

独占锁虽说在 j.u.c 中有现成的实现,但在 JAVA 的语言层面也同样提供了支持(synchronized);但共享锁却是只存在于 AQS 中,而它在实际生产中的使用频次丝毫不亚于独占锁,在整个 AQS 体系中占有举重若轻的地位。而在某种意义上,因为可能同时存在多个线程的并发,它的复杂度要高于独占锁。本章除了介绍共享锁数据结构等,还会重点对焦并发处理,看 doug lea 在并发部分是否有遗漏

j.u.c 下支持的并发锁有 Semaphore、CountDownLatch 等,本章我们采用经典并发类 Semaphore 来阐述

二、简介



共享锁其实是相对独占锁而言的,涉及到共享锁就要聊到并发度,即同一时刻最多允许同时执行线程的数量。上图所述的并发度为 3,即在同一时刻,最多可有 3 个人在同时过河。

但共享锁的并发度也可以设置为 1,此时它可以看作是一个特殊的独占锁

2.1、waitStatus

在独占锁章节中,我们介绍到了关键的状态标记字段 waitStatus,它在独占锁的取值有

  • 0

  • SIGNAL (-1)

  • CANCELLED (1)

而这些取值在共享锁中也都存在,含义也保持一致,而除了上述这 3 个取值外,共享锁还额外引入了新的取值:

  • PROPAGATE (-3)

且-3 这个取值在整个 AQS 体系中,只存在于共享锁中,它的存在是为了更好地解决并发问题,我们将在后文中详细介绍

2.2、使用场景

本人参加的某性能挑战赛中,有这样一个场景:数据产生于 CPU,且有 12 个线程在不断地制造数据,而这些数据需要持久化到磁盘中,由于数据产生的非常快,此时的瓶颈卡在 IO 上;磁盘的性能经过基准测试,发现每次写入 8K 数据,且开 4 个线程写入时,能将 IO 打满;但如何控制在同一时刻,最多有 4 个线程进行 IO 写入呢?



其实这是一个典型的使用共享锁的场景,我们用三四行代码即可解决

// 设置共享锁的并发度为4Semaphore semaphore = new Semaphore(4);// 加锁semaphore.acquire();// 执行数据存储storeIO();// 释放锁semaphore.release();
复制代码

三、并发

3.1、独占锁 vs 共享锁

共享锁的整体流程与独占锁相似,都是首先尝试去获取资源(子类逻辑,一般是 CAS 操作)

  • 如果能拿到资源,那么进入同步块执行业务代码;当同步块执行完毕后,唤醒阻塞队列的头结点

  • 如果资源已空,那么进入阻塞队列并挂起,等待被其他线程唤醒

两者的不同点在什么地方呢?就在于“唤醒阻塞队列的头结点”的操作。在独占锁时,唤醒头结点的操作,只会有一个线程(加锁成功的线程调用 release())去触发;而在共享锁时,可能会有多个线程同时去调用释放



直观感觉这样设计不太合理:如果多个线程同时去唤醒头结点,而头结点只能被唤醒一次,假定阻塞队列中有 20 个节点,那这些节点只能等待上一个节点执行完毕后才会被唤醒,无形中共享锁的并发度变成了 1。要解决这个疑问,我们先来看共享锁的释放逻辑

3.2、锁释放

先来思考一下锁释放需要做的事儿

  1. 阻塞队列的第一个节点一定要被激活;这个问题看似不值一提,却相当重要,区别于独占锁,共享锁的锁释放是存在并发的,在高并发的流量下,一定要保证阻塞队列的第一个有效节点被激活,否则会导致阻塞队列永久性的挂死

  2. 保证激活阻塞队列时的并发度;这个问题同样也是独占锁不存在的,也就是我们在 3.1 提出的问题;假定这样一种场景:“共享锁的并发度为 10,阻塞队列中有 100 个待处理的节点,而此时又没有新的加锁请求,如何保证在激活阻塞队列时,保持 10 的并发度?”

共享锁如何解决这两个问题呢?我们接下来逐一阐述

3.2.1、调用点

与独占锁不同,共享锁调用“锁释放”有 2 个地方(注:AQS 的一个阻塞队列是可以同时添加独占节点、共享节点的,为了简化模型,我们这里暂不讨论这种混合模型)

  • a、某线程同步块执行完毕,正常调用解锁逻辑;此点与独占锁一致

  • b、在每次更换头结点时,如果满足以下任一条件,同样会调用“锁释放”;更换头结点的操作,其实此时已经意味着当前线程已经加锁成功 b.1、有额外的资源可用;拿信号量举例,当发现信号量数量>0 时,表示有额外资源可用 b.2、旧的头结点或当前头结点的 ws < 0

那这两个点调用的时候,是否存在并发呢?有同学会说“a 存在并发,b 是串行的”;其实此处 b 也是存在并发的,例如线程 1 更换了 head 节点后,准备执行“锁释放”逻辑,正在此时,线程 2 正常锁释放后,唤醒了新的 head 节点(线程 3),线程 3 又会执行更换 head 节点,并准备执行“锁释放”逻辑;此时线程 1 跟线程 3 都准备执行“锁释放”逻辑



既然“锁释放”存在这么多并发,那就一定要保证“锁释放”逻辑是幂等的,那它又是如何做到呢?

3.2.1、锁释放

直接贴一下它的源码吧,释放锁的代码寥寥几笔,却很难说它简单

private void doReleaseShared() {    for (;;) {        Node h = head;        if (h != null && h != tail) {            int ws = h.waitStatus;            if (ws == Node.SIGNAL) {                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))                    continue;            // loop to recheck cases                unparkSuccessor(h);            }            else if (ws == 0 &&                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))                continue;                // loop on failed CAS        }        if (h == head)                   // loop if head changed            break;    }}
复制代码

对应的流程图如下:



我们简单描述一下锁释放做的事儿

  • 1、首选获取头结点的快照,并将其赋予变量 h,同时获取 h.waitStatus,并标记位 ws

  • 2、判断 ws 的状态 ws == -1 表示下一个节点已经挂起,或即将挂起。如果只要发现是-1 状态,就进行线程唤起的话,因为存在并发,可能导致目标线程被唤起多次,故此处需要通过 CAS 进行抢锁,保证只有一个线程去唤起 ws == 0 如果发现节点 ws 为 0,此处会存在两种情况(情况 1:节点刚新建完毕,还未进入阻塞队列;情况 2:节点由-1 修改为了 0),不管哪种情况,都强制将其由-1 改为-3,标记位强制传播,此处是否存在漏洞?ws == -3 表示当前节点已经被标识为强制传播了,直接结束

  • 3、如果此时 h == head,说明在上述逻辑发生时,头结点没有发生变化,那么结束当前操作,否则重复上述步骤。注:AQS 中所有节点只有一次当头结点的机会,也就是某个节点当过一次头结点后,便会被抛弃,再无可能第二次成为头结点,这点至关重要

根据以上分析,我们发现,节点的状态流转是通过 ws 来控制的,即 0、-1、-3,乍看上去,貌似不太严谨,那我们来做具体分析

3.2.2、ws 状态流转

仅有 2 个功能点会对 ws 进行修改,一是将节点加入阻塞队列时,二就是 3.2.1 中描述的调用锁释放逻辑时;

我们将加入阻塞队列时 ws 的状态流转再回忆下:

  • 状态为 0(初始状态),加入阻塞队列前,需要将前节点修改为-1,然后进入线程挂起

  • 状态为-3(强制传播状态,被解锁线程标记),加入阻塞队列前,同样需要将前节点修改为-1,然后进入线程挂起

综述,我们出一张 ws 的整体状态流转图



由上图可得知,只要解锁逻辑成功通过 CAS 将 head 节点由-1 修改为 0 的话,那么就要负责唤醒阻塞队列中的第一个节点了

整个流转过程有 bug 吗?我们设想如下场景:共享锁的并发度设置为 1,A、B 两个线程同时进入加锁逻辑,B 线程成功抢到锁,并开始进入同步块,A 线程抢锁失败,准备挂到阻塞队列,正常流程是 A 线程将 ws 由 0 修改为-1 后,进入挂起状态,但 B 线程执行较快,已经优先 A 线程并开始执行解锁逻辑,将 ws 由 0 修改为了-3,然后 B 线程正常结束;A 线程发现 ws 为-3 后,将其修改为-1,然后进入挂起。 如果这个场景真实发生的话,A 线程将永久处于挂起状态,那岂不是存在漏洞?

然而事实并非如此,因为只要 A 线程将 ws 修改为-1 后,都要再尝试进行一次获取锁的操作,正是这个操作避免了上述情况的发生,可见 aqs 是很严谨的



3.3、保证并发度

阻塞队列中节点的激活顺序是什么样呢?其实激活顺序 3.2 章节已经描述的较为清楚,解锁的逻辑只负责激活头节点,那如何保证共享锁的并发度?

我们还是假定这样一个场景:共享锁的并发度为 5,阻塞队列中有 20 个节点,只有 head 节点已被唤醒,且没有新的请求进入,我们希望在同一时刻,同时有 5 个节点处于激活状态。针对上述场景,aqs 如何做到呢?



其实 head 节点被激活时,在第一时间会通知后续节点,并将其唤醒,然后才会执行同步块逻辑,保证了等待中的节点快速激活

用户头像

Geek_dd2241

关注

还未添加个人签名 2021.12.12 加入

还未添加个人简介

评论

发布
暂无评论
三千余字搞定Java并发框架AQS,干货_AQS_Geek_dd2241_InfoQ写作社区