AQS的前菜—详解CLH队列锁

article/2025/11/5 19:46:13

https://blog.csdn.net/weixin_47184173/article/details/115340014

什么是CLH队列锁

CLH锁其实就是一种基于逻辑队列非线程饥饿的一种自旋公平锁。当多线程竞争一把锁时,获取不到锁的线程,会排队进入CLH队列的队尾,然后自旋等待,直到其前驱线程释放锁。由于是 Craig、Landin 和 Hagersten三位大佬的发明,因此命名为CLH锁。

自旋锁与互斥锁的区别

由于CLH锁是一种自旋锁,那么我们先来看看自旋锁是什么?

自旋锁说白了也是一种互斥锁,只不过没有抢到锁的线程会一直自旋等待锁的释放,处于busy-waiting的状态,此时等待锁的线程不会进入休眠状态,而是一直消耗CPU判断是否有资格获取锁。因此自旋锁适用于锁占用时间短的场合。

这里,我们可以总结出,CLH队列锁,如果队列过长(等待锁的线程过多),势必会给CPU带来极大的压力,因此CLH锁的使用场景就被局限住了。

Java中ReentrantLock重入锁中的AQS队列排队策略,是基于CLH队列的一种变种实现。原始的CLH队列,一般用于实现自旋锁。而AQS队列的实现,是获取不到锁的线程,先进行一小段时间的自旋,然后进入Park挂起状态。【同样的,ZK中的分布式锁,也使用了类似方式,获取不到锁的线程值监听前一个节点】。因此AQS解决了CLH每个线程无限自旋的问题,应用场景也更加广泛。

这里谈到了自旋锁,那么我们也顺便说下互斥锁。这里的互斥锁说的是传统意义的互斥锁,就是多个线程并发竞争锁的时候,没有抢到锁的线程会进入休眠状态即sleep-waiting,当锁被释放的时候,处于休眠状态的一个线程会再次获取到锁。缺点就是这一些列过程需要线程切换,需要执行很多CPU指令,同样需要时间。如果CPU执行线程切换的时间比锁占用的时间还长,那么可能还不如使用自旋锁。因此互斥锁适用于锁占用时间长的场合。

CLH锁原理

  • 首先有一个尾节点指针,通过这个尾结点指针来构建等待线程的逻辑队列,当有新的节点加入队列时,尾节点指针会指向这个新加入的节点,并将原本的尾节点变为当前新加入节点的前驱节点。因此能确保线程线程先到先服务的公平性,尾指针可以说是构建逻辑队列的桥梁;此外这个尾节点指针是原子引用类型,避免了多线程并发操作的线程安全性问题;
  • 通过等待锁的每个线程在自己的某个变量上自旋等待,这个变量指向自己的前驱节点中的变量,通过不断地自旋,感知到前驱节点的变化后成功获取到锁。

CLH锁的优点

  1. 没有惊群效应。假设有1000个线程等待获取锁,锁释放后,只会通知队列中的第一个线程去竞争锁,避免了同时唤醒大量线程 在瞬间争抢CPU资源,避免了惊群效应。(此处仅仅是不会对锁过度的争抢,也就是公平锁的好处。但是自旋锁的实现方式依然消耗CPU)
  2. CLH队列锁的长处是空间复杂度低(假设有n个线程。L个锁,每一个线程每次仅仅获取一个锁,那么须要的存储空间是O(L+n),n个线程有n个myNode。L个锁有L个tail)。

CLH锁的缺点

在NUMA系统结构下性能稍差。在这样的系统结构下,每一个线程有自己的内存,假设前趋结点的内存位置比較远。自旋推断前趋结点的locked域,性能将大打折扣,在SMP结构下还是非常有效的。【CLH自旋在前驱节点上,访问的是其他线程的变量值,在NUMA架构下,其他线程变量有可能是对端CPU的高速缓存,因此更适合SMP架构】

深入CLH队列源码

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

// CLHLock.javapublic 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();// 进行解锁操作// 这里将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锁的初始化逻辑

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

首先定义了一个CLHNode节点(在代码中以内部类的形式呈现),里面有一个locked属性,表示线程线程是否获得锁,默认为false。false表示线程没有获取到锁或已经释放锁;true表示线程获取到了锁或者正在自旋等待。

image.png

这个locked也就是我们每个节点轮询判断前驱节点是否释放锁的关键变量。但我们知道这个变量是跨线程获取的,为了保证locked属性线程间可见,该属性被volatile修饰。

接下来,我们看CLHLock类中有三个重要的成员变量:

image.png

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

接着,到我们的构造方法,看看构造方法都干了些什么事。

image.png

给尾指针tailNode和当前节点curNode初始化一个locked状态为false的CLHNode节点,此时前继节点preNode存储的是null。配合上我们之前说的,当节点为false时表示线程没有获取到锁或已经释放锁,因此当新增节点时,必然会直接获取锁。

CLH加锁过程

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

image.png

第一步:CLHNode currNode = curNode.get();

由于在构造函数中对每一个线程的curNode都做了初始化,所以直接获取当前线程的当前节点curNode,并且每次获取的CLHNode节点的locked状态都为false;

image.png

第二步:currNode.locked = true;

设置当前节点的状态位true。表示等待锁或者正在执行锁。意图就是阻塞它的后继节点获取锁。

第三步:CLHNode preNode = tailNode.getAndSet(currNode);

使用尾节点tailNode,将当前节点线程安全的进入队列尾部,并且返回原本的尾节点。

第四步:predNode.set(preNode);

将当原本的尾节点,设置为当前节点的前驱节点。

第五步:轮循检测前驱节点locked的状态

image.png

当前驱节点检测变为了false时,就成功获取到了锁。这也说明locked这种跨线程变量,必须要使用voliate修饰保证可见性。

但是此处我们发现,每一个节点(线程)都在自旋获取锁。因此如果在任务较长较多的情况下,CLH锁对CPU的性能消耗不言而喻。因此在未来的AQS中,对此处进行了优化。

画图解释

第一个节点加入

我们假设线程A是第一个加入队列的线程,我们都知道它的前驱节点是一个null,那么如何给它的前驱节点赋值呢?答案就是tailNode,在初始化的时候对taileNode中的locked成员也设为了false。因此当线程A通过taileNode获取队列中第一个元素的前驱节点时,也不会获取为null。

image.png

image.png

经过代码分析,我们得出了第一个线程加入队列后的状态图。

image.png

第二个节点加入

此时,第二个线程B加入,我们的队列会发生什么变化呢?假设线程A一直持有锁不释放。当线程B调用lock方法进入队列时,队列发生了哪些变化。

image.png

image.png

第三个节点加入

后续的流程就和第二个节点加入流程一模一样了。

image.png

CLH解锁过程

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

image.png

由于我们的加锁逻辑设置的非常巧妙,解锁逻辑反而非常简单明了了。

首先,获取当前线程的CLHNode。(为什么是当前线程?因为肯定是需要解锁的线程调用unlock方法,此处是一个逻辑对应关系)。然后将locked状态置为false即释放了锁;locked因为被volitile关键字修饰,此时后面自旋等待的线程的局部变量preNode.locked也为false,因此后面自旋等待的线程结束while循环即结束自旋等待,此时也获取到了锁。这一步骤也在异步进行着。

然后给当前线程的表示当前节点的线程本地变量重新赋值为一个新的CLHNode。这一步就很莫名其妙了,这么做的目的是什么呢?我们留到后面再说。

现在线程A释放锁,线程B获取锁的流程图如下:

image.png

解锁流程中莫名其妙的两行代码

image.png

就是这两行代码,看得人莫名其妙。那么这两行代码取消了会有什么问题么?别急,这就给你上个例子。

首先,假设队列里存储的都是不同的线程的节点,那么即时注销掉代码,当前线程释放锁,其他线程抢到锁的流程如下图所示:

image.png

这是没有任何问题的,而问题出现在下面这种场景:

此时,CLH队列锁中只有一个线程在反复的添加节点。那么效果就是线程A执行完毕执行unlock方法,紧接着由它再次执行lock方法。现在假设我们这两行莫名其妙的代码注销掉,来看看流程图会出现什么问题。

注销掉两行代码出现问题

image.png

第一步:线程A执行lock

image.png

此时状态如上图。

第二步:线程A执行unlock

image.png

第三步:线程A再次执行lock

接下来,就是问题出现的关键了。此时的获取锁中:CLHNode preNode = tailNode.getAndSet(currNode);这段代码,根据tailNode获取前驱节点,此时的当前线程的前驱节点和当前节点都代表着同一个节点。那么当每次默认给当前节点的locked赋值为true时,就会将原本前驱节点locked值为false的节点又改成true。好家伙,不用说,之后的轮循就会无限等待前驱节点的锁。

image.png

反应到图中的效果如下:

image.png    此时,我们就发现死循环的问题了。说到底,就是因为taileNode指向了同一个节点导致的。那么该如何解决呢?

添加这两行代码解决问题

image.png

第一步:线程A执行unlock

我们可以看到,在添加这两行代码后,线程A的在队列中的状态图变成了这样:

image.png

诶?这样,操作一下,tailNode就不会再指向ThreadA中的当前节点了。

第二步:线程A再次执行lock

image.png

可以看到,再次执行CLHNode preNode = tailNode.getAndSet(currNode);时,获取到的前驱节点就是一个已经被弃用,但必然是false的节点。从而解决了我们连续向CLH队列添加相同线程节点出现的死循环问题。

优化GC减少内存空间的优化

image.png

我们知道。我们之前的那两行代码,就是为了让我们当前线程持有的一个不和后继节点重复的locked值为false的CLHNode节点。那么我们每次新建对象必然会给内存,GC带来消耗。此时已经存在的前驱节点必然locked值为false,正好让我们拿来继续利用,避免过多无用对象的创建。

小结

我们在的CLH队列其实属于我们学习AQS的前菜。但是只有深入研究后,才知道CLH存在什么问题(CLH每一个线程都是一个自旋锁,非常消耗CPU),以及AQS在CLH的基础上做了哪些优化。

我们可以看到公平锁就是最初的实现理念就是CLH队列。


http://chatgpt.dhexx.cn/article/3llh2ul2.shtml

相关文章

CLH(Craig, Landin, and Hagersten locks)机制

CLH(Craig, Landin, and Hagersten locks): 是一个自旋锁&#xff0c;能确保无饥饿性&#xff0c;提供先来先服务的公平性。CLH锁也是一种基于链表的可扩展、高性能、公平的自旋锁&#xff0c;申请线程只在本地变量上自旋&#xff0c;它不断轮询前驱的状态&#xff0c;如果发现…

【Java】Java函数式编程以及流的操作

文章目录 大纲lambda表达式一般内部类局部内部类匿名内部类 基于函数式接口的lambda表达式JDK8中自带的函数式接口Predicate判断Consumer消费Supplier供应商Function方法其他接口 方法引用和构造器引用静态方法引用非静态方法引用构造器引用 lambda表达式中的异常处理Currying …

【Java进阶】java函数式编程的使用

目录 1.目前Java中自带的函数式编程接口 2.java中使用函数式编程的案例 3.自定义函数式接口 4.自定义函数式接口的实现 简单一句话理解函数式编程&#xff0c;传统的方法调用我们都是传递参数&#xff0c;而函数式编程&#xff0c;传递的则是方法实现的过程。 1.目前Java中自…

Java教程:一文详解函数式编程

看懂了这篇-你就懂了函数式接口 ​ 函数式编程是一种编程规范或一种编程思想&#xff0c;简单可以理解问将运算或实现过程看做是函数的计算。 Java8为了实现函数式编程&#xff0c;提出了3个重要的概念&#xff1a;Lambda表达式、方法引用、函数式接口。现在很多公司都在使用l…

java中的函数式编程(一)

当你安安稳稳的学着java&#xff0c;慢慢开始写代码。 兢兢业业&#xff0c;本着面向对象的编程方式。 知道有一种叫做“面向过程”的方式&#xff0c;但是你不在意。 写了一段时间后有人告你&#xff0c;函数式编程很爽&#xff01; 你也看到了他给的一个实例&#xff0c;看着…

Java函数式编程(基础):第一部分

1.函数式编程有三个部分&#xff1a; 第一个部分是&#xff1a;Lambda表达式 第二个部分是&#xff1a;方法引用 第三个部分是&#xff1a;函数式接口 刚接触Lambda表达式的我&#xff0c;觉得它很神奇&#xff0c;能够用简短的代码&#xff0c;代替传统的编程方式 举一个简…

java-函数式编程浅谈

了解函数式编程的实际应用场景以及优点。 文章目录 什么是函数式编程函数式编程的使用原理解析 什么是函数式编程 以数学中的函数作为切入点&#xff0c;只关注参数之间的运算满足某种规则&#xff0c;例如zxy。 那么如何体现在编程中呢&#xff0c;熟知的function定义可以作…

Java 8函数式编程

函数式接口 一个接口中&#xff0c;有且只有一个抽象方法&#xff0c;这个接口就叫做函数式接口。常常使用FunctionalInterface注解作为编译校验。满足函数式接口的要求&#xff0c;才能校验通过&#xff0c;否则会在校验阶段失败。 接口中有且只能有一个抽象方法&#xff0c;…

【函数式编程实战】(一)Java演变与函数式编程

前言 &#x1f4eb;作者简介&#xff1a;小明Java问道之路&#xff0c;专注于研究计算机底层/Java/Liunx 内核&#xff0c;就职于大型金融公司后端高级工程师&#xff0c;擅长交易领域的高安全/可用/并发/性能的架构设计&#x1f4eb; &#x1f3c6;CSDN专家博主/Java领域优质…

Java8 函数式编程

文章目录 Java 函数式编程1. Lambda 表达式1.1 标准格式1.2 使用前提1.2.1 一个参数1.2.2 多个参数1.2.3 有返回值 1.3 省略简化1.4 函数式接口1.4.1 Supplier1.4.2 Consumer1.4.3 Predicate1.4.4 Function 1.5 方法引用1.5.1 对象 :: 实例方法1.5.2 类 :: 静态方法1.5.3 类 ::…

入门 Java 函数式编程,看完这篇就清晰了

Java 在最开始是不支持函数式编程的&#xff0c;想来也好理解&#xff0c;因为在 Java 中类 Class 才是第一等公民&#xff0c;这就导致在 Java 中实现编程不是件那么容易的事儿&#xff0c;不过虽然难&#xff0c;但是结果我们也已经知道了&#xff0c;在 Java 8 这个大版本里…

Java函数式编程详解

Java从1.8以后引入了函数式编程&#xff0c;这是很大的一个改进。函数式编程的优点在提高编码的效率&#xff0c;增强代码的可读性。本文历时两个多月一点点写出来&#xff0c;即作为心得&#xff0c;亦作为交流。 1.Java函数式编程的语法&#xff1a; 使用Consumer作为示例&…

Java 函数式编程 详细介绍

在兼顾面向对象特性的基础上&#xff0c;Java语言通过Lambda表达式与方法引用等&#xff0c;为开发者打开了函数式编程的大门。 下面我们做一个初探。 Lambda的延迟执行 有些场景的代码执行后&#xff0c;结果不一定会被使用&#xff0c;从而造成性能浪费。而Lambda表达式是延…

Java基础函数式编程

本篇博文目录: 前言1.什么是函数式接口2.函数式编程(1) 使用Lambda表达式(2) Lambda表达式的进一步简化(3) Java内置函数式接口 3.方法引用(1) 方法引用的简单使用(2) 方法引用的分类 4.Stream API(1) 什么是Stream(2) 流式操作的执行流程(3) Stream的创建(4) Stream中间操作(5…

Java8新特性【函数式编程API、新时间日期处理API、Optional容器类】总结

文章目录 1、Lambda表达式1.1什么是Lambda表达式1.2从匿名类到 Lambda 的转换1.3Lambda表达式语法 2、函数式接口2.1什么是函数式接口2.2自定义函数式接口2.3内置核心函数式接口2.4接口中常用的默认方法 3、方法引用与构造器引用3.1 推荐用法3.2 基本格式3.3 语法详解(了解)3.3…

一文带你入门 Java 函数式编程

Java 在最开始是不支持函数式编程的&#xff0c;想来也好理解&#xff0c;因为在 Java 中类 Class 才是第一等公民&#xff0c;这就导致在 Java 中实现编程不是件那么容易的事儿&#xff0c;不过虽然难&#xff0c;但是结果我们也已经知道了&#xff0c;在 Java 8 这个大版本里…

Oracle数据库 存储过程入门

oracle存储过程:简单入门 一、定义 存储过程是一组为了完成特定功能的SQL语句&#xff0c;经编译后存储在数据库中。点击查看优缺点。二、存储过程简单入门 ***第一个存储过程&#xff1a;打印hello word, my name is stored procedure内容*** create or replace procedure m…

数据库储存过程超简单实例

网上看了半天都没找到一个完整储存过程从创建到调用的实例,于是自己写了一个简单的实例. 数据库创建存储过程,定义个函数 格式如下,开头DELIMITER //和结尾/DELIMITER 和BEGIN 和 END 是固定格式 定了一个叫test2()的方法(在mapper.xml中会指定这个函数名),in表示入参,varc…

DM8达梦数据库存储过程函数使用

DM8数据库的过程函数的编写主要分为4个部分&#xff1a;过程头部分&#xff0c;声明定义部分&#xff0c;执行部分和异常处理部分。在编写方面&#xff0c;过程和函数的主要区别还是函数可以返回一个值&#xff0c;但是过程没有。下面就从这4个部分来分别介绍过程的编写和一些常…

数据库:存储过程实验

一、实验目的及要求 目的 掌握存储过程的编写与调用 要求 掌握存储过程的编写&#xff1b;掌握存储过程的调用 二、实验条件 安装有SQL Server2014数据库的计算机 三、实验内容 使用事务、锁和游标&#xff1b;编写和使用存储过程&#xff1b;使用触发器 四、实验结果…