锁的代价:上下文切换、缓存行失效、线程阻塞
说实话,很多做交易系统的朋友,一开始都不太把锁当回事。
「不就是加个 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 就够你喝一壶的。
四、线程阻塞:线程在干等
线程拿不到锁,怎么办?
有两种策略:
- 自旋等待 —— 线程不挂起,一直在循环检查锁状态
- 阻塞挂起 —— 线程主动让出 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 倍。
一句话总结:锁本身不贵,贵的是竞争。竞争导致上下文切换和缓存行失效,这才是真正的代价。
六、如何衡量锁的代价?
我建议用两个指标:
- 锁竞争率 —— 尝试获取锁但失败的次数 / 总尝试次数
- 锁持有时间 —— 线程持有锁的平均时长
如果竞争率超过 5%,或者持有时间超过 10 微秒,就要警惕了。
怎么监控?
Linux 下可以用 perf stat -e cache-misses,context-switches。Java 下可以用 JFR(Java Flight Recorder)或者 JMX 的锁信息。
个人经验:我一般会在压测时,同时监控上下文切换速率和缓存缺失率。如果上下文切换超过每秒 10,000 次,或者缓存缺失率超过 5%,基本可以断定锁竞争是瓶颈。
七、一张图看懂锁的代价
下面这张图,把锁竞争的完整链路画出来了。从无锁到有竞争,每一步的代价都标了出来。
这张图想表达什么?
锁竞争的代价不是线性的。从无锁到自旋,代价涨了 10 倍。从自旋到阻塞,代价再涨 100 倍。所以,消除锁竞争的关键,就是避免走到「阻塞」这一步。
八、实战建议
说了这么多,到底怎么干?
- 能不用锁就不用锁 —— 用 CAS、用无锁数据结构、用 ThreadLocal
- 必须用锁时,缩小临界区 —— 只锁必要的代码,别锁一大片
- 减少锁持有时间 —— 不要在锁里做 IO、网络请求、数据库操作
- 避免伪共享 —— 用 @Contended 注解或者缓存行填充
- 监控锁竞争 —— 用 perf、JFR、async-profiler 等工具
最后提醒一句:不要过早优化。先写对,再写快。如果锁竞争不是瓶颈,你花一周去优化它,就是白费功夫。先压测,找到热点,再动手。
好了,关于锁的代价,今天就聊到这里。记住一句话:锁不贵,竞争贵。下次加锁前,先想想能不能不加。
公众号:蓝海资料掘金营,微信deep3321