总结
在清楚了各个大厂的面试重点之后,就能很好的提高你刷题以及面试准备的效率,接下来小编也为大家准备了最新的互联网大厂资料。
注意了,这里的头节点中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
(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
}
}
这个代码逻辑上是有点绕的,所以还是要结合图形来会比较好理解,而且这里有两种情况,一种就是当前队列中没有无效节点被清除,一种是有无效节点被清除,我们假设当前有如下两个队列:
上图中的AQS同步队列中假设没有无效节点需要被清除,这种场景的5和6行是可以忽略的,这时候第7行的predNext其实就是当前节点自己。 假如这时候就是ThreadD进来,而ThreadC是无效节点,那么第5行和第6行就会执行了,这时候predNext就是ThreadC所在的节点了,而不是ThreadD本身了,所以predNext在这种场景的时候就不会是自己了。 然后下面分了三种情况来进行移除节点(为了便于理解,下图中没有将状态改为-3页也没有将thread设置为null显示出来):
- 当前节点为tail节点(即ThreadD) 这种情况可以直接移除,所以第9行通过一个CAS直接把tail节点替换成当前节点的prev节点,得到如下AQS:
紧接着第10行,就是把前一个节点的下一个节点设置为空,也就是把ThreadC的next设置为空:
这样其实就相当于把ThreadD移除了,这里个人认为可以加上node.prev=null帮助GC。
- 当前节点不是tail节点,且不是head节点的下一个节点 假如当前节点是ThreadC,这里的if中的13-16行的判断都是为了确定前一个节点状态是-1且thread不为null,如果后一个节点也是有效的,那么就通过CAS将ThreadB的next节点设置为ThreadD:
这里到这一步其实就可以了,因为每次唤醒的时候都会执行无效节点的清除,而且唤醒是根据next往后移动的,这里根据next找不到ThreadC节点了。 然后22行就是把当前节点的下一个节点设置为自己:
最后
如果觉得本文对你有帮助的话,不妨给我点个赞,关注一下吧!
的话,不妨给我点个赞,关注一下吧!**
[外链图片转存中…(img-ZzgZI1zl-1715671002836)]
[外链图片转存中…(img-oBG9qUQx-1715671002837)]