适合读者:3 年以上经验的同学
谈到并发编程,基本上都会想到JDK 的 JUC 工具包,它包含 锁,并发工具类,原子类,线程池,还有阻塞队列,这是从网上找的一个大致的知识体系。
相信这些工具读者都见过并使用过一部分了,比如 CountDownLatch,线程池,原子类,但是可能不了解其中的原理,而面试可能要求更高一点,要求说出其原理,或者经常有这么一问,如果是你,你会怎么去实现。
本文主要讲 AQS 的实现,需要你有如下基础
本文参考自 https://www.cnblogs.com/waterystone/p/4920797.html 个人不太喜欢贴源码的风格,把一个很简单的东西说得很复杂了。
AQS是AbstractQueuedSynchronizer的简称,AQS提供了一种实现阻塞锁和一系列依赖FIFO等待队列的同步器的框架,如下图所示。
就是多个线程对共享资源 state 的获取与释放,AQS 在顶层维护了一个线程队列,它实现了线程的出队,入队,唤醒,和节点状态的维护,使用模板方法模式,为子类提供了一些有用的方法,子类只要实现资源的获取与释放即可。
它有两对方法分别用于独占资源和共享资源 acquire-release,acquireShared-releaseShared
下面 -- 标记的都是方法名,可以自己去源码中查看,如果当前方法不清楚含义可以来这里看
下面的流程一定要对照源码 AbstractQueuedSynchronizer
来查看,不然可能不知道我在说什么,这里说的是独占模式和共享模式资源的获取和释放,这个框架只会来管你有几个资源,不会管你资源的其它属性。
获取独占资源的流程是这样子的 -- acquire
释放独占资源的流程是这样子的 -- release
tryAcquireShared 的含义是,如果返回的结果小于 0 ,表示没有可用资源,如果等于 0 表示最后一个可用资源,如果大于 0 ,表示获取成功并且还有可用资源
获取共享资源流程是这样子的 -- acquireShared
释放共享资源流程
这里我们说下Node。Node结点是对每一个等待获取资源的线程的封装,其包含了需要同步的线程本身及其等待状态,如是否被阻塞、是否等待唤醒、是否已经被取消等。变量waitStatus则表示当前Node结点的等待状态,共有5种取值CANCELLED、SIGNAL、CONDITION、PROPAGATE、0。
注意:负值表示结点处于有效等待状态,而正值表示结点已被取消。所以源码中很多地方用>0、<0来判断结点的状态是否正常。
在独占模式中只需要关注 3 个状态 > 0 的为取消,0 是默认就是这个状态 < 0 是可唤醒后继节点
学习这个不能只看这个队列,因为它是抽象出来的一个框架,需要结合具体的使用示例,如 CountDownLatch ,去看它是如何工作的。
CountDownLatch 构造了一个有 n 个资源的同步队列,使用共享模式,因为会有多个线程同时访问资源,每当 countDown 的时候,去释放了一个资源,然后在主线程 await 的时候去获取一个共享资源,但这个获取资源是只有资源量为 0 的时候才是成功的,核心代码就一句话。
protected int tryAcquireShared(int acquires) { return (getState() == 0) ? 1 : -1; }
再看看独占锁的使用 ReentrantLock
,它使用独占资源,同一时刻只能有一个线程访问共享资源,增加了一个属性 OwnerThread ,当再次是同一个线程获取锁的时候是可重入的,有公平锁和非公平锁的实现,非公平锁在进行 lock 的时候 ,会先去试图将 state 设置为 1 ,预期为 0 ,如果成功,则成功插队,公平锁在 tryAcquire 增加了一个判断 hasQueuedPredecessors 如果有线程在排队就乖乖排队吧,并且没有在 lock 的时候尝试去占用资源 。
这里解释下在释放资源的时候 unparkSuccessor
方法为什么要从队尾开始找,从前面开始找不是更快吗
要理解这个,你首先得理解双向队列是如何插入节点的,假设有这样一个队列
head = A <---> B = tail
需要往队列尾加入节点 C ,应该是这样操作的
C.prev = tail; tail.next = C; tail = C;
在多线程并发情况下,这里多步操作并不是原子性的,tail 属于临界资源随时可能被其它线程修改成其它指向。
假设这种场景 C 在入队的时候 ,进行 tail.next =C 这一步前 ,D 也在入队,如果 C 先完成,这时队列结构会变成这样子
head = A <---> B <---> C
B <---> D = tail
AQS 使用了自旋和 CAS 来解决这个麻烦 ,它的代码是这样子的,在 enq 函数中
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; } } }
这个函数的主要作用是当那[一瞬间]在队列为空的时候进入这个方法创建队列,有可能在正想初始化队列的时候已经被其它线程初始化了,所以初始化时使用了 CAS compareAndSetHead(),然后再把 tail 的指向和 head 的指向一致,这里因为另一个线程不能初始化成功,所以这里来说是正确的。
再看 else ,当其它线程已经初始化完成 ,tail 不为空的时候会进入这里,这个 t 只是表示这一瞬间的尾结点,尾节点可能被其它线程给修改了,使用 CAS compareAndSetTail 保证了只有当尾节点是这个瞬时尾结点的时候才能修改 node.prev=t ,保证了节点的前置结点一定是正确的,但后置节点就不一定了,如果在 t.next=node 赋值之前 tail 被修改,则队列会很可能变成这种样子
假设并发加入了节点 C,D,E
head = A <---> B <---> C <--- D <--- E = tail
B ---> D C ---> E
从此图可以看出,最终于,从后往前找链表是连续的,但从前往后找是不一定的,回答了最开始的问题。
写得有点晦涩,我也是想以最通俗易懂的方式写出来,但表达水平有限,望大神指正。
创作不易,希望可以支持下我的开源软件,及我的小工具,欢迎来 gitee 点星,fork ,提 bug 。
Excel 通用导入导出,支持 Excel 公式
博客地址: https://blog.csdn.net/sanri1993/article/details/100601578
gitee: https://gitee.com/sanri/sanri-excel-poi
使用模板代码 ,从数据库生成代码 ,及一些项目中经常可以用到的小工具
博客地址: https://blog.csdn.net/sanri1993/article/details/98664034
gitee: https://gitee.com/sanri/sanri-tools-maven