bigywater.tech
Java 内部原理 · 07

ReentrantLock:公平锁和非公平锁,真实差了 58 倍耗时

new ReentrantLock() 默认是非公平的,传 true 才是公平锁——但"公平"具体公平在哪、要付出多大代价,真实起线程抢锁量出来,比记一句"公平锁更慢"实在得多。

2346 次 → 134 次
4 线程各抢锁 2 万次,同一线程连续拿到锁的最长次数:非公平锁 vs 公平锁
7ms → 411ms
同样的抢锁负载,非公平锁真实只用 7ms 跑完,公平锁真实用了 411ms,慢了 58 倍

上一篇:ConcurrentHashMap:多线程 put 为什么不会锁住整张表

真实验证:同一个线程能连续插队多少次

4 个线程各抢锁 2 万次,统计"同一个线程连续拿到锁"这件事最多发生了多少次。

如果锁是绝对公平的,理论上不会有任何一个线程连续两次拿到锁而不轮到别人——每次释放都该轮到排在队首的下一个线程。真实统计一下同一线程连续拿锁的最长streak:

核心逻辑 for (int i = 0; i < ITER; i++) { lock.lock(); try { // 记录:这次拿到锁的线程,是不是跟上一次同一个线程 // 是就把连续计数 +1,不是就重置成 1,全程跟踪最大值 } finally { lock.unlock(); } }
真实实测 · 4 线程,每线程抢锁 2 万次
=== 非公平锁(默认) ===
耗时: 7ms
同一个线程连续拿到锁的最长连续次数: 2346

=== 公平锁 ===
耗时: 411ms
同一个线程连续拿到锁的最长连续次数: 134
非公平锁:同一个线程曾经连续 2346 次抢到锁,其它 3 个线程这段时间里完全没插上手 公平锁:最长连续也只有 134 次,插队严重程度差了一个数量级

再独立跑一遍,确认不是偶然:

真实实测(第二次运行)
非公平锁: 耗时 7ms,最长连续 2153 次
公平锁  : 耗时 244ms,最长连续 15 次

两次运行,非公平锁的耗时稳定在 7ms 左右,公平锁的耗时在 244~411ms 之间波动——具体倍数会变,但"非公平锁明显更快、连续插队次数明显更高"这个趋势两次完全一致。有意思的是,两种锁最终四个线程拿到锁的总次数都精确是 20000 次一个不少——公平性影响的是"谁先拿到",不影响"最终拿到的次数是不是准确",这本身也是锁本该保证的基本正确性。

为什么公平锁慢这么多

非公平锁在锁被释放的瞬间,谁都可以直接尝试抢——包括刚刚释放锁、还没来得及被系统真正挂起的那个线程自己。如果它反应够快,经常能在其它排队线程被操作系统真正唤醒调度之前,直接原地再次抢到锁,完全跳过"把一个线程从阻塞状态唤醒、参与调度、切换上下文"这一整套昂贵的操作。这也是为什么它的耗时能做到公平锁的几十分之一——本质是省掉了大量线程唤醒和上下文切换的开销。

公平锁则严格规定:只要队列里还有比你等得更久的线程,你就必须排到它后面,哪怕你人在锁旁边、它还在系统调度队列里没醒。这意味着几乎每一次释放锁,都要真的去唤醒下一个排队线程、等它被调度器选中、完成上下文切换——这一整套流程的代价,远比"运气好直接抢到手"要高得多。

那是不是应该永远用非公平锁?不是。非公平锁的代价是可能让某个线程长期抢不到锁(极端情况下的"饥饿"),真实实验里那个连续 2346 次的线程存在期间,其它 3 个线程完全被晾在一边。大多数业务场景没那么在意"谁先谁后",默认的非公平锁性能好得多,是合理的默认值;但如果场景里确实要保证请求按到达顺序公平处理(比如某种排队系统),公平锁的这份代价才值得付。

分步演示:ReentrantLock 的持有计数是怎么真实一步步变化的

每一行都是真实运行这段代码得到的结果。

ReentrantLock 真实靠持有计数(hold count)实现可重入;加锁解锁次数必须真实对齐,多解锁会真实抛异常
这一步新验证的状态

    参考与说明

    • 本文全部耗时和连续拿锁次数均在本机真实运行 Java 17、4 线程并发得到,跑了两次独立确认趋势一致;具体倍数会因机器负载波动,但"非公平锁明显更快"这个结论是稳定的。
    • 另一种"看起来安全实则有隐患"的并发容器行为,参见上一篇ConcurrentHashMap。
    • 没有涉及:AbstractQueuedSynchronizer(AQS)的内部队列实现细节、synchronized 关键字本身有没有公平性选项(没有,它从设计上就是非公平的)——这些留给系列后面按需再写。
    京ICP备2026067502号-1京公网安备11010502063487号