ReentrantLock 公平锁为什么更慢?
公平锁在存在竞争时倾向于把机会交给等待更久的线程,但“顺序更合理”不等于“系统更快”。在高竞争下,公平锁通常降低吞吐,不过可能改善部分线程长期得不到执行的问题。选择它不是道德判断,而是服务目标的取舍。
公平性的真正含义
new ReentrantLock(false); // 非公平,默认
new ReentrantLock(true); // 公平
公平锁获取时会先检查同步队列中是否已有前驱:
if (!hasQueuedPredecessors()
&& compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
非公平锁则允许刚到达的线程直接 CAS。如果持锁线程刚释放锁,而一个正在 CPU 上运行的新线程恰好到达,它可以立即拿锁;被唤醒的老线程尚需从阻塞态切回运行态。这个“插队”称为 barging。
公平也只是等待队列层面的近似公平:操作系统调度、取消、超时重试以及不带参数的 tryLock() 都可能改变肉眼观察到的顺序。Javadoc 明确指出,无参 tryLock() 即使锁被配置为公平也允许插队;若需要尊重公平设置,可使用带超时的 tryLock(0, TimeUnit.SECONDS),同时正确处理中断。
吞吐差距来自哪里
第一,公平锁多一次队列前驱判断和相关内存访问。第二,也是更重要的一点,它更依赖挂起、唤醒和上下文切换。非公平锁可能让已经运行的线程完成下一段临界区,缓存仍然温热;公平锁倾向于把机会交给刚唤醒的线程,调度成本更高。
假设临界区只有计数自增,业务工作远小于线程切换成本,差异会非常明显。若临界区本身执行 20ms I/O,锁策略带来的相对差距可能被业务耗时淹没,但此时真正的问题通常是“不应持锁做 I/O”。
用 JMH 正确比较
@State(Scope.Group)
public class LockBench {
@Param({"true", "false"}) boolean fair;
Lock lock;
long value;
@Setup public void setup() { lock = new ReentrantLock(fair); }
@Group("inc") @GroupThreads(8)
@Benchmark public long increment() {
lock.lock();
try { return ++value; }
finally { lock.unlock(); }
}
}
建议使用多次 fork,固定 JDK、CPU 配额和线程数,同时报告 ops/s 与采样延迟。不要在普通 main 中用 currentTimeMillis() 跑一次就下结论;JIT 预热、死代码消除和系统噪声足以扭曲结果。
实验至少覆盖:1、2、8、32 个竞争线程;极短和中等长度临界区;吞吐、P50、P99 以及每个线程成功次数的分布。公平性应通过方差或最大等待时间衡量,而不是只看控制台打印顺序。
公平锁并不自动解决饥饿
如果线程拿到锁后长时间运行,或任务在线程池队列里就得不到调度,公平锁无能为力。反过来,非公平锁也不意味着必然饥饿:AQS 仍维护等待队列,只是允许特定时机插队。
真正要求严格先来先服务时,还要定义取消、超时、优先级和重试是否保留原排位。大多数业务锁并不需要如此强的语义。
如何选择
优先使用默认非公平锁,尤其是短临界区、高吞吐服务。只有在压测或生产指标证明存在不可接受的等待偏斜,而且业务确实更重视等待上界而非总吞吐时,才考虑公平锁。还可以从源头缩小临界区、锁分段、不可变数据或消息串行化入手。
生产检查清单
- 选择公平锁的业务 SLA 是什么?
- 是否同时测量吞吐和 P99,而非只看平均值?
- 临界区是否包含 RPC、数据库或文件操作?
- 线程数是否远高于 CPU 核数,导致调度抖动?
- 是否能通过拆锁、读写分离或降低共享状态解决?
- 是否误以为公平锁能修复线程池饥饿?
公平锁购买的是更可预测的排队机会,支付的是更频繁的调度和更少的就地复用。最终答案必须来自与真实负载接近的实验。