最新并发编程:JUC必知ReentrantLock和AQS同步队列实现原理分析,整理了3家面试问题:美团+字节+腾讯

总结

在清楚了各个大厂的面试重点之后,就能很好的提高你刷题以及面试准备的效率,接下来小编也为大家准备了最新的互联网大厂资料。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

需要这份系统化的资料的朋友,可以点击这里获取

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

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

image

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

image

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

AQS#acquireQueued(Node,arg)

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

image

有几个属性这里可以先不管,关注for循环里面逻辑,首先获取到前一个节点,如果前一个节点是head节点,那就再调用tryAcquire(arg)方法去抢一次锁。 我们这里假设争抢锁还是失败了,这时候就会走到882行的if判断,if判断中第一个逻辑看名字shouldParkAfterFailedAcquire能猜到大致意思,就是争抢锁失败后看一下当前线程是不是应该挂起,我们进入shouldParkAfterFailedAcquire方法看看:

image

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

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

image

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

image

3、这时候while循环的条件肯定不成立,因为此时的pred已经指向了头节点,状态为-1, 所以循环结束,继续执行815行代码,得到如下AQS:

image

最终的结果我们可以看到,虽然ThreadB还有指向其他线程,但是我们通过其他任何节点,都没办法找到ThreadB,已经重新构建了一个关联关系,相当于ThreadB被移出了队列。 因为head节点是一个哨兵,不可能会被取消,所以这里的while循环是不需要担心pred会变为null的。

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

image

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

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

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

image

为什么要使用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”);
}
}

输出结果:

image

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

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

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

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

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

lock.unlock()源码解读

ReentrantLock#unlock()

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

image

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

image

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

ReentrantLock#tryRelease()

image

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

image

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

image

这个方法就没什么好说的,比较简单了,我们直接进入到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队列的步骤还有没有印象,为了不让大家翻上去找代码,我把代码重新贴下来:

image

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

image

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

image

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

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

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

image

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

回到AQS#acquireQueued(Node,arg)

image

也就是说会回到上面代码中的882行的if判断,不管interrupted是等于true(想成挂起期间被中断过)还是等于false,都不会跳出当前的for循环,那么就继续循环。 因为被唤醒的线程是ThreadB,所以这时候if判断成立,而且因为此时state=0,处于无锁状态,tryAcquire(arg)获取锁也会成功,这时候AQS又变成了有锁状态,只不过独占线程由A变成了B:

image

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

image

我们还是来演示一下这三行代码: 1、head=node,于是得到如下AQS队列:

image

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

image

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

image

经过这三步,我们看到,原先的头节点已经没有任何关联关系了,其实在第二步的时候,原先头节点已经不在队列中了,执行第三步只是为了消除其持有的引用,方便被垃圾回收。 到这里,最终会执行return interrupted;跳出循环,继续回到前一个方法。

回到AQS#acquire(arg)

image

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

image

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

到这里,整个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
    (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&//15
    pred.thread != null) {//16
    Node next = node.next;//17
    if (next != null && next.waitStatus <= 0)//18
    compareAndSetNext(pred, predNext, next);//19
    } else {//20-当前节点的前置节点是head节点,那就直接把下一个节点唤醒
    unparkSuccessor(node);//21-//这里面会去除状态为cancel的节点,而此时状态已经为-1了
    }
    node.next = node; //22-help GC
    }
    }

这个代码逻辑上是有点绕的,所以还是要结合图形来会比较好理解,而且这里有两种情况,一种就是当前队列中没有无效节点被清除,一种是有无效节点被清除,我们假设当前有如下两个队列:

image

上图中的AQS同步队列中假设没有无效节点需要被清除,这种场景的5和6行是可以忽略的,这时候第7行的predNext其实就是当前节点自己。 假如这时候就是ThreadD进来,而ThreadC是无效节点,那么第5行和第6行就会执行了,这时候predNext就是ThreadC所在的节点了,而不是ThreadD本身了,所以predNext在这种场景的时候就不会是自己了。 然后下面分了三种情况来进行移除节点(为了便于理解,下图中没有将状态改为-3页也没有将thread设置为null显示出来):

  • 当前节点为tail节点(即ThreadD) 这种情况可以直接移除,所以第9行通过一个CAS直接把tail节点替换成当前节点的prev节点,得到如下AQS:

image

紧接着第10行,就是把前一个节点的下一个节点设置为空,也就是把ThreadC的next设置为空:

image

这样其实就相当于把ThreadD移除了,这里个人认为可以加上node.prev=null帮助GC。

  • 当前节点不是tail节点,且不是head节点的下一个节点 假如当前节点是ThreadC,这里的if中的13-16行的判断都是为了确定前一个节点状态是-1且thread不为null,如果后一个节点也是有效的,那么就通过CAS将ThreadB的next节点设置为ThreadD:

image

这里到这一步其实就可以了,因为每次唤醒的时候都会执行无效节点的清除,而且唤醒是根据next往后移动的,这里根据next找不到ThreadC节点了。 然后22行就是把当前节点的下一个节点设置为自己:

最后

如果觉得本文对你有帮助的话,不妨给我点个赞,关注一下吧!

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

需要这份系统化的资料的朋友,可以点击这里获取

的话,不妨给我点个赞,关注一下吧!**

[外链图片转存中…(img-ZzgZI1zl-1715671002836)]

[外链图片转存中…(img-oBG9qUQx-1715671002837)]

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

需要这份系统化的资料的朋友,可以点击这里获取

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值