本文目录导读:
- 指数退避(Exponential Backoff)
- 自适应自旋(Adaptive Spinning)
- 先检查再CAS(Read-Check-CAS)
- 时间片让步(Thread.yield() 或 Thread.onSpinWait())
- 混合策略:自旋 + 阻塞(Adaptive Hybrid)
- 不同场景下的最佳实践
CAS(Compare-And-Swap,比较并交换)操作是乐观锁和无锁编程的核心,虽然在自旋等待时,CAS本身非常轻量,但如果自旋次数过多,会带来CPU缓存一致性流量的剧增和CPU流水线的停顿,优化自旋的目标通常是:在保证线程安全的前提下,尽可能少地执行CAS指令,并尽快检测到锁或资源状态的变化。
以下是针对不同场景的几种主流优化策略,从常见到深入依次排列:
指数退避(Exponential Backoff)
这是最简单、最常用的优化,每次CAS失败后,线程不是立即重试,而是等待一段时间,如果连续失败,等待时间指数级增加。
- 原理: 高并发下,多个线程同时争抢一个变量,持续CAS会因“缓存一致性协议”导致所有线程的缓存行失效,产生大量的总线流量,引入退避可以让部分线程暂停,减少争抢,同时让当前持有锁的线程有更多机会执行完临界区。
- 实现方式:
// 伪代码 int retries = 0; int maxRetries = 10; long baseDelay = 1; // 纳秒或微秒级 while (true) { if (CAS(...)) { return; } // 指数退避 + 随机化防止惊群 long delay = baseDelay << Math.min(retries, maxRetries); Thread.sleepNanos(delay + random.nextInt(10)); retries++; } - 适用场景: 资源竞争比较激烈,且临界区执行时间不稳定的场景。
自适应自旋(Adaptive Spinning)
这是JVM(如HotSpot)内部常用的优化策略,它根据上一个持有锁的线程的行为动态调整自旋次数。
- 原理: 如果前一个线程释放锁时,当前线程恰好自旋结束,说明自旋是有效的,虚拟机就会认为这次自旋很可能成功,因此会增加下一次自旋的次数,反之,如果上次自旋没等到锁(比如阻塞了),下次就会减少甚至不进行自旋。
- 实现方式: JVM内部维护了每个锁的“自旋统计信息”(如
_previous_owner_tid),这是JVM层面实现的,开发者无法直接控制,但可以通过JVM参数-XX:PreBlockSpin(旧版)或-XX:UseBiasedLocking(影响偏向锁)来影响。 - 适用场景: 通用场景,JVM默认行为。
先检查再CAS(Read-Check-CAS)
很多情况下,CAS失败是因为目标值根本就没变,或者变得不符合预期,在尝试CAS之前,先读取一次当前值,如果不符合条件就直接跳过CAS。
-
原理: 避免不必要的CAS操作,尤其是当CAS操作涉及内存屏障(Memory Barrier)时,成本较高。
-
实现方式:
// 传统做法 while (true) { if (CAS(&value, expect, update)) { break; } } // 优化版 while (true) { // 先读取当前值 int current = atomicLoad(&value); // 如果当前值不是期望值,说明其他线程已经修改过了 // 此时直接进入下一轮循环,不执行CAS if (current != expect) { // 可以选择暂停一下(yield/pause),或者直接continue Thread.currentThread().yield(); continue; } // 只有当前值确实等于期望值时,才尝试CAS if (CAS(&value, expect, update)) { break; } }注意: 在Java中,
java.util.concurrent.atomic.AtomicInteger的compareAndSet方法内部已经非常高效,通常不需要手动做这种“读-检查”优化,但在C++或更底层的实现中,这可以减少CAS指令执行次数。
时间片让步(Thread.yield() 或 Thread.onSpinWait())
当自旋多次后仍失败,告诉操作系统“我还在跑,但可以先让出CPU”。
- 原理: 让出当前线程剩余的时间片,给其他线程(比如持有锁的线程)执行的机会。
- 实现方式:
- Thread.yield(): Java中,将线程从运行态转换为就绪态,但JVM可能不保证立即生效。
- Thread.onSpinWait() (Java 9+)/ _mm_pause() (x86) / __builtin_ia32_pause() (GCC): 这是硬件级提示,它告诉CPU当前线程正在自旋等待,CPU可以因此优化指令流水线,避免因乱序执行导致的资源浪费,在超线程CPU上效果尤佳。
- 典型模式:
// Java 9+ 推荐的优化自旋模式 int spins = 0; while (true) { if (CAS(...)) { return; } spins++; if (spins > MAX_SPINS) { // 自旋太多,转为阻塞 lock.lock(); return; } // 向CPU发送暂停信号,优化功耗和流水线 Thread.onSpinWait(); }
混合策略:自旋 + 阻塞(Adaptive Hybrid)
这是最扎实的优化,当自旋达到一定次数(例如可能达到CAS指令的消耗上限)后,不再继续空转,而是主动让线程阻塞(进入等待队列),直到锁被释放时再由其他线程唤醒。
- 原理: 自旋适合锁持有时间很短的场景;阻塞适合锁持有时间较长的场景,混合策略能兼顾两者。
- 实现方式(类似Java AQS的实现逻辑):
- 快速路径: 先尝试一次CAS获取锁。
- 自旋路径: 如果失败,进行有限次数的自适应自旋(结合退避和
onSpinWait)。 - 阻塞路径: 如果自旋失败,线程进入等待队列,并调用
LockSupport.park()挂起。 - 唤醒: 持有锁的线程释放锁后,主动唤醒队列中的头节点线程(
LockSupport.unpark())。
- 典型代表:
java.util.concurrent.locks.ReentrantLock的内部实现AbstractQueuedSynchronizer(AQS),它虽然没有在用户态做大量自旋(主要靠内核态的阻塞唤醒),但其tryLock或lockInterruptibly等变体可以支持参数化的自旋。
不同场景下的最佳实践
| 场景 | 推荐优化策略 | 核心原因 |
|---|---|---|
| 超低争用 (几乎无竞争) | 直接CAS | 无竞争时,CAS一步到位,退避反而降低性能。 |
| 低到中等争用 (几个线程短时间抢占) | 有限自旋 + Thread.onSpinWait() |
避免上下文切换,onSpinWait降低功耗。 |
| 高争用 (大量线程长时间抢占) | 指数退避 + 先检查再CAS | 减少总线流量和伪共享。 |
| 极高争用 (锁持有时间较长) | 混合策略(自旋→阻塞) | 自旋是浪费CPU,必须挂起线程,切换至其他任务。 |
| 硬件支持 (x86/ARM) | _mm_pause() 或 yield |
提升超线程效率,优化流水线。 |
一个优化案例(Java的实际应用):
很多人认为AtomicInteger的incrementAndGet()在高并发下性能不佳,这并不完全正确,它慢的原因其实是CAS失败导致的缓存一致性流量,优化方案是使用LongAdder——它通过数组分片(Cell数组)将单点争用分散到多个元素上,每个线程只在自己的槽位上CAS,极大降低了单个自旋次数,这是硬件层面(MESI协议) 和算法层面的终极优化。
标签: 自适应自旋