3. 锁竞争的本质与分类
锁竞争的本质,不是“锁”本身慢,而是多个执行单元在同一时刻对同一份共享状态产生了排他性访问需求,导致其中一部分线程无法继续推进。真正决定性能的,是等待方式、唤醒代价、临界区长度、访问模式这四个变量。理解这一点后,各种锁的差异就不再是名词记忆,而是可推导的行为差异。
先看一个典型场景:8 个线程对一个计数器执行 100 万次自增。若用互斥锁保护,临界区只有几条指令,但线程在拿不到锁时会被挂起,进入内核态等待,唤醒时又要重新调度。此时锁竞争的开销远大于临界区本身。若换成自旋锁,线程在用户态反复检查锁标志,避免了上下文切换,但会持续占用 CPU。若临界区稍长,自旋反而浪费更多算力。这说明:锁的选择必须与临界区长度和竞争强度匹配。
从竞争行为看,锁可以分为几个正交维度。第一维是悲观与乐观:悲观锁假设冲突一定会发生,访问前先加锁;乐观锁假设冲突较少,先修改再校验版本或 CAS,失败则重试。第二维是阻塞与自旋:阻塞锁让等待线程让出 CPU,自旋锁让等待线程忙等。第三维是共享与排他:互斥锁同一时刻只允许一个持有者,读写锁允许多个读者并存、写者独占。第四维是锁膨胀层次:偏向锁、轻量级锁、重量级锁描述的是同一把对象锁在不同竞争程度下的状态迁移。
上图的左侧与中间是“选择维度”,右侧是“访问模式维度”,底部是“同一把锁的膨胀路径”。它们共同回答一个问题:当前竞争形态下,哪种等待策略代价最低。
具体到工程判断,可以按以下顺序做决策。第一步,判断访问模式:如果读操作远多于写操作,优先考虑读写锁;如果读写比例接近,互斥锁往往更简单且不易出现写者饥饿。第二步,判断临界区长度:若临界区只有几条指令,自旋锁或轻量级锁更合适;若临界区包含 I/O、复杂计算或可能阻塞的调用,必须使用阻塞锁,否则自旋会白白烧掉 CPU。第三步,判断冲突概率:低冲突场景可用乐观锁(CAS + 重试),高冲突场景应改用悲观锁,避免大量重试导致 CPU 空转。第四步,观察 JVM 锁状态:通过 -XX:+PrintFlagsFinal 确认偏向锁是否启用,通过 JFR 或 jstack 观察线程是否频繁处于 BLOCKED 状态。
下面给出一个可执行的判断表,用于在代码评审或压测后快速定位锁选型问题。表中参数为示例阈值,实际应结合压测数据调整。
| 观察项 | 典型表现 | 推荐锁类型 | 调整方向 |
|---|---|---|---|
| 读:写 > 10:1 | 读线程大量等待 | 读写锁 | 检查写者饥饿,必要时公平模式 |
| 临界区 < 1μs | 上下文切换占比高 | 自旋锁 / 轻量级锁 | 减少阻塞唤醒,控制自旋次数 |
| CAS 失败率 > 30% | 重试次数飙升 | 悲观锁 | 缩小临界区或分段锁 |
| 同一线程反复进入 | 无实际竞争 | 偏向锁 | 确认偏向锁启用,避免过早撤销 |
| BLOCKED 线程 > 5 | 内核态切换频繁 | 重量级锁已生效 | 降低锁粒度或改用无锁结构 |
需要特别注意的是,偏向锁、轻量级锁、重量级锁并不是三把不同的锁,而是 HotSpot 对同一把对象锁在不同竞争程度下的优化状态。偏向锁在无竞争时几乎零开销;一旦出现第二个线程尝试获取,偏向锁撤销,升级为轻量级锁;轻量级锁通过 CAS 和自旋尝试解决短时竞争;若自旋失败或竞争加剧,则膨胀为重量级锁,线程进入内核等待队列。这个迁移过程是单向的(在传统实现中),因此锁一旦膨胀,就很难回到轻量状态。这也是为什么在高并发服务中,要尽量避免让锁经历“先无竞争、后突然高竞争”的剧烈变化。
jstack 的线程状态分布和 perf 的 CPU 火焰图。若 BLOCKED 占比高,说明阻塞锁竞争严重;若 RUNNABLE 中大量出现在 CAS 重试路径,说明乐观锁冲突过高。两者结合,才能判断是该换锁类型,还是该缩小临界区。
最后回到本质:锁竞争的性能差异,最终都落在“等待的线程在做什么”上。阻塞锁让线程睡觉,自旋锁让线程空转,乐观锁让线程重试,读写锁让读线程并行。选择哪一种,不取决于名词是否高级,而取决于当前临界区的长度、冲突概率和访问比例。把这三个量测准,锁的选型就不再是猜测。