写点什么

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

作者:Java高工P7
  • 2021 年 11 月 10 日
  • 本文字数:4816 字

    阅读完需:约 16 分钟

CLH 锁原理如下:


  1. 首先有一个尾节点指针,通过这个尾结点指针来构建等待线程的逻辑队列,因此能确保线程线程先到先服务的公平性,因此尾指针可以说是构建逻辑队列的桥梁;此外这个尾节点指针是原子引用类型,避免了多线程并发操作的线程安全性问题;

  2. 通过等待锁的每个线程在自己的某个变量上自旋等待,这个变量将由前一个线程写入。由于某个线程获取锁操作时总是通过尾节点指针获取到前一线程写入的变量,而尾节点指针又是原子引用类型,因此确保了这个变量获取出来总是线程安全的。


这么说肯定很抽象,有些小伙伴可能不理解,没关系,我们心中可以有个概念即可,后面我们会一步一图来彻彻底底把 CLH 锁弄明白。

3 为什么要学习 CLH 锁?

好了,前面我们对 CLH 锁有了一个概念后,那么我们为什么要学习 CLH 锁呢?


研究过 AQS 源码的小伙伴们应该知道,AQS 是 JUC 的核心,而 CLH 锁又是 AQS 的基础,说核心也不为过,因为 AQS 就是用了变种的 CLH 锁。如果要学好 Java 并发编程,那么必定要学好 JUC;学好 JUC,必定要先学好 AQS;学好 AQS,那么必定先学好 CLH。因此,这就是我们为什么要学习 CLH 锁的原因。

4 CLH 锁详解

那么,下面我们先来看 CLH 锁实现代码,然后通过一步一图来详解 CLH 锁。


// CLHLock.java


public class CLHLock {/**


  • CLH 锁节点*/private static class CLHNode {// 锁状态:默认为 false,表示线程没有获取到锁;true 表示线程获取到锁或正在等待// 为了保证 locked 状态是线程间可见的,因此用 volatile 关键字修饰 volatile boolean locked = false;}// 尾结点,总是指向最后一个 CLHNode 节点// 【注意】这里用了 java 的原子系列之 AtomicReference,能保证原子更新 private final AtomicReference<CLHNode> tailNode;// 当前节点的前继节点 private final ThreadLocal<CLHNode> predNode;// 当前节点 private final ThreadLocal<CLHNode> curNode;


// CLHLock 构造函数,用于新建 CLH 锁节点时做一些初始化逻辑 public CLHLock() {// 初始化时尾结点指向一个空的 CLH 节点 tailNode = new AtomicReference<>(new CLHNode());// 初始化当前的 CLH 节点 curNode = new ThreadLocal() {@Overrideprotected CLHNode initialValue() {return new CLHNode();}};// 初始化前继节点,注意此时前继节点没有存储 CLHNode 对象,存储的是 nullpredNode = 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();// 进行解锁操


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


作// 这里将 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.currentThread().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 操作,重新获得锁,如下图:



用户头像

Java高工P7

关注

还未添加个人签名 2021.11.08 加入

还未添加个人简介

评论

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