自旋锁与自适应自旋:等待的艺术

咱们来聊聊锁。提到锁,很多人第一反应就是让线程睡觉——也就是阻塞。但有时候,让线程睡一觉的代价,比让它站着等一会儿还大。这就是自旋锁的出发点。

我早年在调一个高并发服务时,就遇到过这种情况。锁的持有时间极短,但线程切换的耗时占了大头。性能上不去,CPU倒是忙得团团转。后来一查,问题就出在无谓的上下文切换上。今天咱们就把这块彻底讲透。

为什么需要自旋?

先说个直觉。线程被阻塞后,操作系统要把它挂起,保存上下文。等锁释放了,再把它唤醒,恢复上下文。这个“挂起+唤醒”的操作,代价可不小。

你想想看,一个锁的持有时间如果是10微秒。而线程切换的成本是20微秒。那就亏大了。等一个锁,还不如让线程在那里“空转”一会儿,等锁释放。

核心矛盾:自旋浪费CPU时间片,阻塞浪费上下文切换开销。
到底选哪个?就看锁的持有时间。

自旋锁的思路很简单:线程不放弃CPU,就在那里循环检查锁状态。用CPU时间换上下文切换。

自旋等待的利弊

自旋不是银弹。有几个场景得拎清楚:

  • 锁持有时间短:自旋划算,省去了切换开销。
  • 锁持有时间长:阻塞划算。自旋只会白白烧CPU。
  • CPU核心多:自旋更友好。反正CPU闲着也是闲着。
  • CPU核心少:自旋反而拖累其他线程。一个线程空转,其他活都干不了。

我个人习惯,在锁竞争不激烈、锁粒度极小的场景下,优先考虑自旋。比如一些状态标志位的更新。

自适应自旋:动态决策

静态的自旋次数是固定的。设定10次,每次都转10圈。但锁的持有时间会变,系统负载也会变。怎么办?
JVM给出的答案是:自适应自旋。

说白了,就是让JVM根据历史记录,动态调整自旋次数。如果一个锁之前自旋成功过,那就多转几圈。如果之前自旋都失败了,那就早点放弃,直接阻塞。

在JDK 1.6之后,synchronized就引入了自适应自旋。这也是它性能大幅提升的关键原因之一。

JVM中synchronized的演进历程

synchronized早期被吐槽为“重量级锁”。背后的原因就是它直接依赖操作系统的互斥量(Mutex Lock)。每次加锁解锁都要切换到内核态,成本极高。

但后来JDK团队做了大量优化。咱们用一张表看看这个演进过程:

JDK版本 锁状态 核心机制 适用场景
1.6之前 重量级锁 直接使用OS互斥量 所有场景(性能差)
1.6 偏向锁 第一次获取锁时,记录线程ID 只有一个线程反复获取同一把锁
1.6 轻量级锁 CAS操作,自旋等待 少量线程竞争,锁持有时间短
1.6+ 自适应自旋 根据历史成功率动态调整自旋次数 竞争不确定,但锁粒度小
1.6+ 重量级锁 升级回OS互斥量,线程阻塞 多线程激烈竞争,锁持有时间长

从这张表能看出来,synchronized已经不是一个“一刀切”的锁了。它有一套完整的升级路径:
偏向锁 → 轻量级锁(自旋) → 重量级锁(阻塞)

这套路径,本质上就是在不断权衡“自旋”和“阻塞”的成本。

自旋锁的实现示例

了解原理后,咱们看看代码。下面是一个简单的自旋锁实现:

public class SimpleSpinLock {
    private AtomicBoolean locked = new AtomicBoolean(false);

    public void lock() {
        // 自旋等待,直到CAS成功
        while (!locked.compareAndSet(false, true)) {
            // 空循环,消耗CPU时间片
        }
    }

    public void unlock() {
        locked.set(false);
    }
}

这个实现太粗糙。实际生产中,不能这么干。你想,如果一个线程自旋了100万次还没拿到锁,CPU就全浪费了。
所以,需要设置一个自旋上限。

下面是一个带超时和让步的优化版:

public class BackoffSpinLock {
    private AtomicBoolean locked = new AtomicBoolean(false);
    private static final int MAX_SPIN = 100;

    public void lock() {
        int spinCount = 0;
        while (!locked.compareAndSet(false, true)) {
            if (spinCount < MAX_SPIN) {
                spinCount++;
                // 让出CPU时间片,给其他线程
                Thread.yield();
            } else {
                // 自旋超时,阻塞等待
                synchronized (this) {
                    try {
                        wait();
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                }
                spinCount = 0;
            }
        }
    }

    public void unlock() {
        locked.set(false);
        synchronized (this) {
            notify();
        }
    }
}

这个版本就更实用了。自旋有限度,超时了就走阻塞。这也是自适应自旋的精髓——不能无脑干等。

注意:Thread.yield() 只是一个建议,操作系统不一定会执行。在高并发下,它可能毫无效果。更可靠的做法是使用 LockSupport.parkNanos() 做微秒级的休眠。

自旋锁与自适应自旋决策流程 自旋锁与自适应自旋决策流程 线程尝试获取锁 锁是否已被持有? 获得锁,执行临界区 进入自旋等待 自旋次数是否超限? CAS重试 升级为重量级锁,阻塞 记录成功/失败,调整下次策略 自适应反馈 自适应自旋:JVM根据上次自旋成功率,动态调整本次自旋次数上限

避坑指南

我曾经掉进过一个坑。在某个中间件里,我把一个短锁改成了自旋锁。本意是减少上下文切换。结果压力上来后,CPU直接飙到100%。几个核心都在空转,业务线程拿不到时间片。

原因就是锁冲突太频繁。所有线程都在自旋,谁也不让谁。这就得像春运抢票,大家都在刷页面,谁也抢不到。

所以,有几点忠告:

  • 单核CPU禁用自旋:只有一个核,自旋等于锁死系统。
  • 锁冲突高时别用自旋:超过2个线程频繁竞争,自旋只会火上浇油。
  • 自适应自旋不是万能的:它的学习需要时间。短期突发流量下,学习还没完成,系统可能已经挂了。

我的建议是:如果系统负载可以预估,手动设置自旋次数比自适应更可靠。自适应适合波动型负载,手动设置适合稳态型负载。

最后总结一句:
自旋锁和阻塞锁,没有谁更高贵。只有谁更合适。理解了上下文切换和自旋的代价,你就能做出正确的取舍。