锁的代价:上下文切换、缓存行失效、线程阻塞

说实话,很多做交易系统的朋友,一开始都不太把锁当回事。

「不就是加个 synchronized 嘛,能慢到哪去?」

我以前也这么想。直到有一次,我们的订单处理延迟从 50 微秒飙到了 5 毫秒。整整 100 倍的差距。查了三天,最后发现罪魁祸首就是一把看起来人畜无害的锁。

嗯,今天我们就来扒一扒,一把锁到底有多贵。

一、锁的三大开销

我个人习惯把锁的代价拆成三块来看:

  • 上下文切换 —— 操作系统在忙活
  • 缓存行失效 —— CPU 缓存白干了
  • 线程阻塞 —— 线程在干等

这三块加在一起,就是一把锁的真实成本。咱们一个一个说。

二、上下文切换:操作系统在忙活

你想想看,当一个线程拿不到锁时,它会被挂起。操作系统要把这个线程的上下文保存起来,然后切换到另一个线程去执行。

这个过程叫什么?叫上下文切换。

一次上下文切换大概要花多少时间?

操作 耗时(纳秒) 对比
CPU 执行一条指令 ~0.5 ns 基准
L1 缓存访问 ~1 ns 2 倍
一次上下文切换 ~10,000 ns 20,000 倍
一次锁竞争(含切换) ~50,000 ns 100,000 倍

看到了吗?一次上下文切换,相当于两万条指令的时间。我在项目中遇到过,一个高频的锁竞争热点,每秒能触发几万次上下文切换。CPU 全在忙活切换,真正干活的时间少得可怜。

核心结论:上下文切换是锁竞争中最贵的开销。一次切换 ≈ 20,000 条指令。

三、缓存行失效:CPU 缓存白干了

这个很多人会忽略。但我觉得,它有时候比上下文切换更坑。

现代 CPU 有多级缓存。L1 缓存访问只要 1 纳秒,主存访问要 100 纳秒。差了 100 倍。

锁操作会干什么?它会修改一个共享变量。这个变量一旦被修改,所有持有该缓存行的 CPU 核心,都必须把对应的缓存行标记为失效。

为什么会这样?

因为 CPU 要保证缓存一致性。MESI 协议听过吧?一个核心写,其他核心的缓存行全部失效。下次访问,只能从主存重新加载。

这叫「缓存行 bouncing」。说白了,就是缓存行在多个核心之间来回弹跳,谁也落不下好。

避坑指南:我曾经在一个多核服务器上跑交易引擎,发现锁竞争不激烈,但延迟就是降不下来。后来用 perf 工具一看,缓存行失效事件高得离谱。原因是什么?两个线程虽然不常抢锁,但它们操作的对象在同一个缓存行上。这就是「伪共享」。

伪共享的代价有多大?

  • 一次缓存行失效:~100 ns(从主存重载)
  • 如果频繁发生:性能下降 10 倍以上

嗯,这里要注意。锁操作本身就会导致缓存行失效。即使没有上下文切换,光是缓存 bouncing 就够你喝一壶的。

四、线程阻塞:线程在干等

线程拿不到锁,怎么办?

有两种策略:

  1. 自旋等待 —— 线程不挂起,一直在循环检查锁状态
  2. 阻塞挂起 —— 线程主动让出 CPU,进入等待队列

自旋等待的好处是避免了上下文切换。坏处是浪费 CPU 时间片。如果锁很快释放,自旋是划算的。如果锁持有时间较长,自旋就是白费电。

阻塞挂起的好处是不浪费 CPU。坏处是上下文切换成本高。

Java 的 synchronized 是怎么做的?

它有个「锁升级」机制:

  • 无锁 → 偏向锁 → 轻量级锁(自旋) → 重量级锁(阻塞)

一开始是偏向锁,只有一个线程访问。有竞争时升级为轻量级锁,用 CAS 自旋。自旋超过一定次数(默认 10 次),升级为重量级锁,线程开始阻塞。

注意:锁升级本身也有开销。每次升级都是一次 CAS 操作,加上内存屏障。如果锁竞争是间歇性的,频繁的锁升级反而会拖慢性能。

五、一个锁操作到底有多贵?

我们来算一笔账。

假设一个锁操作,没有竞争:

  • CAS 操作:~10 ns
  • 内存屏障:~5 ns
  • 合计:~15 ns

有竞争,但自旋成功:

  • CAS 自旋几次:~50-100 ns
  • 缓存行失效:~100 ns
  • 合计:~150-200 ns

有竞争,需要上下文切换:

  • 上下文切换:~10,000 ns
  • 缓存行失效:~100 ns
  • 线程调度延迟:~5,000 ns
  • 合计:~15,000-20,000 ns

看到了吗?

无竞争时,一把锁只要 15 纳秒。有竞争且需要切换时,要 20 微秒。差了 1000 倍。

一句话总结:锁本身不贵,贵的是竞争。竞争导致上下文切换和缓存行失效,这才是真正的代价。

六、如何衡量锁的代价?

我建议用两个指标:

  1. 锁竞争率 —— 尝试获取锁但失败的次数 / 总尝试次数
  2. 锁持有时间 —— 线程持有锁的平均时长

如果竞争率超过 5%,或者持有时间超过 10 微秒,就要警惕了。

怎么监控?

Linux 下可以用 perf stat -e cache-misses,context-switches。Java 下可以用 JFR(Java Flight Recorder)或者 JMX 的锁信息。

个人经验:我一般会在压测时,同时监控上下文切换速率和缓存缺失率。如果上下文切换超过每秒 10,000 次,或者缓存缺失率超过 5%,基本可以断定锁竞争是瓶颈。

七、一张图看懂锁的代价

下面这张图,把锁竞争的完整链路画出来了。从无锁到有竞争,每一步的代价都标了出来。

锁竞争代价链路图 无锁操作 ~15 ns CAS + 内存屏障 锁竞争发生 ~100 ns 缓存行失效 自旋成功 自旋失败 自旋等待 上下文切换 ~200 ns ~20,000 ns 性能下降 10-1000倍 无锁 → 15 ns | 自旋竞争 → 200 ns | 阻塞竞争 → 20,000 ns

这张图想表达什么?

锁竞争的代价不是线性的。从无锁到自旋,代价涨了 10 倍。从自旋到阻塞,代价再涨 100 倍。所以,消除锁竞争的关键,就是避免走到「阻塞」这一步。

八、实战建议

说了这么多,到底怎么干?

  1. 能不用锁就不用锁 —— 用 CAS、用无锁数据结构、用 ThreadLocal
  2. 必须用锁时,缩小临界区 —— 只锁必要的代码,别锁一大片
  3. 减少锁持有时间 —— 不要在锁里做 IO、网络请求、数据库操作
  4. 避免伪共享 —— 用 @Contended 注解或者缓存行填充
  5. 监控锁竞争 —— 用 perf、JFR、async-profiler 等工具

最后提醒一句:不要过早优化。先写对,再写快。如果锁竞争不是瓶颈,你花一周去优化它,就是白费功夫。先压测,找到热点,再动手。

好了,关于锁的代价,今天就聊到这里。记住一句话:锁不贵,竞争贵。下次加锁前,先想想能不能不加。


公众号:蓝海资料掘金营,微信deep3321