近期观看了B站上子路老师的关于AQS与ReentrantLock的讲解,同时怀着对于Doug Lea大神的敬佩之情,自己去研读分析了一下关于这个自JDK1.5出现的要替代synchronized的ReentrantLock的上锁流程(当然这里为了防止杠精特地声明以下,synchronized自jdk1.6以后改变为锁升级的一个流程,性能方面与ReentrantLock不分上下,具体的升级流程可自行百度或者后续会再出帖子进行该知识点的补充)被其中的一处判断所惊艳到,由此激发写下这边博客。上锁的基本思路便是自旋+CAS+park这样的一个核心思想,接下来会通过源码的解读来展现出在这一基本的思路之下Doug Lea对于该API的精妙刀法和巧妙的思路。同时本篇文章并不能保证初学者或者是刚接触多线程的同行们能立刻看懂,希望看完我这篇文章再去看源码可以帮助大家理解,也算是目的达到了。
首先介绍以下什么是CAS和自旋:这两个概念在多线程当中是频频出现,首先介绍CAS(CompareAndSwap)操作:通过该方法的全拼名字可以显而易见的得知其内部含义:比较并交换,这里如果要对一个变量或者对象进行CAS操作,首先会获取它的当前值V并存储起来,之后尝试进行更改为想要更新的值E,最后会再次进行获取这个变量或对象的当前值N,如果此时V=N代表CAS操作成功,更新操作会被执行,如果不相等那么此次更新失败,通过自旋再进行CAS(注解:这里只是针对CAS操作的一个简单解释,在比较V==N时也会出现经典的ABA问题,由于该操作是unsafe类提供的一个本地方法,具体其内部是通过版本号进行解决还是通过其他方法因为没有查证在此不予解释。CAS算法的具体内容可以百度专门讲解的帖子);自旋操作就是将一系列操作进行一次循环,称为一次自旋,可以简单的理解其实自旋就是自循环。
源码分析
1:首先调用reentrantlock的lock方法,在这里我将针对公平锁的流程进行解释,因为公平锁与非公平锁的区别在于进行CAS修改
状态时,公平锁会先进行一次判断当前线程是否需要排队,之后进行CAS操作,非公平锁是直接进行CAS操作。
// ReentrantLock中的lock方法,调用了sync的lock方法(sync继承了AbstractQueuedSynchronizer,所以最终调用的是
//AQS中的lock方法)
public void lock() {
sync.lock();
}
通过CTRL+ALT+B(IDEA快捷键)查看子类中的实现方法,这里选择公平锁
// 公平锁是定义在ReentrantLock的内部类,其继承了Sync,又由于Sync继承了AQS,所以该acquire真实调用的是AQS中的acquire方法
// 参数1是需要将当前锁对象状态state(状态字段)置为1,代表上锁成功
final void lock() {
acquire(1);
}
// acquire方法(获取锁方法)首先进行第一步操作tryAcquire,tryAcquire方法是一个受保护的方法,需要查看子类具体实现
// 所以需要查看在公平锁中的tryAcquire方法的实现。
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
protected final boolean tryAcquire(int acquires) {
// 1-1:首先获取当前线程(注意:1-1前面的 1- 代表是第一次抢锁流程,假设当前有许多线程在进行抢锁,多个线程
// 都进入到该方法中)
// 2-1 : 第二轮多个线程尝试获取锁,如果此时其中一个线程已经持有该锁,在执行某个方法时又要需要获取该锁,
// 会进入到else if语句块
final Thread current = Thread.currentThread();
// 1-2:之后子类调用AQS中的getState方法获取当前线程对应的state
int c = getState();
// 1-3: 由于该锁对象是第一次被争抢,所以肯定是0.直接进入到if内部
if (c == 0) {
// 1-4:这里便体现了公平锁的一个概念:小伙子们你们进来抢我这把锁前先看看自己要不要排队(注意这个排队概念
// 接下来解释会有些抽象)即此时会先调用hasQueuedPredecessors方法,此处直接跳转到该方法解析处
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// 2-2 : 若当前这个线程是该锁被占用的线程,则在原有状态值上+1.这里体现了可重入的特性,无需再次上锁节约时间
// 减少了性能消耗
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
2.在这里先介绍一下AQS队列这一个概念:AQS类中存在一个静态内部类Node(随着锁对象被初始化其父类 AQS初始化
紧接着Node也会被初始化),这个节点类Node充当了一个队列对象(其内部包含三个关键字段:prev
(指向上一个节点)、next(指向下一个节点)、thread:当前处在队列中的线程对象)同时AQS中有两个关键字段head和tail分别
是队列头节点指针和队列尾节点指针。
// 这个方法便是最为精髓的一个方法
public final boolean hasQuereuedPredecessors() {
// 1-1 : 第一次多个线程进入到这一步进行判断,head和tail是AQS类的两个属性
// 此时是第一次发生多个线程争抢锁,head和tail只是被初始化所以两者都为null,所以多个线程进来之后h != t
// 所有线程判定都为false该方法直接返回false代表当前这些线程都不需要排队。(注意:这是该方法的第一种情况,
// 之后会解释这个排队的概念)当该方法返回false后对应着上一个tryAcquire方法中的判断
// !hasQueuedPredecessors()== true 此时多个线程开始进行 compareAndSetState(0, acquires)这一步CAS
// 操作将当前锁状态置为1,设置成功者则抢锁成功,上锁成功后执行setExclusiveOwnerThread(current)该方法就
// 是给锁对象中的线程字段赋值为当前持有这把锁的线程。由于其他线程进行CAS操作失败tryAcquire返回false,对应着
// acquire方法中的判断!tryAcquire(arg)==true然后执行后续的入队操作,此时跳转到addWaiter()。
Node t = tail;
Node h = head;
Node s;
// 这里这个步骤等之后某处会有提醒过来再详细看该处的(这个方法是公平锁中最精髓的地方)
// 第一种情况:首先是初次多个线程来争抢这把锁,正如上段注释所说,AQS队列未被创建h!=t为false所以代表这不用入队
// 第二种情况:如果AQS队列已经存在,但是队列中没有线程对应的节点在队列中只有一个虚拟接待你,此时h != t 依旧为false
//代表不用入队
// 第三种情况:如果AQS队列已经存在,并且当前队列中存在一个线程对应的node节点在head指向的节点的下一个,此时
// h!=t为true进入后续的判断,此时又分成了两种情况
// 3-1:第三种情况中的第一种情况:由于此时只有一个线程对应的node节点存在,这个线程其实不算是排队,但是它确实处在
// AQS队列中,所以它很有可能在之前h!=t判断通过后成功的抢到锁,所以此时进行赋值操作s =h.next 这时s其实拿到的是
//null,所以此时第一个判断为true,返回true说明现在这个线程需要入队
// 3-2: 第三种情况中的第二种情况:当然一般情况下,已经处在AQS队列中的第一位的线程节点并不会立刻获取到锁,所以这时
// s=h.next s!=null s拿到的是正处在AQS队列中的线程对应的node节点,所以前者为false,由于是或操作进行后续的判断
// 拿到s对应的线程与当前先线程对比,肯定是不等,所以这里返回true,整体返回true代表需要入队,含义:当前的队列中
//第一个人就在窗口那巴巴的等着呢,后面来的该排队排队去
// 可以说后面的两个或操作就是专门为由于多线程的不确定性而存在的从而保证这把锁的公平性,当然要谨记后面的两个判断的前提
//是h!=t是成功的,一定要谨记!!
// 这三种情况(也可以说四种情况,仅仅通过这样三个判断就可以保证公平锁的一个公平性和安全性,不得不佩服Doug Lea的
// coding能力和思想,也是因为相通了这块所存在的情况激发了写本篇博客的冲动!!!!)
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
//这个方法的主要作用是将当前线程作为节点添加到队列中(但并未调用park方法)
private Node addWaiter(Node mode) {
// 1-1 第一次线程进入到这里需要根据当前线程创建一个队列的节点,由于是第一次有线程需要入队,所以tail节点为null
// 直接调用enq方法进行队列的初始化————> enq(node)
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
if (pred != null) {
// 如果此时已经存在AQS队列,tail指针不为null所以pred不为null此时直接进行入队操作
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
// 1-2:初始化完毕后,开始执行acquireQueued()
return node;
}
// 该方法的作用是创建队列
private Node enq(final Node node) {
// 这里有一个死循环代表着自旋操作
for (;;) {
// 第一次tail肯定为null,所以直接进入第一个判断
Node t = tail;
if (t == null) {
// 通过CAS操作将AQS中的head(队列头指针)指向一个新的Node节点,并将tail(队列尾节点)指向这个新节点,
// 此时头尾指针都指向了同一个节点,这一步执行完毕由于自旋进入到else语句块
if (compareAndSetHead(new Node()))
tail = head;
} else {
// 将依据当前线程创建出的node节点的prev指针指向第一次循环创建的new Node(),并通过CAS操作将tail指向
// 当前线程的node节点,同时new Node()的next指针指向当前线程node节点。这样该线程node节点成功入队。
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
// 这里存在返回值是为了终止自旋操作,返回值没有具体作用
return t;
}
}
}
}
该图展示自旋+CAS操作初始化AQS队列的流程,假设当前入队的线程名为t1。最后初始化完AQS队列后会形成一个虚拟节点(thread=null)
3.下面这个方法首先判断是否需要park,若在这个期间尝试获取锁时失败则会调用park方法,这个方法执行完毕之后此时我们需要回到hasQuereuedPredecessors方法中去剖析那里return的三个判断的精妙之处!!!
final boolean acquireQueued(final Node node, int arg) {
// 首先声明一个布尔字段,后续有作用
boolean failed = true;
try {
// 这个布尔变量是为了在调用lockinterruptibly方法响应中断而存在
boolean interrupted = false;
// 自旋操作
for (;;) {
// 首先获取到当前线程节点的上一个节点 p
final Node p = node.predecessor();
// 如果p是head指针指向的节点,说明当前这个线程是在这个队伍中的第一个(这个时候需要解释一下排队,在生活当中
// 我们如果看到在一个业务窗口前有一个人,那么这个人其实不属于在排队,可以理解为他正要办理业务或者是业务人员
// 正在处理还未处理完的业务。如果说此时队列中已经有一个人了,但是又来了一个人站到第一个人后面,则这个人处在一
//个排队的状态) 此时如果当前线程的上一个节点是head指针指向的虚拟节点(thread=null)那么对于当前已经入队的线
//程来讲:这把锁可能被占用也可能这把锁被释放了,所以会再次进行tryAcquire操作,再去执行一次抢锁的操作。
// 当然若p不是head节点说明当前这个线程的node节点是真正意义上的需要排队直接进入到下一个if判断
if (p == head && tryAcquire(arg)) {
// 如果抢锁成功,这里需要图解说明一下队列去除node节点的操作,跳转sethead()方法代码解析处
setHead(node);
// 执行完setHead后,p 的next置为null,这样之前的head指向的节点没有任何引用,方便垃圾收集器进行回收
p.next = null; // help GC
// 该字段置为false
failed = false;
// 在普通的lock方法中这个interrupted并未有什么作用,这里的返回值也并没有什么意义,
// 因为线程已经抢锁成功,这里return是为打断自旋
return interrupted;
}
// 如果第一次tryAcquire并未抢锁成功代表着当前锁还在被占用,所以此时进入到shouldParkAfterFailedAcquire
if (shouldParkAfterFailedAcquire(p, node) &&
// 若第一个判断返回true则进入parkAndCheckInterrupt方法内对当前线程直接调用park方法进行阻塞,
//若第一个判断返回flase则进行自旋
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
// 该方法是在队列中的线程被unpark后需要将对应的node节点从AQS队列移除的操作,在这里不进行详细的阐述
cancelAcquire(node);
}
}
private void setHead(Node node) {
// 将头指针指向我们在AQS队列中并成功抢到锁的线程的node节点
head = node;
// 并且将这个节点的thread置为null,prev指针置为null 回到acquireQueued方法中
node.thread = null;
node.prev = null;
}
此图为当前线程处在AQS队列中时判断是否需要park之前进行尝试抢锁并抢锁成功后,从队列中移除该线程对应的node节点的示意图,将
成功抢锁的线程节点作为新的虚拟节点,上一个虚拟节点所有的指针都置null等待GC
// 该方法主要是借助acquireQueued中的自旋再次给予当前线程抢锁的机会
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 首先获取到上一个节点的状态,含义是:我先去判断上一个线程的node节点是不是处在一个等待的状态,如果上一个都是处在
//等待状态那我当前这个线程肯定直接调用park不再尝试拿锁,而如果上一个节点是虚拟节点或者是虚拟节点的下一个节点此时此刻
//当前线程的前一个节点(即刚刚所说的这两种情况)并不是在排队,所以他的状态此时为0
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
/*
* This node has already set status asking a release
* to signal it, so it can safely park.
*/
return true;
if (ws > 0) {
/*
* Predecessor was cancelled. Skip over predecessors and
* indicate retry.
*/
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
// 如果为0则会跳过上述的判断,直接进入到当前的CAS操作中,将上一个节点的状态置为SIGNAL(-1),并返回fasle
// 返回false之后由于acquireQueued方法是自旋操作,此时会自旋一次再次进行判断,若p == head再次进行抢锁,
// 当抢锁失败再次进入shouldParkAfterFailedAcquire后,第一个判断会通过返回true代表着需要被park,此时回到
//acquireQueued方法中的if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
整个上述所有方法的介绍都是只围绕着ReentrantLock公平锁的lock方法的上锁流程中的一些有关情况,并未介绍一些方法复用所涉及到
的一些字段和方法,,当然这些字段和方法并不会影响理解此次的主题,欢迎各位阅读到该篇文章的伙伴们查究指正!!!由于是本人
第一次写博客,内容和逻辑的表达多少会有所欠缺,也希望志同道合的伙伴可以给出中肯的建议,愿我们在技术的道路上越走越远!!