首页 > 编程语言 >[Java并发]CLH

[Java并发]CLH

时间:2024-07-29 15:57:34浏览次数:12  
标签:Java AQS 并发 线程 自旋 CLH 节点

在并发编程中,锁是一种常用的保证线程安全的方法。Java 中常用的锁主要有两类,一种是 Synchronized 修饰的锁,被称为 Java 内置锁或监视器锁。另一种就是在 J2SE 1.5版本之后的 java.util.concurrent包(下称j.u.c包)中的各类同步器,包括 ReentrantLock(可重入锁),ReentrantReadWriteLock(可重入读写锁),Semaphore(信号量),CountDownLatch 等。这些同步器都是基于 AbstractQueuedSynchronizer(下称 AQS)这个简单的框架来构建的,而 AQS 类的核心数据结构是一种名为 Craig, Landin, and Hagersten locks(下称 CLH 锁)的变体。本篇文章将详细讲解 CLH 锁这一数据结构以及 AQS 对 CLH 锁的改进。

CLH 锁是对自旋锁的一种改良。在介绍 CLH 锁前,我先简单介绍一下自旋锁。

1 自旋锁
1.1 什么是自旋锁
自旋锁是互斥锁的一种实现,Java 实现如下方所示。

public class SpinLock {
private AtomicReference owner = new AtomicReference();

public void lock() {
    Thread currentThread = Thread.currentThread();
    // 如果锁未被占用,则设置当前线程为锁的拥有者
    while (!owner.compareAndSet(null, currentThread)) {
    }
}

public void unlock() {
    Thread currentThread = Thread.currentThread();
    // 只有锁的拥有者才能释放锁
    owner.compareAndSet(currentThread, null);
}

}
如代码所示,获取锁时,线程会对一个原子变量循环执行 compareAndSet 方法,直到该方法返回成功时即为成功获取锁。compareAndSet 方法底层是通用 compare-and-swap (下称 CAS)实现的。该操作通过将内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值。该操作是原子操作。原子性保证了根据最新信息计算出新值,如果与此同时值已由另一个线程更新,则写入将失败。因此,这段代码可以实现互斥锁的功能。

1.2 自旋锁缺点
自旋锁实现简单,同时避免了操作系统进程调度和线程上下文切换的开销,但他有两个缺点:

第一个是锁饥饿问题。在锁竞争激烈的情况下,可能存在一个线程一直被其他线程”插队“而一直获取不到锁的情况。

第二是性能问题。在实际的多处理上运行的自旋锁在锁竞争激烈时性能较差。

下图是引用自《多处理器编程的艺术》的 n 个线程固定地执行一段临界区所需的时间。

TASLock 和 TTASLock 与上文代码类似,都是针对一个原子状态变量轮询的自旋锁实现,最下面的曲线表示线程在没有干扰的情况下所需的时间。

图片

显然,自旋锁的性能和理想情况相距甚远。这是因为自旋锁锁状态中心化,在竞争激烈的情况下,锁状态变更会导致多个 CPU 的高速缓存的频繁同步,从而拖慢 CPU 效率(这里涉及到 CPU 底层的一些知识,这里不再展开)。

因此自旋锁适用于锁竞争不激烈、锁持有时间短的场景。

2 CLH 锁

2.1 什么是 CLH 锁
CLH 锁是对自旋锁的一种改进,有效的解决了以上的两个缺点。首先它将线程组织成一个队列,保证先请求的线程先获得锁,避免了饥饿问题。其次锁状态去中心化,让每个线程在不同的状态变量中自旋,这样当一个线程释放它的锁时,只能使其后续线程的高速缓存失效,缩小了影响范围,从而减少了 CPU 的开销。

CLH 锁数据结构很简单,类似一个链表队列,所有请求获取锁的线程会排列在链表队列中,自旋访问队列中前一个节点的状态。当一个节点释放锁时,只有它的后一个节点才可以得到锁。CLH 锁本身有一个队尾指针 Tail,它是一个原子变量,指向队列最末端的 CLH 节点。每一个 CLH 节点有两个属性:所代表的线程和标识是否持有锁的状态变量。当一个线程要获取锁时,它会对 Tail 进行一个 getAndSet 的原子操作。该操作会返回 Tail 当前指向的节点,也就是当前队尾节点,然后使 Tail 指向这个线程对应的 CLH 节点,成为新的队尾节点。入队成功后,该线程会轮询上一个队尾节点的状态变量,当上一个节点释放锁后,它将得到这个锁。

下面用图来展示 CLH 锁从获取到释放锁的全过程。

图片

CLH 锁初始化时会 Tail 会指向一个状态为 false 的空节点,如图1所示。

当 Thread 1(下称 T1)请求获取锁时,Tail 节点指向 T1 对应的节点,同时返回空节点。T1 检查到上一个节点状态为 false,就成功获取到锁,可以执行相应的逻辑了,如图2所示。

当 Thread 2(下称 T2)请求获取锁时,Tail 节点指向 T2 对应的节点,同时返回 T1 对应的节点。T2检查到上一个节点状态为 True,无法获取到锁,于是开始轮询上一个节点的状态,如图3所示。

当 T1 释放锁时,会将状态变量置为 False,如图4所示。

T2 轮询到检查到上一个节点状态变为 False,则获取锁成功,如图5所示。

2.2 CLH 锁 Java 实现解析
通过上面的图形象的展示了 CLH 的数据结构以及初始化、获取、释放锁的全过程,便于大家理解 CLH 锁的原理。但是就算理解了原理,也不一定能够实现一个线程安全的 CLH 互斥锁。在并发编程领域,“细节是魔鬼”这一格言同样适用。下面将解读 CLH 锁 Java 实现源码并分享并发编程的一些细节。

图片

代码如图所示,有三个地方需要关注,上图已用红框标记:

1、节点中的状态变量为什么用 volatile 修饰?可以不用 volatile 吗?

使用 volatile 修饰状态变量不是为了利用 volatile 的内存可见性,因为这个状态变量只会被持有该状态变量的线程写入,只会被队列中该线程的后驱节点对应的线程读,而且后者会轮询读取。因此,可见性问题不会影响锁的正确性。以上面的例子为例,T2 会不断轮询T1的状态变量,T1 将它的状态变更为 False 时 T2 没有立即感知也没有关系。该状态变量最终会写回内存并被 T2 终感知到变更后的值。

但要实现一个可以在多线程程序中正确执行的锁,还需要解决重排序问题。在《Java 并发编程实战》一书对于重排序问题是这么描述的:在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得到正确的结论。对于 Java synchronized 关键字提供的内置锁(又叫监视器),Java Memory Model(下称 JMM)规范中有一条 Happens-Before(先行发生)规则:“一个监视器锁上的解锁发生在该监视器锁的后续锁定之前”,因此 JVM 会保证这条规则成立。

而自定义互斥锁就需要自己保证这一规则的成立,因此上述代码通过 volatile 的 Happens-Before(先行发生)规则来解决重排序问题。JMM 的 Happens-Before(先行发生)规则有一条针对 volatile 关键字的规则:“volatile 变量的写操作发生在该变量的后续读之前”。

2、CLH 锁是一个链表队列,为什么 Node 节点没有指向前驱或后继指针呢?

CLH 锁是一种隐式的链表队列,没有显式的维护前驱或后继指针。因为每个等待获取锁的线程只需要轮询前一个节点的状态就够了,而不需要遍历整个队列。在这种情况下,只需要使用一个局部变量保存前驱节点,而不需要显式的维护前驱或后继指针。

3、this.node.set(new Node()) 这行代码有何意义?

如果没有这行代码,Node 可能被复用,导致死锁,如下图所示:

图片

1.一开始,T1 持有锁,T2 自旋等待,如图1开始。

2.当 T1 释放锁(设置为 false),但此时 T2 尚未抢占到锁,如图2所示。

3.此时如果 T1 再次调用 lock()请求获取锁,会将状态设为 True,同时自旋等待 T2 释放锁。而T2也自旋等待前驱节点状态变为 False,这样就造成了死锁,如图3所示。

因此需要这行代码生成新的 Node 节点,避免 Node 节点复用带来的死锁。

2.3 CLH 优缺点分析
CLH 锁作为自旋锁的改进,有以下几个优点:

性能优异,获取和释放锁开销小。CLH 的锁状态不再是单一的原子变量,而是分散在每个节点的状态中,降低了自旋锁在竞争激烈时频繁同步的开销。在释放锁的开销也因为不需要使用 CAS 指令而降低了。

公平锁。先入队的线程会先得到锁。

实现简单,易于理解。

扩展性强。下面会提到 AQS 如何扩展 CLH 锁实现了 j.u.c 包下各类丰富的同步器。

当然,它也有两个缺点:第一是因为有自旋操作,当锁持有时间长时会带来较大的 CPU 开销。第二是基本的 CLH 锁功能单一,不改造不能支持复杂的功能。

3 AQS 对 CLH 队列锁的改造
针对 CLH 的缺点,AQS 对 CLH 队列锁进行了一定的改造。针对第一个缺点,AQS 将自旋操作改为阻塞线程操作。针对第二个缺点,AQS 对 CLH 锁进行改造和扩展,原作者 Doug Lea 称之为“CLH 锁的变体”。下面将详细讲 AQS 底层细节以及对 CLH 锁的改进。AQS 中的对 CLH 锁数据结构的改进主要包括三方面:扩展每个节点的状态、显式的维护前驱节点和后继节点以及诸如出队节点显式设为 null 等辅助 GC 的优化。正是这些改进使 AQS 可以支撑 j.u.c 丰富多彩的同步器实现。

3.1 扩展每个节点的状态
AQS 每个节点的状态如下所示,在源码中如下所示:

volatile int waitStatus;
AQS 同样提供了该状态变量的原子读写操作,但和同步器状态不同的是,节点状态在 AQS 中被清晰的定义,如下表所示:
状态名
描述
SIGNAL 表示该节点正常等待
PROPAGATE 应将 releaseShared 传播到其他节点
CONDITION 该节点位于条件队列,不能用于同步队列节点
CANCELLED 由于超时、中断或其他原因,该节点被取消

3.2 显式的维护前驱节点和后继节点
上文我们提到在原始版本的 CLH 锁中,节点间甚至都没有互相链接。但是,通过在节点中显式地维护前驱节点,CLH 锁就可以处理“超时”和各种形式的“取消”:如果一个节点的前驱节点取消了,这个节点就可以滑动去使用前面一个节点的状态字段。对于通过自旋获取锁的 CLH 锁来说,只需要显式的维护前驱节点就可以实现取消功能,如下图所示:

图片

但是在 AQS 的实现稍有不同。因为 AQS 用阻塞等待替换了自旋操作,线程会阻塞等待锁的释放,不能主动感知到前驱节点状态变化的信息。AQS 中显式的维护前驱节点和后继节点,需要释放锁的节点会显式通知下一个节点解除阻塞,如下图所示,T1 释放锁后主动唤醒 T2,使 T2 检测到锁已释放,获取锁成功。
图片

其中需要关注的一个细节是:由于没有针对双向链表节点的类似 compareAndSet 的原子性无锁插入指令,因此后驱节点的设置并非作为原子性插入操作的一部分,而仅是在节点被插入后简单地赋值。在释放锁时,如果当前节点的后驱节点不可用时,将从利用队尾指针 Tail 从尾部遍历到直到找到当前节点正确的后驱节点。

3.3 辅助 GC
JVM 的垃圾回收机制使开发者无需手动释放对象。但在 AQS 中需要在释放锁时显式的设置为 null,避免引用的残留,辅助垃圾回收。

4 小结
Java AQS 是 Java 并发中重要的一部分,但作为一个抽象的并发同步框架,源代码比较晦涩,不利于阅读和学习。本篇文章从自旋锁出发,详细介绍了 CLH 锁及 AQS 对 CLH 的改造,尽量使用丰富的图展示相关的数据结构,避免引用晦涩难懂的 Java 源码,希望对大家理解 AQS 原理和正确使用 j.u.c 下各类同步器有帮助。

标签:Java,AQS,并发,线程,自旋,CLH,节点
From: https://www.cnblogs.com/DCFV/p/18330285

相关文章

  • Java计算机毕业设计考研信息交流网站(开题报告+源码+论文)
    本系统(程序+源码)带文档lw万字以上 文末可获取一份本项目的java源码和数据库参考。系统程序文件列表开题报告内容研究背景随着高等教育的普及与考研热潮的持续升温,越来越多的学生选择继续深造,考研成为了他们人生规划中的重要一环。然而,在备考过程中,考生往往面临信息获取渠......
  • Java计算机毕业设计家乡印象网站(开题报告+源码+论文)
    本系统(程序+源码)带文档lw万字以上 文末可获取一份本项目的java源码和数据库参考。系统程序文件列表开题报告内容研究背景在快节奏的现代生活中,人们对家乡的眷恋与回忆愈发显得珍贵。随着互联网的普及,网络成为了连接过去与现在、家乡与远方的桥梁。然而,目前市场上缺乏一个......
  • Java计算机毕业设计旅游网站(开题报告+源码+论文)
    本系统(程序+源码)带文档lw万字以上 文末可获取一份本项目的java源码和数据库参考。系统程序文件列表开题报告内容研究背景随着互联网的普及和人们生活水平的提高,旅游已成为现代人追求生活品质的重要方式之一。然而,面对众多的旅游目的地和丰富的旅游资源,游客在规划行程时往......
  • Java计算机毕业设计零食销售系统(开题报告+源码+论文)
    本系统(程序+源码)带文档lw万字以上 文末可获取一份本项目的java源码和数据库参考。系统程序文件列表开题报告内容研究背景随着生活节奏的加快和消费习惯的变化,零食已成为现代人日常生活中不可或缺的一部分。从传统的超市货架到新兴的电商平台,零食销售渠道日益多元化,市场竞......
  • JAVA基础 - 异常处理
    目录一.简介二. 受检异常三. 非受检异常四. 自定义异常类一.简介异常处理是Java编程中的一个重要概念,它用于处理程序运行时可能出现的不正常情况。在Java中,异常可以分为两类:受检异常(CheckedException)和非受检异常(UncheckedException)。受检异常是指在编译......
  • Javaweb简单的学生管理系统(增删改)
    学生servletimportjavax.servlet.ServletException;importjavax.servlet.annotation.WebServlet;importjavax.servlet.http.HttpServlet;importjavax.servlet.http.HttpServletRequest;importjavax.servlet.http.HttpServletResponse;importjava.io.IOException;......
  • Java计算机毕业设计创新创业评审系统(开题报告+源码+论文)
    本系统(程序+源码)带文档lw万字以上 文末可获取一份本项目的java源码和数据库参考。系统程序文件列表开题报告内容研究背景在创新驱动发展战略的引领下,创新创业已成为推动社会经济发展的重要引擎。随着高校创新创业教育的不断深化,学生创新创业项目如雨后春笋般涌现,但如何科......
  • Java计算机毕业设计机房设备管理系统(开题报告+源码+论文)
    本系统(程序+源码)带文档lw万字以上 文末可获取一份本项目的java源码和数据库参考。系统程序文件列表开题报告内容研究背景随着信息技术的飞速发展,机房作为数据存储、处理和传输的核心区域,其设备的数量与种类日益增多,管理复杂度也随之提升。传统的手工或简单电子化管理模式......
  • java中使用ImageMagick工具在图片中进行文字水印输出
    第一步:需要准备ImageMagick水印工具第二步:配置水印工具位置、字体等基础设置publicstaticvoidmain(String[]args)throwsInterruptedException,IOException,IM4JavaException{StringimageMagickPath="D:\\ImageMagick-7.0.8-Q16";//软件路径StringfontPath......
  • CSDN最新JAVA面试题集
    第一章-Java基础篇1、你是怎样理解OOP面向对象   难度系数:⭐面向对象是利于语言对现实事物进行抽象。面向对象具有以下特征:继承:继承是从已有类得到继承信息创建新类的过程封装:封装是把数据和操作数据的方法绑定起来,对数据的访问只能通过已定义的接口多态性:多态性是指允......