写点什么

-So-easy! 多图详解 CLH 锁的原理与实现,轻松把握 AQS,nginx 原理及应用

  • 2021 年 11 月 12 日
  • 本文字数:5003 字

    阅读完需:约 16 分钟

predNode = new ThreadLocal();}


/**


  • 获取锁*/public void lock() {// 取出当前线程 ThreadLocal 存储的当前节点,初始化值总是一个新建的 CLHNode,locked 状态为 false。CLHNode currNode = curNode.get();// 此时把 lock 状态置为 true,表示一个有效状态,// 即获取到了锁或正在等待锁的状态 currNode.locked = true;// 当一个线程到来时,总是将尾结点取出来赋值给当前线程的前继节点;// 然后再把当前线程的当前节点赋值给尾节点// 【注意】在多线程并发情况下,这里通过 AtomicReference 类能防止并发问题// 【注意】哪个线程先执行到这里就会先执行 predNode.set(preNode);语句,因此构建了一条逻辑线程等待链// 这条链避免了线程饥饿现象发生 CLHNode preNode = tailNode.getAndSet(currNode);// 将刚获取的尾结点(前一线程的当前节点)付给当前线程的前继节点 ThreadLocal// 【思考】这句代码也可以去掉吗,如果去掉有影响吗?predNode.set(preNode);// 【1】若前继节点的 locked 状态为 false,则表示获取到了锁,不用自旋等待;// 【2】若前继节点的 locked 状态为 true,则表示前一线程获取到了锁或者正在等待,自旋等待 while (preNode.locked) {System.out.println("线程" + Thread.currentThread().getName() + "没能获取到锁,进行自旋等待。。。");}// 能执行到这里,说明当前线程获取到了锁 System.out.println("线程" + Thread.currentThread().getName() + "获取到了锁!!!");}


/**


  • 释放锁*/public void unLock() {// 获取当前线程的当前节点 CLHNode node = curNode.get();// 进行解锁操作// 这里将 locked 至为 false,此时执行了 lock 方法正在自旋等待的后继节点将会获取到锁// 【注意】而不是所有正在自旋等待的线程去并发竞争锁 node.locked = false;System.out.println("线程" + Thread.currentThread().getName() + "释放了锁!!!");// 小伙伴们可以思考下,下面两句代码的作用是什么??CLHNode newCurNode = new CLHNode();curNode.set(newCurNode);


// 【优化】能提高 GC 效率和节省内存空间,请思考:这是为什么?// curNode.set(predNode.get());}}

4.1 CLH 锁的初始化逻辑

通过上面代码,我们缕一缕 CLH 锁的初始化逻辑先:


  1. 定义了一个CLHNode节点,里面有一个locked属性,表示线程线程是否获得锁,默认为falsefalse表示线程没有获取到锁或已经释放锁;true表示线程获取到了锁或者正在自旋等待。


注意,为了保证locked属性线程间可见,该属性被volatile修饰。


  1. CLHLock有三个重要的成员变量尾节点指针tailNode,当前线程的前继节点preNode和当前节点curNode。其中tailNodeAtomicReference类型,目的是为了保证尾节点的线程安全性;此外,preNodecurNode都是ThreadLocal类型即线程本地变量类型,用来保存每个线程的前继CLHNode和当前CLHNode节点。

  2. 最重要的是我们新建一把CLHLock对象时,此时会执行构造函数里面的初始化逻辑。此时给尾指针tailNode和当前节点curNode初始化一个locked状态为falseCLHNode节点,此时前继节点preNode存储的是null

4.2 CLH 锁的加锁过程

我们再来看看 CLH 锁的加锁过程,下面再贴一遍加锁lock方法的代码:


// CLHLock.java


/**


  • 获取锁*/public void lock() {// 取出当前线程 ThreadLocal 存储的当前节点,初始化值总是一个新建的 CLHNode,locked 状态为 false。CLHNode currNode = curNode.get();// 此时把 lock 状态置为 true,表示一个有效状态,// 即获取到了锁或正在等待锁的状态 currNode.locked = true;// 当一个线程到来时,总是将尾结点取出来赋值给当前线程的前继节点;// 然后再把当前线程的当前节点赋值给尾节点// 【注意】在多线程并发情况下,这里通过 AtomicReference 类能防止并发问题// 【注意】哪个线程先执行到这里就会先执行 predNode.set(preNode);语句,因此构建了一条逻辑线程等待链// 这条链避免了线程饥饿现象发生 CLHNode preNode = tailNode.getAndSet(currNode);// 将刚获取的尾结点(前一线程的当前节点)付给当前线程的前继节点 ThreadLocal// 【思考】这句代码也可以去掉吗,如果去掉有影响吗?predNode.set(preNode);// 【1】若前继节点的 locked 状态为 false,则表示获取到了锁,不用自旋等待;// 【2】若前继节点的 locked 状态为 true,则表示前一线程获取到了锁或者正在等待,自旋等待 while (preNode.locked) {try {Thread.sleep(1000);} catch (Exception e) {


}System.out.println("线程" + Thread.curr


【一线大厂Java面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义】
浏览器打开:qq.cn.hn/FTf 免费领取
复制代码


entThread().getName() + "没能获取到锁,进行自旋等待。。。");}// 能执行到这里,说明当前线程获取到了锁 System.out.println("线程" + Thread.currentThread().getName() + "获取到了锁!!!");}


虽然代码的注释已经很详细,我们还是缕一缕线程加锁的过程:


  1. 首先获得当前线程的当前节点curNode,这里每次获取的CLHNode节点的locked状态都为false

  2. 然后将当前CLHNode节点的locked状态赋值为true,表示当前线程的一种有效状态,即获取到了锁或正在等待锁的状态;

  3. 因为尾指针tailNode的总是指向了前一个线程的CLHNode节点,因此这里利用尾指针tailNode取出前一个线程的CLHNode节点,然后赋值给当前线程的前继节点predNode,并且将尾指针重新指向最后一个节点即当前线程的当前CLHNode节点,以便下一个线程到来时使用;

  4. 根据前继节点(前一个线程)的locked状态判断,若lockedfalse,则说明前一个线程释放了锁,当前线程即可获得锁,不用自旋等待;若前继节点的 locked 状态为 true,则表示前一线程获取到了锁或者正在等待,自旋等待。


为了更通俗易懂,我们用一个图里来说明。


**假如有这么一个场景:**有四个并发线程同时启动执行 lock 操作,假如四个线程的实际执行顺序为:threadA<--threadB<--threadC<--threadD


第一步,线程 A 过来,执行了 lock 操作,获得了锁,此时locked状态为true,如下图:



第二步,线程 B 过来,执行了 lock 操作,由于线程 A 还未释放锁,此时自旋等待,locked状态也为true,如下图:



第三步,线程 C 过来,执行了 lock 操作,由于线程 B 处于自旋等待,此时线程 C 也自旋等待(因此 CLH 锁是公平锁),locked状态也为true,如下图:



第四步,线程 D 过来,执行了 lock 操作,由于线程 C 处于自旋等待,此时线程 D 也自旋等待,locked状态也为true,如下图:



这就是多个线程并发加锁的一个过程图解,当前线程只要判断前一线程的locked状态如果是true,那么则说明前一线程要么拿到了锁,要么也处于自旋等待状态,所以自己也要自旋等待。而尾指针tailNode总是指向最后一个线程的CLHNode节点。

4.3 CLH 锁的释放锁过程

前面用图解结合代码说明了 CLH 锁的加锁过程,那么,CLH 锁的释放锁的过程又是怎样的呢? 同样,我们先贴下释放锁的代码:


// CLHLock.java


/**


  • 释放锁*/public void unLock() {// 获取当前线程的当前节点 CLHNode node = curNode.get();// 进行解锁操作// 这里将 locked 至为 false,此时执行了 lock 方法正在自旋等待的后继节点将会获取到锁// 【注意】而不是所有正在自旋等待的线程去并发竞争锁 node.locked = false;System.out.println("线程" + Thread.currentThread().getName() + "释放了锁!!!");// 小伙伴们可以思考下,下面两句代码的作用是什么???CLHNode newCurNode = new CLHNode();curNode.set(newCurNode);


// 【优化】能提高 GC 效率和节省内存空间,请思考:这是为什么?// curNode.set(predNode.get());}


可以看到释放 CLH 锁的过程代码比加锁简单多了,下面同样缕一缕:


  1. 首先从当前线程的线程本地变量中获取出当前CLHNode节点,同时这个CLHNode节点被后面一个线程的preNode变量指向着;

  2. 然后将locked状态置为false即释放了锁;


注意:locked因为被volitile关键字修饰,此时后面自旋等待的线程的局部变量preNode.locked也为false,因此后面自旋等待的线程结束while循环即结束自旋等待,此时也获取到了锁。这一步骤也在异步进行着。


  1. 然后给当前线程的表示当前节点的线程本地变量重新赋值为一个新的CLHNode


思考:这一步看上去是多余的,其实并不是。请思考下为什么这么做?我们后续会继续深入讲解。


我们还是用一个图来说说明 CLH 锁释放锁的场景,接着前面四个线程加锁的场景,假如这四个线程加锁后,线程 A 开始释放锁,此时线程 B 获取到锁,结束自旋等待,然后线程 C 和线程 D 仍然自旋等待,如下图:



以此类推,线程 B 释放锁的过程也跟上图类似,这里不再赘述。

4.4 考虑同个线程加锁释放锁再次正常获取锁的情况

在前面 4.3 小节讲到释放锁unLock方法中有下面两句代码:


CLHNode newCurNode = new CLHNode();curNode.set(newCurNode);


这两句代码的作用是什么?这里先直接说结果:若没有这两句代码,若同个线程加锁释放锁后,然后再次执行加锁操作,这个线程就会陷入自旋等待的状态。这是为啥,可能有些下伙伴也没明白,劲越也是搞了蛮久才搞明白,嘿嘿。


下面我们同样通过一步一图的形式来分析这两句代码的作用。 假如有下面这样一个场景:线程 A 获取到了锁,然后释放锁,然后再次获取锁。


第一步: 线程 A 执行了 lock 操作,获取到了锁,如下图:



上图的加锁操作中,线程 A 的当前CLHNode节点的locked状态被置为true;然后tailNode指针指向了当前线程的当前节点;最后因为前继节点的locked状态为false,不用自旋等待,因此获得了锁。


第二步: 线程 A 执行了 unLock 操作,释放了锁,如下图:



上图的释放锁操作中,线程 A 的当前CLHNode节点的locked状态被置为false,表示释放了锁;然后新建了一个新的CLHNode节点newCurNode,线程 A 的当前节点线程本地变量值重新指向了newCurNode节点对象。


第三步: 线程 A 再次执行 lock 操作,重新获得锁,如下图:



上图的再次获取锁操作中,首先将线程 A 的当前CLHNode节点的locked状态置为true;然后首先通过tailNode尾指针获取到前继节点即第一,二步中的curNode对象,然后线程 A 的前继节点线程本地变量的值重新指向了重新指向了curNode对象;然后tailNode尾指针重新指向了新创建的CLHNode节点newCurNode对象。最后因为前继节点的locked状态为false,不用自旋等待,因此获得了锁。


扩展: 注意到以上图片的preNode对象此时没有任何引用,所以当下一次会被 GC 掉。前面是通过每次执行unLock操作都新建一个新的CLHNode节点对象newCurNode,然后让线程 A 的当前节点线程本地变量值重新指向newCurNode。因此这里完全不用重新创建新的CLHNode节点对象,可以通过curNode.set(predNode.get());这句代码进行优化,提高 GC 效率和节省内存空间。

4.5 考虑同个线程加锁释放锁再次获取锁异常的情况

现在我们把unLock方法的CLHNode newCurNode = new CLHNode();curNode.set(newCurNode);这两句代码注释掉,变成了下面这样:


// CLHLock.java


public void unLock() {CLHNode node = curNode.get();node.locked = false;System.out.println("线程" + Thread.currentThread().getName() + "释放了锁!!!");/CLHNode newCurNode = new CLHNode();curNode.set(newCurNode);/}


那么结果就是线程 A 通过加锁,释放锁后,再次获取锁时就会陷入自旋等待的状态,这又是为什么呢?我们下面来详细分析。


第一步: 线程 A 执行了 lock 操作,获取到了锁,如下图:



上图的加锁操作中,线程 A 的当前CLHNode节点的locked状态被置为true;然后tailNode指针指向了当前线程的当前节点;最后因为前继节点的locked状态为false,不用自旋等待,因此获得了锁。这一步没有什么异常。


第二步: 线程 A 执行了 unLock 操作,释放了锁,如下图:



现在已经把unLock方法的CLHNode newCurNode = new CLHNode();curNode.set(newCurNode);这两句代码注释掉了,因此上图的变化就是线程 A 的当前CLHNode节点的locked状态置为false即!可。


第三步: 线程 A 再次执行 lock 操作,此时会陷入一直自旋等待的状态,如下图:



通过上图对线程 A 再次获取锁的lock方法的每一句代码进行分析,得知虽然第二步中将线程 A 的当前CLHNodelocked状态置为false了,但是在第三步线程 A 再次获取锁的过程中,将当前CLHNodelocked状态又置为true了,且尾指针tailNode指向的依然还是线程 A 的当前当前CLHNode节点。又因为每次都是将尾指针tailNode指向的CLHNode节点取出来给当前线程的前继CLHNode节点,之后执行while(predNode.locked) {}语句时,此时因为predNode.locked = true,因此线程 A 就永远自旋等待了。

5 测试 CLH 锁

下面我们通过一个 Demo 来测试前面代码实现的 CLH 锁是否能正常工作,直接上测试代码:

评论

发布
暂无评论
-So-easy!多图详解CLH锁的原理与实现,轻松把握AQS,nginx原理及应用