跳过导航

ReentrantLock 公平锁为什么更慢?

约 4 分钟...次浏览
专栏Java 并发编程第 2 篇

公平锁在存在竞争时倾向于把机会交给等待更久的线程,但“顺序更合理”不等于“系统更快”。在高竞争下,公平锁通常降低吞吐,不过可能改善部分线程长期得不到执行的问题。选择它不是道德判断,而是服务目标的取舍。

公平性的真正含义

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 核数,导致调度抖动?
  • 是否能通过拆锁、读写分离或降低共享状态解决?
  • 是否误以为公平锁能修复线程池饥饿?

公平锁购买的是更可预测的排队机会,支付的是更频繁的调度和更少的就地复用。最终答案必须来自与真实负载接近的实验。

分享:
文章作者:狼码纪
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。文章可能参考了其他优秀文章,如有侵权请联系删除。