自旋锁和CLH锁和AQS
说一下自旋锁
1. 介绍一下自旋锁
自旋锁是互斥锁的一种实现 本质上是一种忙等待机制的锁
- 不同于传统的阻塞锁(比如 synchronized 或 mutex)这些阻锁在竞争锁失败后被挂起进入一种等待锁释放的状态。等待被唤醒的这个状态这样虽然不占用 CPU、但上下文切换的开销比较大。
2. 介绍一下阻塞
阻塞就是线程是竞争锁失败后被挂起进入一种等待锁释放的状态
而自旋锁就是一种非阻塞式的锁实现方式、线程在尝试获取锁失败时不会进入阻塞或者挂起状态、而是通过循环自旋的方式等待锁的释放。保持活跃状态但是会占用CPU
3. 自旋锁的工作原理是:
当一个线程尝试获取锁、如果锁被占用、线程获取锁失败、但它不会立即进入阻塞或者挂起状态、而是继续尝试获取锁。
- 这个的自旋的意思就是它会在循环中使用类似 CAS 的方式不断尝试获取锁
- 在自旋期间、线程保持活跃状态、占用 CPU、不会主动让出。
但是 - 若锁竞争激烈或临界区耗时长、自旋会导致 CPU 空转、系统吞吐降低
- 因此在 JDK 或操作系统中、自旋锁常结合自旋一定次数后挂起线程策略使用(这是现代操作系统或 JVM 的优化策略)
4. 说一下自旋锁的优点和缺点:
✅ 优点:
- 避免线程上下文切换开销:适用于锁持有时间很短的场景
- 性能更高:在多核 CPU 上竞争不激烈时、自旋锁可以更快获取锁
❌ 缺点:
- 占用 CPU 资源:线程自旋时不释放 CPU、若锁长时间未释放、容易造成资源浪费;
- 不公平(插队问题):线程一直尝试获取锁时、可能被后来加入的线程插队成功获取、导致这个线程一直获取不到锁而导致饿死
- 在高竞争场景下性能反而下降、因为多个线程都在白忙活抢锁
5. CAS与锁升级
自旋锁常使用 CAS 操作 来尝试设置锁标志位(原子性比较和交换)
- 在 JDK 中、自旋锁常作为轻量级锁的一部分;
- 如果线程自旋多次都失败,比如锁被长时间持有、那么 JVM 可能会将轻量级锁升级为重量级锁、让线程真正进入阻塞挂起状态、提高整体系统的稳定性
6. 自旋锁的适用场景
自旋锁适用于
- 锁竞争不激烈、锁持有时间短的场景
- 临界区代码执行时间很短
- 多核 CPU
- 线程切换代价高的场景
✅ 为什么线程切换代价高?
当线程因获取不到锁而被挂起时系统需要:
- 保存当前线程上下文(寄存器、栈信息等)
- 切换到内核态(用户态 ➜ 内核态)
- 调度其他线程执行
- 等待唤醒后、再恢复原线程上下文
- 再次调度该线程执行
这一套流程的时间开销相对较大(几十到上百微秒)、在锁持有时间很短的情况下、这种切换 ➜ 再切回来反而得不偿失
✅ 自旋锁的优势:
- 自旋锁的最大优势在于不涉及线程的上下文切换。当线程尝试获取锁失败时、它不会立刻被挂起、而是选择持续在 CPU 上自旋尝试再次获取锁。
- 虽然这种方式会消耗 CPU 资源(因为线程在空转)、但如果临界区执行时间非常短、线程可能只需要等待几微秒就能获取到锁。此时自旋所浪费的CPU 时间、远小于阻塞线程所带来的上下文切换、线程挂起与唤醒等操作的系统开销
7. 自旋锁的核心问题->中心化
7.1 什么是中心化锁问题
-
在传统的自旋锁实现中、所有线程都围绕一个全局锁变量进行 CAS 操作 这种模式被称为中心化锁(Centralized
Lock):也就是所有线程都围绕一个全局锁竞争、如果有大量线程在竞争这个锁、那么会导致大量线程阻塞、降低系统的并发性能缓存失效(Cache Coherency)问题: 在多处理器系统中、每个处理器都有自己的缓存。
当一个线程修改了锁的状态(比如锁被释放)、所有其他处理器上的缓存数据会失效、迫使这些处理器从主内存重新获取锁状态
如果所有线程都在轮询同一个锁变量,会造成频繁的缓存同步。这会导致频繁的缓存失效,增加系统的开销
为了解决这些问题、提出了去中心化锁(Decentralized Lock)的设计:
- 不让所有线程都围绕一个锁变量自旋
- 而是让每个线程自旋在不同的状态变量上、降低竞争冲突和缓存失效范围。
7.2 举个例子:基于队列的自旋锁(如 CLH 锁)
CLH锁是典型的去中心化自旋锁、它通过一个虚拟队列链表管理线程排队自旋、核心思路如下:
原理说明:
- 每个线程都有一个自己的节点、并将它加入到一个隐式的队列中
- CLH 是一种去中心化的队列锁、每个线程只关注其前驱节点状态
- 线程不再围绕全局锁状态自旋、而是轮询自己前驱节点的状态来判断是否获得锁;
- 当前线程释放锁时、只需修改自己的节点状态;
- 只有它的直接后继线程会感知到变化、从而减少了缓存失效范围
优势:
- ✅ 排队公平:线程按顺序加入队列、避免了插队和饿死现象
- ✅ 缓存优化:线程只监听前驱状态、不访问同一个变量、减少了缓存一致性协议开销、减少了 CPU 缓存一致性冲突
- ✅ 扩展性好:适用于高并发多核系统、不会因为锁竞争而出现严重抖动。
- CLH天然支持 FIFO 排队 保证线程的公平性
8. 然后引入AQS
8.1 AQS是什么
AQS将每条请求共享资源的线程封装成一个CLH队列锁的一个节点来实现锁的分配 然后我们来说一下AQS
AQS它是 Java 并发包 java.util.concurrent.locks 下一个非常核心的抽象类。
它本身不是一个可以直接使用的同步工具、而是一个用来构建锁或者其他同步组件的基础框架。
像我们常用的 ReentrantLock、Semaphore、CountDownLatch 等 它们的底层实现都依赖于 AQS。
8.2 AQS 的底层原理
AQS 的底层是基于模板方法模式:
它内部维护了一个核心的、原子的 int 类型变量、叫做 state、用来表示同步状态。
- 对于不同的同步组件,其含义不同:
- 对于 ReentrantLock、state 表示锁的重入次数;
- 对于 Semaphore、state 表示可用许可数;
- 对于 CountDownLatch、state 表示剩余计数值;
- 所有对 state 的修改都必须通过 CAS 操作 保证原子性:
AQS 的一个关键设计亮点是其去中心化的线程排队机制、这依赖于 CLH(Craig, Landin, and Hagersten)队列 实现。它用来管理所有尝试获取锁但尚未成功的线程。其核心逻辑如下:
- 每个失败获取锁的线程会被封装成一个 Node 节点、并被加入队列尾部;
- 每个 Node 维护了当前线程、等待状态(如 SIGNAL、CANCELLED)以及前驱节点等信息;
- 线程在等待过程中会被挂起(通过
LockSupport.park()
实现)。仅当前驱节点的waitStatus == SIGNAL
时、线程才会挂起自己 - 当锁被释放、AQS 会唤醒其直接后继节点中的线程、让它重新尝试获取锁。
这种设计是典型的去中心化锁实现
每个线程只需关注其前驱节点的状态、而非与所有线程争抢同一个共享变量、从而带来以下优势
- 降低锁竞争对缓存一致性的冲突、避免了伪共享问题
- 减少 CPU cache line 的压力、提升并发性能
- 被唤醒的线程不会直接获得锁、而是重新参与竞争、这种唤醒-尝试-失败再挂起的模式进一步优化了系统的整体吞吐
8.3 AQS 核心思想是他的AQS资源共享方式
AQS 定义两种资源共享方式:
Exclusive(独占:同一时间只有一个线程能获取资源、其他线程必须等待。这个也叫互斥锁。
典型例子ReentrantLock)
Share(共享:多个线程可以同时获取资源、适用于允许多个线程并发访问的场景。如 Semaphore/CountDownLatch)
通常自定义同步器时、只需选择实现其中一种模式的方法:
- 独占锁:实现
tryAcquire() 和 tryRelease()
- 共享锁:实现
tryAcquireShared() 和 tryReleaseShared()
然后用ReentranLock的锁争夺和释放例子、看看节点是怎么入队的、资源state是怎么被争夺的
8.4 以可重入锁ReentranLock为例
8.4.1 获取锁的流程
🟩 一、获取锁的流程(acquire)
state
表示锁状态
- 初始状态为
state
= 0、表示锁未被持有。 - 当前线程尝试调用 tryAcquire() 进行加锁。
- 如果
state == 0
、使用 CAS 操作尝试获取锁、成功后将state +1
、线程成为锁的持有者。 - 如果
state != 0
且持有锁的线程正是当前线程、则表示是可重入、state
继续累加。 - 如果是其他线程持有锁,当前线程获取失败、进入CLH 队列排队等待。
- 失败后的入队过程
- 若线程争抢
state
失败、会被封装为 Node 节点。 - 检查队列是否初始化:
- 若
head == null
、CAS 初始化队列。 - 否则通过 CAS 插入队尾。
- 若
- 每个线程只会自旋检查自己的前驱节点是否是 head、并判断是否能获取锁。
- 如果不满足条件或被中断、就会被挂起(park),等待被唤醒。
重点: 获取锁前需跳过被标记为 CANCELLED 的节点(如中断、异常退出的线程)。
- 成功获取锁后
- 当前线程所在的节点会被移为新的 head 节点
- head 是一个虚拟节点、它本身并不持有锁、只是队列头标志。
8.4.2 释放锁的流程
🟩 二、释放锁的流程(release)
- 释放锁逻辑
- 当前线程调用
tryRelease()
尝试释放锁 - 判断当前线程是否是持有者
- 若是则
state
递减 - 若递减后的
state
仍 > 0、表示还有重入次数、继续持有锁 - 若 state 变为 0、表示锁彻底释放、将持有线程设置为 null
- 若是则
- 唤醒后继节点
- 根据
head.waitStatus
判断是否需要唤醒:- 若
waitStatus == SIGNAL (-1)
、说明后续节点处于等待状态。
- 若
- 寻找第一个有效的、未被取消的节点、并通过
LockSupport.unpark()
唤醒它。
唤醒范围受限:AQS 只唤醒head 的直接后继节点、这就是 去中心化唤醒、减少了缓存失效冲击。
🟩 三、可重入性的体现
- 当前线程可以多次获取同一把锁(lock() 多次)、state 会不断增加;
- 释放锁时必须和获取次数一致、state 才能归零;
- 只有
state == 0
,锁才会被真正释放、其他线程才能竞争到。
9. 面试时AQS的回答引导
AQS是什么:抽象队列同步器
AQS的作用是啥:它是Java中大量的锁和同步器的基、,用来构建锁和同步器例如CountDownLatch
倒计时器和ReentranLock
可重入锁
通过ReentranLock举例子来说明AQS的原理
首先先说明什么是自旋锁->自旋锁的不公平问题、再介绍到CLH锁
CLH锁是如何优化和解决自旋锁的问题的不公平问题和中心化锁问题以及什么是中心化锁问题
开始介绍AQS里面的东西、也就是我们的CLH队列和State状态变量
然后用ReentranLock的锁争夺和释放例子、看看节点是怎么入队的、资源state
是怎么被争夺的
什么是独占方式和共享方式
独占方式(Exclusive Mode)
- 定义:同一时间只有一个线程能获取资源、其他线程必须等待。
- 实现:通过tryAcquire和tryRelease方法实现资源的获取和释放。
- 应用场景:适用于互斥锁(如ReentrantLock)、确保资源不会被多个线程同时访问
共享方式(Shared Mode)
- 定义:多个线程可以同时获取资源,适用于允许多个线程并发访问的场景。
- 实现:通过tryAcquireShared和tryReleaseShared方法实现资源的获取和释放。
- 应用场景:适用于信号量(如Semaphore)和读写锁(如ReentrantReadWriteLock),允许多个线程同时读取资源
区别
- 独占方式:资源一次只能被一个线程持有。
- 共享方式:资源可以被多个线程同时持有。
示例
- 独占方式:ReentrantLock确保同一时间只有一个线程持有锁。
- 共享方式:Semaphore允许多个线程同时获取许可。
总结
- 独占方式:互斥访问、适用于需要排他性控制的场景。
- 共享方式:并发访问、适用于允许多个线程同时操作的场景
11. ReentrantLock 公平与非公平实现的不同
ReentrantLock 的公平与非公平实现主要体现在其内部的 Sync 对象上、该对象继承自 AQS。
Sync 有两个实现类:FairSync 和 NonfairSync、分别对应公平锁和非公平锁。
- 公平锁(FairSync):在尝试获取锁时、FairSync 会调用 hasQueuedPredecessors() 方法来检查等待队列中是否有前驱线程。如果有前驱线程、当前线程必须排队等待、确保锁的获取顺序与请求顺序一致。
- 非公平锁(NonfairSync):NonfairSync 则不检查等待队列、直接尝试通过 CAS 操作获取锁。如果成功则立即获得锁。这种方式可能导致插队现象、但在某些情况下可以提高吞吐量
ReentrantLock 通过这两个内部类实现了公平和非公平策略
总结:
AQS 是通过 CAS 控制 state
+ CLH 队列管理线程排队 + LockSupport 实现线程挂起与唤醒来实现一种高性能、去中心化的并发控制框架