【并发编程系列5(1),Java开发进阶吃透这一篇必拿60W年薪


在这里插入图片描述

这个方法就是为了尝试去获得锁,但是这里有一个问题,因为这个方法之所以会被执行,就是因为前面的CAS操作失败了,也就是获得锁失败了,state就肯定不会为0了,可是这个方法的131行为什么还要再次判断state是否等于0呢?

我们想一想,如果一个线程争抢锁失败,我们应该怎么做,无外乎两个办法:一个就是多试几次,之前介绍synchronized的时候曾经说过,大部分锁被持有之后都会很快被释放,所以再试试总没有错,万一刚好锁被释放了呢。另一个办法就是阻塞等待,这个后面会介绍,所以这里的131行代码判断也是这个逻辑,就是再试一次,如果成功,就可以直接获得锁,而不需要加入AQS队列挂起线程了。

线程A没有释放锁的时候,线程B会抢占锁失败,则返回false,我们回到之前的逻辑,会继续执行acquireQueued方法,这个方法里面有一个参数是addWaiter返回的,所以我们先去看addWaiter这个方法

AQS#addWaiter(Node)


走到这里就是说明当前线程至少2次尝试获取锁都失败了,所以当前线程会被初始化成为一个Node节点,加入到AQS队列中。我们前面提到了,AQS有两种模式,一种独占,一种共享,而重入锁ReentrantLock是独占的,所以这里固定传入了参数Node.EXCLUSIVE表示当前锁是独占的。而由前面Node对象的源码可以知道,Node.EXCLUSIVE其实是一个null值的Node对象。

在这里插入图片描述

因为我们到这里的时候是第一次进来,AQS队列还没有被初始化,head和tail都是为空的,所以if判断肯定不成立,也就是说,如果是第一次调用addWaiter方法时,会先执行下面的enq(node)方法。

AQS#enq()


在这里插入图片描述

先不要看else逻辑,线程B第一次进来肯定是走的if逻辑,初始化之后,得到这样的一个AQS:

图一

头节点为什么放空线程

注意了,这里的头节点中thread没有赋值(thread=null),其实这里的第一个节点只是起了一个哨兵的作用,这样就可以免去了后续在查找过程中每次比较是否越界的操作,后面会陆续提到这个哨兵的作用。

回到源码逻辑来,因为上面是一个死循环,初始化之后,紧接着会立刻进行第二次for循环,第二次循环的时候tail节点不为空了,所以会走else逻辑,走完else逻辑之后会得到下面这样一个AQS:

在这里插入图片描述

这时候假如又来了线程C,那么线程C就会走到AQS#addWaiter(Node)方法中上面的if逻辑了,因为这时候tail节点已经不为空了,这里的if逻辑其实和enq(Node)方法中for循环中的else分支逻辑是一样的,只是把线程C添加到AQS的尾部,最终会得到下面这个AQS:

在这里插入图片描述

接下来我们回到前面的方法,继续执行AQS中的acquireQueued(Node,arg)方法。

AQS#acquireQueued(Node,arg)


上面经过addWaiter(Node)之后,阻塞的线程已经被加入到了AQS队列当中,但是注意,这时候仅仅只是把线程加入进去了,而线程并没有被挂起,也就是说,线程还是处于运行状态,那么接下来要做的事就是需要把加入AQS队列中的线程挂起,当然在挂起之前,还是我们前面说的,就是线程还是不死心,所以还需要最后搏一搏,万一抢到锁了,就不需要挂起了,所以这就是acquireQueued(Node,arg)方法中会做的两件事:

1、看看前一个节点是不是头节点,如果是的话,就再试一次

2、再试一次如果还是失败了,那么线程正式挂起

在这里插入图片描述

有几个属性这里可以先不管,关注for循环里面逻辑,首先获取到前一个节点,如果前一个节点是head节点,那就再调用tryAcquire(arg)方法去抢一次锁。

我们这里假设争抢锁还是失败了,这时候就会走到882行的if判断,if判断中第一个逻辑看名字shouldParkAfterFailedAcquire能猜到大致意思,就是争抢锁失败后看一下当前线程是不是应该挂起,我们进入shouldParkAfterFailedAcquire方法看看:

在这里插入图片描述

上面这段代码值得说的就是811-815行,我们先来演示下这个流程,因为移除cancel状态节点后面逻辑中还会出现。

1、假设ThreadB被取消了,那么这时候AQS中ThreadB节点状态为-:

在这里插入图片描述

2、执行813行代码,相当于:prev=prev.prev;node.prev=prev;得到如下AQS:

在这里插入图片描述

3、这时候while循环的条件肯定不成立,因为此时的pred已经指向了头节点,状态为-1,

所以循环结束,继续执行815行代码,得到如下AQS:

在这里插入图片描述

最终的结果我们可以看到,虽然ThreadB还有指向其他线程,但是我们通过其他任何节点,都没办法找到ThreadB,已经重新构建了一个关联关系,相当于ThreadB被移出了队列。

因为head节点是一个哨兵,不可能会被取消,所以这里的while循环是不需要担心pred会变为null的。

暂时忘掉上面移除cancel节点的流程,我们假设是线程B进来,那么前一个节点就是head节点,肯定会走到最后一个else,这也是一个CAS操作,把头节点状态改为-1,如果是线程C进来,就会把B节点设置为-1,这时候就会得到下面这样一个AQS:

在这里插入图片描述

这个AQS队列和上面的唯一区别就是前面两个节点的waitStatus状态从0改成了-1。

这里注意了,只有前一个节点waitStatus=-1才会返回true,所以这里第一次循环进来肯定返回false,也就是还会再一次进行循环,循环的时候还会再次执行上面的争抢锁方法(看起来真的是贼心不死哈)。判断失败后,就会二次进入shouldParkAfterFailedAcquire方法,这时候因为第一次循环已经把前一个节点状态改为-1了,所以就会返回true了。

返回true之后,就会执行if判断的第二个逻辑了,这里面才是真的把线程正式挂起来。要挂起一个线程着实有点不容易哈哈。调用parkAndCheckInterrupt()方法正式挂起:

在这里插入图片描述

为什么要使用interrupted()返回中断标记


要解释这个原因我们需要先解释下park()方法:

LockSupport.park()方法是中断一个线程,但是遇上下面三种情况,就会立即返回:

  • 其他线程对当前线程发起了unpark()操作时

  • 其他线程中断了当前线程时

  • 不合逻辑的调用(也就是没有理由)时

第三点没想明白场景,有知道的欢迎留言,感谢!

这里我们要说的是第2点,其他线程中断了当前线程会有什么影响,我们先来演示一个例子再来得出结论:

当park()遇上了interrupt()

前面讲线程基本知识的时候,我们讲到了sleep()遇到了interrupt()会怎么样,感兴趣的可以点击这里详细了解。

这里我们来看个例子:

package com.zwx.concurrent.lock;

import java.util.concurrent.locks.LockSupport;

public class LockParkInterrputDemo {

public static void main(String[] args) throws InterruptedException {

Thread t1 = new Thread(()->{

int i = 0;

while (true){

if(i == 0){

LockSupport.park();

//获取中断标记,但是不复位

System.out.println(Thread.currentThread().isInterrupted());

LockSupport.park();

LockSupport.park();

System.out.println(“如果走到这里就说明park不生效了”);

}

i++;

if(i == Integer.MAX_VALUE){

break;

}

}

});

t1.start();

Thread.sleep(1000);//确保t1被park()之后再中断

t1.interrupt();

System.out.println(“end”);

}

}

输出结果:

在这里插入图片描述

所以其实park()方法至少有以下两个个特点:

  • 当一个线程park()时收到中断信号,会立刻恢复,且中断标记为true,而且不会抛出InterruptedException

  • 当一个线程中断标记为true时候,park()对其无效

有这两个结论,上面就很好理解了,我们想一想,假设上面的线程挂起之后,并不是被线程A释放锁之后调用unpark()唤醒的,而是被其他线程中断了,那么就会立刻恢复继续后面的操作,这时候如果不对线程进行复位,那么他会回到前面的死循环,park()也无效了,就会一直死循环抢占锁,会一直占用CPU资源,如果线程多了可能直接把CPU耗尽。

分析到这里,线程被挂起,告一段落。挂起之后需要等待线程A释放锁之后唤醒再继续执行。所以接下来我们看看unlock()是如何释放锁以及唤醒后续线程的。

lock.unlock()源码解读

==============================================================================

ReentrantLock#unlock()


上文的示例中,当我们调用lock.unlock()时,我们进入Lock接口的实现类ReentrantLock中的释放锁入口:

在这里插入图片描述

这里和上文的加锁不一样,加锁会区分公平锁和非公平锁,这里直接就是调用了sync父类AQS中的release(arg)方法:

在这里插入图片描述

我们可以看到,这里首先会调用tryRelease(arg)方法,最终会回到ReentrantLock类中的tryRelease(arg)方法:

ReentrantLock#tryRelease()


在这里插入图片描述

这个方法看起来就比较简单了,释放一次就把state-1,所以我们的lock()和unlock()是需要配对的,否则无法完全释放锁,这里因为我们没有重入,所以c=0,那么这时候的AQS队列就变成了这样:

在这里插入图片描述

当前方法返回true,那么就会继续执行上面AQS#release(arg)方法中if里面的逻辑了:

在这里插入图片描述

这个方法就没什么好说的,比较简单了,我们直接进入到unparkSuccessor(h)方法中一窥究竟。

AQS#unparkSuccessor(Node)


private void unparkSuccessor(Node node) {

/*

  • If status is negative (i.e., possibly needing signal) try

  • to clear in anticipation of signalling. It is OK if this

  • fails or if status is changed by waiting thread.

  • 如果状态是负的,尝试去清除这个信号,当然,如果清除失败或者说被其他

  • 等待获取锁的线程修改了,也没关系。

  • 这里为什么要去把状态修改为0呢?其实这个线程是要被唤醒的,修不修改都无所谓。

  • 回忆一下上面的acquireQueued方法中调用了shouldParkAfterFailedAcquire

  • 去把前一个节点状态改为-1,而在改之前会抢占一次锁,所以说这里的操作

  • 其实并没有太大用处,可能可以为争抢锁的线程再多一次抢锁机会,故而成功失败均不影响

*/

int ws = node.waitStatus;

if (ws < 0)

compareAndSetWaitStatus(node, ws, 0);

/*

  • Thread to unpark is held in successor, which is normally

  • just the next node. But if cancelled or apparently null,

  • traverse backwards from tail to find the actual

  • non-cancelled successor.

  • 唤醒后继节点,通常是next节点,但是如果next节点被取消了或者为空,那么

  • 就需要从尾部开始遍历,将无效节点先剔除

*/

Node s = node.next;

if (s == null || s.waitStatus > 0) {//如果下一个节点为空或者被取消了

s = null;

for (Node t = tail; t != null && t != node; t = t.prev)

if (t.waitStatus <= 0)//一直遍历,直到找到状态小于等于0的有效节点

s = t;

}

if (s != null)

LockSupport.unpark(s.thread);

}

这段代码中值得说明的是为什么要从tail节点开始循环遍历。不知道大家对enq()方法中的构造AQS队列的步骤还有没有印象,为了不让大家翻上去找代码,我把代码重新贴下来:

在这里插入图片描述

我们看到,不管是if分支还是else分支,cas操作成功之后都只是把tail节点的关系构造出来了,第一个if分支CAS操作后得到下面这样的情况:

在这里插入图片描述

执行else分支的CAS操作之后,可能得到下面这样的情况:

在这里插入图片描述

我们可以发现,上面两种情况next节点都还没来得及构造,那么假如这时候从前面还是遍历就会出现找不到节点的情况,但是从tail往前就不会有这个问题。

看到这里忍不住感叹下,大佬的思维真是达到了一定的高度,写的代码完全都是精华。

到这里释放锁完成,下一个线程(ThreadB)也被唤醒了,那么下一个线程被唤醒后在哪里呢?还是把上面线程最终挂起的代码贴出来:

在这里插入图片描述

也就是说线程被唤醒后,会继续执行return语句,返回中断标记。然后会回到AQS类中的

acquireQueued(Node,arg)方法

回到AQS#acquireQueued(Node,arg)


在这里插入图片描述

也就是说会回到上面代码中的882行的if判断,不管interrupted是等于true(想成挂起期间被中断过)还是等于false,都不会跳出当前的for循环,那么就继续循环。

因为被唤醒的线程是ThreadB,所以这时候if判断成立,而且因为此时state=0,处于无锁状态,tryAcquire(arg)获取锁也会成功,这时候AQS又变成了有锁状态,只不过独占线程由A变成了B:

在这里插入图片描述

这时候线程B获取锁成功了,所以必然要从AQS队列中移除,我们进入setHead(node)方法:

在这里插入图片描述

我们还是来演示一下这三行代码:

1、head=node,于是得到如下AQS队列:

在这里插入图片描述

2、node.Thread=null;node.prev=null;得到如下AQS队列:

在这里插入图片描述

3、回到前一个方法,执行setHead(Node)下一行代码,p.next = null,得到如下最新的AQS:

在这里插入图片描述

经过这三步,我们看到,原先的头节点已经没有任何关联关系了,其实在第二步的时候,原先头节点已经不在队列中了,执行第三步只是为了消除其持有的引用,方便被垃圾回收。

到这里,最终会执行return interrupted;跳出循环,继续回到前一个方法。

回到AQS#acquire(arg)


在这里插入图片描述

这时候假如前面的interrupted返回true的话会执行selfInterrupt()方法:

在这里插入图片描述

这里自己中断自己的原因就是上面介绍过的,上面捕获到线程中断之后只是记录下了中断状态,然后对线程进行了复位,所以这时候这里需要再次中断自己,对外界做出响应。

到这里,整个lock()和unlock()分析就结束了,但是上面acquireQueued方法我们这里需要再进去看一下,里面的finally中有一个cancelAcquire(Node)方法。

AQS#cancelAcquire(Node)


private void cancelAcquire(Node node) {

if (node == null)//1

return;//2

node.thread = null;//3-将当前节点的线程设置为null

// Skip cancelled predecessors 跳过已经被取消的前置节点

Node pred = node.prev;//4

while (pred.waitStatus > 0)//5

node.prev = pred = pred.prev;//6

//predNext是很明显需要解除关系的,如果不解除下面的cas操作将会失败

Node predNext = pred.next;//7-如果上一个节点没有不合法的,那么这个就是自己,否则就是当前节点前面的某一个节点

node.waitStatus = Node.CANCELLED;//8

//1.如果当前线程是tail节点,直接移除掉,并且把上一个节点设置为tail节点

if (node == tail && compareAndSetTail(node, pred)) {//9

compareAndSetNext(pred, predNext, null);//10-这里要和上面Node predNext = pred.next结合起来理解

} else {//11

/**

  • 如果下一个节点需要唤醒信号(即需要状态设置为-1),尝试把上一个节点的next节点设置

  • 为当前节点的下一个节点,这样他就可以得到一个唤醒的信号,如果设置信号失败,那就直接唤醒

  • 当前节点的下一个节点,并以此往后传递

*/

int ws;//12

//2.如果当前线程前置节点是head节点,且状态为-1(不为-1但是设置为-1成功)

if (pred != head &&//13

((ws = pred.waitStatus) == Node.SIGNAL ||//14

小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Java工程师,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年最新Java开发全套学习资料》送给大家,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
img
img
img

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频

如果你觉得这些内容对你有帮助,可以添加下面V无偿领取!(备注Java)
img

s = pred.waitStatus) == Node.SIGNAL ||//14

小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Java工程师,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年最新Java开发全套学习资料》送给大家,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
[外链图片转存中…(img-8Z23E0iH-1710851498946)]
[外链图片转存中…(img-dy9rtgq1-1710851498947)]
[外链图片转存中…(img-naI3F0Wp-1710851498947)]

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频

如果你觉得这些内容对你有帮助,可以添加下面V无偿领取!(备注Java)
[外链图片转存中…(img-vgRa8R81-1710851498948)]

  • 15
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值