深刻理解Java并发框架AQS系列(一):线程
深刻理解Java并发框架AQS系列(二):AQS框架简介及锁概念
深刻理解Java并发框架AQS系列(三):独占锁(Exclusive Lock)
深刻理解Java并发框架AQS系列(四):共享锁(Shared Lock)html
那些“简单的”并发代码背后,隐藏着大量信息。。。数据结构
独占锁虽然说在j.u.c
中有现成的实现,但在JAVA的语言层面也一样提供了支持(synchronized
);但共享锁倒是只存在于AQS中,而它在实际生产中的使用频次丝绝不亚于独占锁,在整个AQS体系中占有举重若轻的地位。而在某种意义上,由于可能同时存在多个线程的并发,它的复杂度要高于独占锁。本章除了介绍共享锁数据结构等,还会重点对焦并发处理,看 doug lea 在并发部分是否有遗漏并发
j.u.c
下支持的并发锁有Semaphore
、CountDownLatch
等,本章咱们采用经典并发类Semaphore
来阐述框架
共享锁实际上是相对独占锁而言的,涉及到共享锁就要聊到并发度,即同一时刻最多容许同时执行线程的数量。上图所述的并发度为3,即在同一时刻,最多可有3我的在同时过河。高并发
但共享锁的并发度也能够设置为1,此时它能够看做是一个特殊的独占锁oop
waitStatus
在独占锁章节中,咱们介绍到了关键的状态标记字段waitStatus
,它在独占锁的取值有性能
0
SIGNAL (-1)
CANCELLED (1)
而这些取值在共享锁中也都存在,含义也保持一致,而除了上述这3个取值外,共享锁还额外引入了新的取值:测试
PROPAGATE (-3)
且-3
这个取值在整个AQS体系中,只存在于共享锁中,它的存在是为了更好的解决并发问题,咱们将在后文中详细介绍ui
本人参加的某性能挑战赛中,有这样一个场景:数据产生于CPU,且有12个线程在不断的制造数据,而这些数据须要持久化到磁盘中,因为数据产生的很是快,此时的瓶颈卡在IO上;磁盘的性能通过基准测试,发现每次写入8K数据,且开4个线程写入时,能将IO打满;但如何控制在同一时刻,最多有4个线程进行IO写入呢?线程
其实这是一个典型的使用共享锁的场景,咱们用三四行代码便可解决
// 设置共享锁的并发度为4 Semaphore semaphore = new Semaphore(4); // 加锁 semaphore.acquire(); // 执行数据存储 storeIO(); // 释放锁 semaphore.release();
共享锁的总体流程与独占锁类似,都是首先尝试去获取资源(子类逻辑,通常是CAS操做)
二者的不一样点在什么地方呢?就在于“唤醒阻塞队列的头结点”的操做。在独占锁时,唤醒头结点的操做,只会有一个线程(加锁成功的线程调用release()
)去触发;而在共享锁时,可能会有多个线程同时去调用释放
直观感受这样设计不太合理:若是多个线程同时去唤醒头结点,而头结点只能被唤醒一次,假定阻塞队列中有20个节点,那这些节点只能等待上一个节点执行完毕后才会被唤醒,无形中共享锁的并发度变成了1。要解决这个疑问,咱们先来看共享锁的释放逻辑
先来思考一下锁释放须要作的事儿
共享锁如何解决这两个问题呢?咱们接下来逐一阐述
与独占锁不一样,共享锁调用“锁释放”有2个地方(注:AQS的一个阻塞队列是能够同时添加独占节点、共享节点的,为了简化模型,咱们这里暂不讨论这种混合模型)
ws < 0
那这两个点调用的时候,是否存在并发呢?有同窗会说“a存在并发,b是串行的”;其实此处b也是存在并发的,例如线程1更换了head节点后,准备执行“锁释放”逻辑,正在此时,线程2正常锁释放后,唤醒了新的head节点(线程3),线程3又会执行更换head节点,并准备执行“锁释放”逻辑;此时线程1跟线程3都准备执行“锁释放”逻辑
既然“锁释放”存在这么多并发,那就必定要保证“锁释放”逻辑是幂等的,那它又是如何作到呢?
直接贴一下它的源码吧,释放锁的代码寥寥几笔,却很难说它简单
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; } }
对应的流程图以下:
咱们简单描述一下锁释放作的事儿
h
,同时获取h.waitStatus
,并标记位ws
ws
的状态
ws == -1
表示下一个节点已经挂起,或即将挂起。若是只要发现是-1状态,就进行线程唤起的话,由于存在并发,可能致使目标线程被唤起屡次,故此处须要经过CAS进行抢锁,保证只有一个线程去唤起ws == 0
若是发现节点ws
为0,此处会存在两种状况(状况1:节点刚新建完毕,还未进入阻塞队列;状况2:节点由-1修改成了0),无论哪一种状况,都强制将其由-1改成-3,标记位强制传播,此处是否存在漏洞?ws == -3
表示当前节点已经被标识为强制传播了,直接结束h == head
,说明在上述逻辑发生时,头结点没有发生变化,那么结束当前操做,不然重复上述步骤。注:AQS中全部节点只有一次当头结点的机会,也就是某个节点当过一次头结点后,便会被抛弃,再无可能第二次成为头结点,这点相当重要根据以上分析,咱们发现,节点的状态流转是经过ws
来控制的,即0、-一、-3,乍看上去,貌似不太严谨,那咱们来作具体分析
ws
状态流转仅有2个功能点会对ws
进行修改,一是将节点加入阻塞队列时,二就是3.2.1中描述的调用锁释放逻辑时;
咱们将加入阻塞队列时ws
的状态流转再回忆下:
综述,咱们出一张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.2章节已经描述的较为清楚,解锁的逻辑只负责激活头节点,那如何保证共享锁的并发度?
咱们仍是假定这样一个场景:共享锁的并发度为5,阻塞队列中有20个节点,只有head节点已被唤醒,且没有新的请求进入,咱们但愿在同一时刻,同时有5个节点处于激活状态。针对上述场景,aqs如何作到呢?
其实head节点被激活时,在第一时间会通知后续节点,并将其唤醒,而后才会执行同步块逻辑,保证了等待中的节点快速激活