Java 自旋锁:实现机制与优化策略
一.自旋锁概念和示例
自旋锁是一种忙等待(busy-wait)的锁机制。在获取锁时,线程不会立即挂起,而是在循环中不断尝试竞争条件,一旦条件满足即可进入临界区,从而避免了线程阻塞和上下文切换的开销。自旋锁的基本设计思想是假设锁持有时间极短,这种情况下短暂的忙等待往往比进行一次昂贵的线程调度更高效。
简单的CAS 的自旋锁实现:
public class SpinLock {private AtomicReference<Thread> owner = new AtomicReference<>();public void lock() {Thread cur = Thread.currentThread();// 自旋等待,直到 CAS 成功获得锁while (!owner.compareAndSet(null, cur)) {// 这里可以调用 LockSupport.parkNanos(…) 等来降低 CPU 占用}}public void unlock() {// 释放锁:只有当前线程才能把 owner 置为 nullowner.compareAndSet(Thread.currentThread(), null);}
}
自旋锁避免了上下文切换,但如果锁持有时间超出预期,则会造成大量无效的 CPU 占用。因此在 JVM 中,对于自旋锁等待次数通常有上限(HotSpot 默认 10 次,可通过 -XX:PreBlockSpin
调整);超过次数后,线程便会被挂起等待。
可重入的自旋锁实现:(本质和ReentranrLock实现类似,就是通过一个count变量计数)
public class ReentrantSpinLock extends SpinLock {private Thread owner;private int count;//用于记录重入的次数@Overridepublic void lock() {if (owner == null || owner != Thread.currentThread()) {while (!value.compareAndSet(0, 1));owner = Thread.currentThread();count = 1;}else {count++;}}@Overridepublic void unlock() {if (count == 1) {count = 0;value.compareAndSet(1, 0);}elsecount--;}
}
二.JVM与OS层面
HotSpot 中的偏向锁、轻量级锁与自适应自旋
为了优化 synchronized
的性能,HotSpot JVM 在 JDK6 以后引入了偏向锁(Biased Locking)、轻量级锁(Lightweight Lock)以及自适应自旋等机制。一个对象在默认情况下(启用偏向锁)会处于偏向锁状态,此时第一个获取锁的线程会把自己的线程 ID 记录到该对象的 Mark Word 中,当后续同一线程再次请求锁时,无需任何 CAS 操作就能成功进入。偏向锁适用于几乎没有竞争的场景,能极大减少无竞争时的加锁开销。但如果另一个线程尝试访问该锁,HotSpot 会撤销偏向锁,将锁升级为轻量级锁。
当锁升级为轻量级锁时,每个竞争锁的线程会在自己的栈中创建一个 LockRecord,并尝试通过 CAS 把对象头的 Mark Word 指向自己的 LockRecord。如果 CAS 成功,该线程获得锁;如果失败,线程可以选择继续自旋或最终进入阻塞。
HotSpot 会根据竞争激烈程度和历史自旋情况决定是否需要自适应地调节自旋策略。
当自旋很可能成功时(如上一次自旋曾获得过锁且持有者线程仍在运行),系统会允许更长时间的自旋;反之则较快让线程阻塞。这种自适应自旋(Adaptive Spinning)策略在 JDK6 之后成为默认启用项,通过衡量自旋成功率和锁持有者状态来权衡忙等与阻塞的成本。
bool ObjectMonitor::TrySpin_VaryFrequency(int attempts) {if (recent_prev_succeeded()) { // 上次自旋成功if (owner_thread->is_running()) { // 锁持有者仍在运行return TrySpin_Default(attempts * 2); // 更多自旋机会} else {return TrySpin_Default(attempts / 2); // 减少自旋}} else {return TrySpin_Default(attempts); // 维持默认}
}
如果锁竞争依然激烈(例如自旋次数和自旋线程数超过 JVM 控制的阈值),锁会膨胀为重量级锁,由操作系统提供的互斥量(Mutex)实现。此时线程放弃自旋,挂起并进入操作系统队列等待,由内核完成调度和唤醒。重量级锁下的上下文切换成本较高,因此 HotSpot 的多级锁策略(偏向 → 轻量级 → 重量级)结合自适应自旋,能够在不同并发场景中自动选择性能最优的路径。
操作系统层面的支持与约束
自旋锁主要依赖硬件原语和操作系统提供的低级接口。在多核架构下,CAS 等原子操作(如 x86 的 LOCK CMPXCHG
或 XCHG
指令)用于执行并发竞争时的原子更新,这些操作会引发缓存一致性机制(MESI/MOESI 协议)将锁变量对应的缓存行在各核间“弹来弹去”。在锁竞争时,每次执行自旋的 CAS 都可能导致缓存行在不同 CPU 缓存之间高速通信,从而带来显著开销(即“缓存行颠簸”)。这是自旋锁在高竞争场景下开销巨大的原因之一:CPU 内部为了维护一致性需要频繁协调并锁定总线,从而限制了自旋锁的伸缩性。
注:1."弹来弹去":
在这个例子中:
AtomicInteger counter = new AtomicInteger(0);// 线程 A 和 B 同时执行以下操作
for (int i = 0; i < 1_000_000; i++) {counter.incrementAndGet();
}
- 线程 A 修改
counter
- 它的缓存行状态变为
M(Modified)
- 发送 Invalidate 消息给线程 B 的缓存
- 它的缓存行状态变为
- 线程 B 尝试修改
counter
- 它发现自己的缓存行是
I(Invalid)
,必须从主存或线程 A 的缓存中重新加载 - 修改后,它变成
M
,又发送 Invalidate 给线程 A
- 它发现自己的缓存行是
- 反复切换
- 缓存行在 A 和 B 之间来回切换状态(S → M → I)
- 导致每次更新都要等待缓存一致性刷新,性能严重下降
2.像平时的synchronized,CAS,volatile都会涉及到这两种协议
MESI 是一种缓存一致性协议(Cache Coherence Protocol) ,用于在多核 CPU 系统中维护多个核心之间缓存数据的一致性。
MESI 的四种状态:
状态 | 含义 |
---|---|
M - Modified(已修改) | 当前核心拥有该缓存行,并且内容与主存不一致,只有本核心有此缓存 |
E - Exclusive(独占) | 当前核心拥有该缓存行,内容与主存一致,其他核心没有副本 |
S - Shared(共享) | 多个核心都拥有该缓存行,内容一致 |
I - Invalid(无效) | 当前缓存行无效,不能使用 |
有些操作系统会使用MOESI 协议 ,比 MESI 多了一个 O(Owned)
状态:
状态 | 含义 |
---|---|
O - Owned | 类似 S,但表示该缓存行的数据是最新的,即使主存不是最新,也不需要立即写回 |
操作系统内核自身也支持自旋锁(用于 SMP 内核对共享数据结构加锁)。在内核态,自旋锁持有期间相关 CPU 核心不可被抢占(不可休眠),上下文切换被禁用。类似地,在用户态的自旋锁设计中,通常假设持有锁的关键区足够短暂,因此会尽量避免线程被阻塞。如果锁确实被长期占用,自旋锁也会让出 CPU——这通常通过自旋次数达到上限后,让线程调用操作系统接口(如 Java 的 LockSupport.park()
)进入阻塞状态来实现。这样,操作系统层面提供了线程挂起与唤醒的机制以平衡等待策略。
多核性能分析:上下文切换 vs 自旋等待
在多核环境中,自旋锁表现出性能和成本的权衡。如果系统空闲核足够多,让等待锁的线程占用其他核自旋,那么在锁释放后几乎可以立即获取,极大地降低了时延。相比之下,如果使用传统阻塞锁(mutex),线程抢锁失败后会陷入阻塞,触发内核态和用户态之间的上下文切换,导致线程切换和 CPU 寄存器保存/恢复等耗时操作。性能测试表明,普通用户态线程上下文切换的延迟往往在微秒级别甚至更高,这在临界区执行极短的情况下,可能完全抵消锁本身的执行开销。故而,当锁持有时间远短于一次上下文切换时间时,自旋锁往往性价比更高。
但自旋锁在以下情况下会表现欠佳:当锁持有者执行较长操作或系统核心数不足时,持续自旋会浪费大量 CPU 资源,且频繁的缓存一致性流量会带来额外延迟。同时,如果所有核心都被忙碌线程占用,且还有线程在自旋等待,这实际上会降低并行度,甚至引发饥饿。为此,HotSpot 和并发框架一般对自旋时间进行限制,一旦判断自旋意义不大,就让线程通过阻塞切换到睡眠状态,等待被唤醒再重新尝试。
总体而言,在多核环境下,短等待、低竞争的场景更适合自旋锁,而长等待、易阻塞的场景则需依赖操作系统层的睡眠机制来避免 CPU 浪费。现代 JVM 的自适应自旋正是试图综合过去行为、锁持有状态和系统负载,在自旋与阻塞之间做动态权衡。
适用场景与替代技术比较
自旋锁的主要适用场景是多核系统中对资源访问时锁持有时间极短的情况。这类场景下避免了线程的上下文切换和唤醒开销。常见于某些高性能并发库中,例如:
-
原子类(CAS)操作:
AtomicInteger
等类通过自旋CAS实现无锁的递增、交换等操作。这实际是一种特殊的自旋锁应用,其成功率高时性能优越,但在高竞争下可能频繁重试。 -
自定义低延迟锁:用户也可自己实现自旋锁(如上例)或更复杂的 MCS、CLH(ReentrantLock底层的Node节点) 等队列锁,来满足极低延迟需求。
然而,自旋锁并非万能。如果锁持有时间无法保证足够短,自旋就会导致严重的 CPU 浪费。此时更合适的替代方案包括:
-
互斥锁(Mutex):例如
synchronized
、ReentrantLock
等,它们在高竞争时采用阻塞式等待,避免了 CPU 的忙等浪费。传统互斥锁通过操作系统调度让线程休眠,适合锁定时间较长的场景。不过,互斥锁引入上下文切换的开销,若锁持有时间很短,反而会带来性能损耗。 -
无锁算法:利用 CAS 等原子操作的无锁数据结构和算法,在线程间保证正确性而无需加锁。无锁算法通常并发度高且无阻塞,但实现复杂,且在极端竞争下仍可能引发活锁(线程不断重试)。
-
基于阻塞的等待(park/unpark):Java 中的
LockSupport.park()
/unpark()
机制以及各类Condition
等候队列,是阻塞线程的基础原语。当自旋失败或认为自旋代价过高时,使用阻塞等待可立刻让出 CPU 资源,由内核调度另一个线程;后续可通过unpark
重新唤醒等待线程。这种方式减少了 CPU 占用,但增加了唤醒延迟。
如 AQS通常采用自适应策略:尝试短暂自旋,如果没有成功则改为阻塞,在可行时还会尝试在唤醒前再次自旋,以综合发挥自旋锁和阻塞锁的优势。例如,ReentrantLock
在尝试获取锁时,内部会先进行有限次的自旋;若仍不可得锁,则把线程加入等待队列并挂起。这样的混合策略使得 ReentrantLock
无需使用者过多关注自旋锁的权衡问题,其内部已经根据竞争状态自动选择更优策略。
对比表格
锁方案 | 并发度 | 延迟 | CPU 消耗 | 优点 | 缺点 |
---|---|---|---|---|---|
自旋锁 (SpinLock) | 适中(仅一线程持有锁) | 低(锁空转响应快) | 高(持续忙等占用 CPU) | 避免上下文切换开销 | 锁占用时间长时浪费大量 CPU |
互斥锁 (ReentrantLock/同步) | 高(支持线程排队访问) | 高(可能阻塞后唤醒) | 低(线程睡眠时不占 CPU) | 避免自旋浪费 CPU | 上下文切换开销高、唤醒延时 |
无锁算法 (CAS 无锁) | 高(线程间无阻塞) | 低(纯原子操作,不阻塞) | 中(冲突时可能重试) | 没有上下文切换,延迟低 | 高竞争下反复重试,易造成活锁 |
阻塞等待 (park/unpark) | 高(线程可随并发队列无阻塞) | 高(阻塞挂起后唤醒延迟) | 低(线程睡眠时几乎无 CPU) | 适合长等待,减少 CPU 消耗 | 存在唤醒延迟和线程切换成本 |
注:活锁:活锁指的是任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败
感谢你看到这里,喜欢的可以点点关注哦!