一行一行源码分析清楚AQS[转]

在分析java并发包java.util.concurrent源码的时候,少不了需要了解AbstractQueuedSynchronizer(以下简写AQS)这个抽象类,因为它是java并发包的基础工具类,是实现ReentrantLock、CountDownLatch、Semaphore、FutureTask等类的基础。

Google一下AbstractQueuedSynchronizer,我们可以找到很多关于AQS的介绍,但是很多都没有介绍清楚,因为大部分文章没有把其中的一些关键的细节说清楚。

本文将从ReentrantLock的公平锁源码出发,分析下AbstractQueuedSynchronizer这个类是怎么工作的,希望能给大家提供一些简单的帮助。

原文链接 https://hongjiev.github.io/2017/06/16/AbstractQueuedSynchronizer/

申明以下几点:

  1. 本文有点长,但是很简单很简单很简单,主要面向读者对象为并发编程的初学者,或者想要阅读java并发包源码的开发者。
  2. 建议在电脑上阅读,如果你想好好地理解所有的细节,而且你从来没看过相关的分析,你可能至少需要20分钟仔细看所有的描述,本文后面的1/3以上很简单,前面的1/4更简单,中间的部分要好好看。
  3. 如果你不知道为什么要看这个,我想告诉你,即使你看懂了所有的细节,你可能也不能把你的业务代码写得更好
  4. 源码环境JDK1.7,看到不懂或有疑惑的部分,最好能自己打开源码看看。Doug Lea大神的代码写得真心不错。
  5. 有很多英文注释我没有删除,这样读者可以参考着英文说的来,万一被我忽悠了呢
  6. 本文不分析共享模式,这样可以给读者减少很多负担,只要把独占模式看懂,共享模式读者应该就可以顺着代码看懂了。而且也不分析condition部分,所以应该说很容易就可以看懂了。
  7. 本文大量使用我们平时用得最多的ReentrantLock的概念,本质上来说是不正确的,读者应该清楚,AQS不仅仅用来实现锁,只是希望读者可以用锁来联想AQS的使用场景,降低读者的阅读压力
  8. ReentrantLock的公平锁和非公平锁只有一点点区别,没有任何阅读压力
  9. 你需要提前知道什么是CAS(CompareAndSet)

废话结束,开始。

AQS结构

先来看看AQS有哪些属性,搞清楚这些基本就知道AQS是什么套路了,毕竟可以猜嘛!

怎么样,看样子应该是很简单的吧,毕竟也就四个属性啊。

AbstractQueuedSynchronizer的等待队列示意如下所示,注意了,之后分析过程中所说的queue,也就是阻塞队列不包含head,不包含head,不包含head。

等待队列中每个线程被包装成一个node,数据结构是链表,一起看看源码吧:

Node的数据结构其实也挺简单的,就是 thread + waitStatus + pre + next 四个属性而已,大家先要有这个概念在心里。

上面的是基础知识,后面会多次用到,心里要时刻记着它们,心里想着这个结构图就可以了。下面,我们开始说ReentrantLock的公平锁。多嘴一下,我说的阻塞队列不包含head节点。

首先,我们先看下ReentrantLock的使用方式。

ReentrantLock在内部用了内部类Sync来管理锁,所以真正的获取锁和释放锁是由Sync的实现类来控制的。

Sync有两个实现,分别为NonfairSync(非公平锁)和FairSync(公平锁),我们看FairSync部分。

线程抢锁

很多人肯定开始嫌弃上面废话太多了,下面跟着代码走,我就不废话了。

说到这里,也就明白了,多看几遍final boolean acquireQueued(final Node node, int arg)这个方法吧。自己推演下各个分支怎么走,哪种情况下会发生什么,走到哪里。

解锁操作

最后,就是还需要介绍下唤醒的动作了。我们知道,正常情况下,如果线程没获取到锁,线程会被LockSupport.park(this);挂起停止,等待被唤醒。

唤醒线程以后,被唤醒的线程将从以下代码中继续往前走:

好了,后面就不分析源码了,剩下的还有问题自己去仔细看看代码吧。

总结

总结一下吧。

在并发环境下,加锁和解锁需要以下三个部件的协调:

  1. 锁状态。我们要知道锁是不是被别的线程占有了,这个就是state的作用,它为0的时候代表没有线程占有锁,可以去争抢这个锁,用CAS将state设为1,如果CAS成功,说明抢到了锁,这样其他线程就抢不到了,如果锁重入的话,state进行+1就可以,解锁就是减1,直到state又变为0,代表释放锁,所以lock()和unlock()必须要配对啊。然后唤醒等待队列中的第一个线程,让其来占有锁。
  2. 线程的阻塞和解除阻塞。AQS中采用了LockSupport.park(thread) 来挂起线程,用unpark来唤醒线程。
  3. 阻塞队列。因为争抢锁的线程可能很多,但是只能有一个线程拿到锁,其他的线程都必须等待,这个时候就需要一个queue来管理这些线程,AQS用的是一个FIFO的队列,就是一个链表,每个node都持有后继节点的引用。AQS采用了CLH锁的变体来实现,感兴趣的读者可以参考这篇文章关于CLH的介绍,写得简单明了: http://coderbee.net/index.php/concurrent/20131115/577

示例图解析

下面属于回顾环节,用简单的示例来说一遍,如果上面的有些东西没看懂,这里还有一次帮助你理解的机会。

首先,第一个线程调用reentrantLock.lock(),翻到最前面可以发现,tryAcquire(1) 直接就返回true了,结束。只是设置了state=1,连head都没有初始化,更谈不上什么阻塞队列了。要是线程1调用unlock()了,才有线程2来,那世界就太太太平了,完全没有交集嘛,那我还要AQS干嘛。

如果线程1没有调用unlock()之前,线程2调用了lock(), 想想会发生什么?

线程2会初始化head【new Node()】,同时线程2也会插入到阻塞队列并挂起 (注意看这里是一个for循环,而且设置head和tail的部分是不return的,只有入队成功才会跳出循环)

首先,是线程2初始化head节点,此时head==tail, waitStatus==0

然后线程2入队:

同时我们也要看此时节点的waitStatus,我们知道head节点是线程2初始化的,此时的waitStatus没有设置,java默认会设置为0,但是到shouldParkAfterFailedAcquire这个方法的时候,线程2会把前驱节点,也就是head的waitStatus设置为-1。

那线程2节点此时的waitStatus是多少呢,由于没有设置,所以是0;

如果线程3此时再进来,直接插到线程2的后面就可以了,此时线程3的waitStatus是0,到shouldParkAfterFailedAcquire方法的时候把前驱节点线程2的waitStatus设置为-1。

这里可以简单说下waitStatus中SIGNAL(-1)状态的意思,Doug Lea注释的是:代表后继节点需要被唤醒。也就是说这个waitStatus其实代表的不是自己的状态,而是后继节点的状态,我们知道,每个node在入队的时候,都会把前驱节点的状态改为SIGNAL,然后阻塞,等待被前驱唤醒。这里涉及的是两个问题:有线程取消了排队、唤醒操作。其实本质是一样的,读者也可以顺着“waitStatus代表后继节点的状态”这种思路去看一遍源码。

(全文完)

One comment on “一行一行源码分析清楚AQS[转]

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

%d 博主赞过: