Java公平锁与非公平锁:从ReentrantLock到AQS的底层原理与选型实践
还有多少人一听到“公平锁”就以为JVM在底层做了什么大动作实际上Java里的公平锁和非公平锁本质上是ReentrantLock在AbstractQueuedSynchronizerAQS里的两种获取锁策略。公平并不是系统调度器给的而是AQS的等待队列帮你排出来的非公平也没有操作系统层面的“不公平”它只是允许后来线程插队抢一次。这篇文章不打算贴整段源码吓人而是把公平锁和非公平锁的底层逻辑、吞吐量差异、饥饿风险讲透再给出实际项目中怎么选型、怎么验证、怎么避坑的完整思路。适合正在看并发编程源码、准备面试、或者接手高并发模块想压榨性能的Java开发者。看完你可以自己写一段压测程序复现公平/非公平的差异也能在技术评审时把“为什么这里用非公平锁”这件事讲得有理有据。1. 公平锁和非公平锁先搞清楚它们在争什么先明确一个最容易混淆的点这里讨论的“锁”不是synchronized也不是JVM内置锁。内置锁的偏向锁、轻量级锁、重量级锁有另一套升级路径和ReentrantLock的公平策略完全是两码事。我们日常说“Java公平锁”绝大多数场景指的是java.util.concurrent.locks.ReentrantLock里的FairSync和NonfairSync这两个同步器实现。为什么需要这个区分因为多线程环境下多个线程同时执行lock()时锁资源只能给其中一个线程。这个选择过程就是“锁分配策略”。公平策略要求谁先发起请求谁先获得锁。非公平策略则允许新来的线程和已经在等待队列里排队的线程一起竞争甚至新线程可能直接抢到锁而不去排队。这里有个哲学问题非公平为什么能存在因为“公平”是有成本的。每次lock()都要严格判断“你是不是排队最久的人”这意味着要做一次等待队列状态检查。在高并发、锁持有时间很短的场景真正抢到锁的线程很可能刚释放锁下一个线程就来了此时如果强制它去队尾排队反而打断了CPU缓存连续性多一次上下文切换开销。1.1 从一段最简单的代码体验区别先写一个最直观的可运行Demo不需要任何框架一个main方法就行。我们创建两个线程都用同一个ReentrantLock去锁住一段耗时极短的临界区然后交替打印线程名称。import java.util.concurrent.locks.ReentrantLock; public class LockFairnessDemo { public static void main(String[] args) { ReentrantLock fairLock new ReentrantLock(true); Runnable task () - { while (true) { fairLock.lock(); try { System.out.println(Thread.currentThread().getName() 拿到锁); Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } finally { fairLock.unlock(); } } }; Thread t1 new Thread(task, 线程A); Thread t2 new Thread(task, 线程B); t1.start(); t2.start(); } }如果你运行这段代码会看到输出基本是“线程A、线程B、线程A、线程B”交替出现很少出现一个线程连续抢到两次。但如果把new ReentrantLock(true)换成new ReentrantLock(false)或者默认构造器你很可能看到“线程A连续输出好几次线程B偶尔才拿到一次”。这就是公平策略最直观的效果不插队按排队顺序来。1.2 公平锁是“绝对公平”吗注意公平锁的公平性只保证“等待队列中处于头部的线程优先获取锁”。它不保证你已经调用了lock()就一定能比另一个刚调用lock()的线程先拿到锁。因为从你调用lock()到真正进入等待队列中间有一段路径新线程可能在你进入队列之前就抢到了锁。另外允许存在“插队失败”的尝试比如某个线程持有锁时另一个线程反复尝试获取锁但失败最终只能进队列排队。所以精确说法是公平锁保证的是“排队秩序的公平”而不是“时间点上的完全公平”。这个点面试时特别容易被追问。很多人背了概念但理解不透既然公平锁是按顺序来的为什么还会出现后调用的线程先拿到锁原因是AQS的入队动作不是原子的。一个线程执行lock()时如果锁没有被持有它可以直接CASCompare And Swap把锁状态从0改成1这一步在入队之前完成。如果此时另一个线程正在排队这个新线程仍然有机会直接拿到锁。公平模式下hasQueuedPredecessors()会拦截这种插队但它只能拦截“已经发现队列非空”的情况无法拦截极端时间窗口比如入队动作尚未完成。2. 从ReentrantLock源码看公平锁的两行关键代码要理解公平锁和非公平锁的本质区别绕不开ReentrantLock源码。但我不打算把AQS几百行代码全抄一遍只挑核心差异点。ReentrantLock内部有两个静态内部类FairSync和NonfairSync都继承自AbstractQueuedSynchronizer。关键的tryAcquire方法两个类都有各自实现。非公平锁的tryAcquire逻辑可以简化成这样先通过getState()读取锁状态。如果锁状态是0没人持有直接执行compareAndSetState(0, acquires)尝试抢锁。如果抢成功设置exclusiveOwnerThread为当前线程。如果锁状态不是0但exclusiveOwnerThread是当前线程说明可重入状态值增加。这个逻辑最大的特点是它没有检查等待队列里有没有人排队。只要锁是空闲的先跑进来的线程直接CAS成败在此一举。失败的话再走完整流程进入同步队列等待唤醒。公平锁的tryAcquire逻辑只比非公平版本多一个前置判断protected final boolean tryAcquire(int acquires) { final Thread current Thread.currentThread(); int c getState(); if (c 0) { if (!hasQueuedPredecessors() compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } // 可重入逻辑与非公平锁一致 return false; }唯一的差别就是!hasQueuedPredecessors()这个判断。这行代码做了两件事检查同步队列是否为空检查队列头部的等待线程是否就是当前线程。如果队列非空且当前线程不是排在首位的那个公平锁会放弃此次抢锁老老实实为自己构建一个队尾节点进入休眠等待被唤醒。2.1 hasQueuedPredecessors为什么这么设计hasQueuedPredecessors()的实现我没法绕开因为这句源码是理解公平锁的钥匙。它的逻辑可以概括为如果head.next null说明队列为空或者正在初始化此时如果head tail说明队列确实为空没有排队线程返回false允许抢锁。如果head.next ! null说明已经有人排队再判断head.next.thread ! Thread.currentThread()如果队首第一个等待线程不是当前线程说明自己不排队、有人排在前面返回true禁止抢锁。为什么需要这么严谨因为AQS的入队过程是懒初始化的head和tail可能同时为null。如果只判断head tail在并发入队的瞬间可能出现误判一个线程刚刚把tail更新为它的节点但head.next还没来得及设置另一个线程恰好执行检查会以为队列为空而抢锁破坏公平性。所以必须同时判断tail和head.next才能确保队列状态真正稳定。这里顺带说一个很多初学者踩过的坑不要自己去写判断“当前线程有没有前驱节点”的逻辑。JDK已经通过hasQueuedPredecessors()封装好了任何自定义AQS子类想实现公平特性都应该复用或模仿这个判断逻辑。你自己在tryAcquire里加上“如果getState()0就CAS”这种判断造出来的不是公平锁只是带概率公平的乐观锁。2.2 可重入机制在公平锁里表现如何可重入性是ReentrantLock的重要特性。公平锁和非公平锁在可重入的逻辑上是完全一致的都是判断exclusiveOwnerThread Thread.currentThread()。如果当前线程已经持有锁再次获取时状态计数加一不需要经过等待队列。但在公平模式下可重入会带来一个隐蔽的性能问题假如线程A持有锁并且在持锁期间多次调用lock()比如一个递归函数每次可重入成功都会让状态计数增加。这个过程中不会检查等待队列所以公平锁并不会因为可重入次数多而频繁放行队列里的其他线程。真正的锁释放发生在最外层unlock()把计数减到0时才可能唤醒队首线程。我在实际项目里见过一个场景一个定时任务线程使用公平锁递归处理树形结构锁内嵌套调用了上百次lock()。这时候锁计数累加到几百每次释放一层最后才让其他线程获得锁。其他线程等待的时间会被人为拉长。表面上这是公平锁“严格按顺序”的代价实际上是可重入特性叠加后把持锁时间放大了。排查下来解决方案是尽量把递归逻辑拆到锁外面或者改用非公平锁降低等待放大效应。3. 非公平锁真正强在哪儿吞吐、切换与“概率公平”很多人有个直觉非公平锁就是“不排队随便抢”所以它必定会导致线程饿死。实际上没有这么绝对。非公平锁的“不公平”只体现在唤醒竞争阶段一旦一个线程进入AQS同步队列睡眠它醒来后会按照parkAndCheckInterrupt()的唤醒机制走不被后续新线程反复插队。真正可能反复吃亏的是那些还没进入队列的“边缘线程”。3.1 为什么非公平锁吞吐普遍更高非公平锁的吞吐优势核心在于减少线程上下文切换。想象一个场景线程A持有锁正在执行一段很短的临界区代码。执行完后unlock()此时线程B刚好因为锁竞争失败而进入睡眠线程C刚刚到达lock()入口。如果使用非公平锁线程C可能直接CAS抢锁成功而线程B继续沉睡。线程C执行完临界区后锁再次释放线程B才终于被唤醒。这个过程中线程B经历了一次睡眠、一次唤醒、一次排队但并没有真的获取到锁。从整体时间线来看线程C的插入没有增加多少额外开销因为它本来就要运行只是把它的运行时间提前了。而线程B的唤醒虽然延迟了但它没有被真正唤醒两次所以系统调用、内核态切换反而更少。如果使用公平锁线程C必须进入队尾等待线程B被唤醒后先抢到锁。问题在于唤醒线程B需要一次LockSupport.unpark()这个操作涉及内核态调度而线程B被唤醒后可能因为CPU核的亲和性不同以及缓存状态实际上胜不过刚释放锁的当前线程。一个典型场景是锁释放线程还在当前核上热着直接继续拿锁的命中率非常高如果强制唤醒另一个CPU核上的线程反而需要重新加载缓存行、导致延迟增加。所以结论不是简单的“非公平快”而是当临界区代码极短、锁竞争强度高、线程数适中时非公平锁能显著减少无谓的睡眠/唤醒周期吞吐量更高。反之临界区代码长、持锁时间长非公平和公平的吞吐差距会缩小因为大部分时间都消耗在临界区本身。3.2 “概率公平”是怎么回事我在多个技术社区看到有人争论非公平锁到底会不会饿死线程。理论上只要有一个线程极其“手速快”确实可能在锁每次释放时都抢在队列线程前面让排队线程长时间得不到锁。但在实际JVM环境中这种极端情况很少见因为CAS失败后线程不会立刻进队列而是会先进入acquireQueued的自旋循环。在一个多核机器上自旋会尝试多次成功率并不低。更准确的说法是非公平锁提供的是“概率公平”。它不做排队承诺但给了每个线程“用当前CPU状态竞争”的机会。线程能不能抢到锁取决于它当前是否在运行、是否离释放锁的线程近、CPU缓存是否还热。在生产环境里这种概率特性通常已经足够不会出现明显的饿了三天的情况。但有一类任务必须小心长时间持锁且频繁被中断的短任务。假设线程A执行一个重计算持锁100毫秒期间有10个短任务线程尝试拿锁。非公平锁可能导致这10个线程全部自旋失败、进入队列然后线程A释放锁后第11个短任务线程直接插队成功。如果这种模式反复出现排在队列里的线程可能一直排在队首但始终只在“下一次”拿锁虽然不会永久饿死延迟却可能非常不稳定。4. 场景选择线程池、有限任务队列与重操作任务公平锁和非公平锁没有绝对的好坏只有合不合适。不同场景下的评价维度完全不同。下面按我平时设计并发模块的思考方式讲几个真实选择场景。4.1 线程池中的锁基本都选非公平线程池内部用到锁的地方非常多比如ThreadPoolExecutor的AtomicInteger工作线程计数、任务队列的并发安全。当你自己设计一个线程池时如果任务队列使用LinkedBlockingQueue队列本身的take()和put()都依赖ReentrantLock并且LinkedBlockingQueue默认采用非公平锁。为什么LinkedBlockingQueue要默认非公平因为生产者消费者场景里吞吐量的优先级非常高。队列的锁持有时间极短只是对链表头尾指针做一次offer或poll操作最多几个CAS配合一次内存屏障。这种临界区非常短采用公平锁会导致生产者之间频繁排队、消费者唤醒延迟队列吞吐骤降。换句话说你把队列的锁改成公平锁不会带来“更稳定”的消费顺序因为每一个队列操作本来就是原子的。要保证某个任务被某个线程处理应该由提交策略来决定而不是靠队列锁的公平性。4.2 服务限流与唯一资源访问公平锁有优势如果你维护一个只能被单个线程访问的稀缺资源比如一个单连接的外部硬件设备、一个不能并发的数据库连接池阈值那么请求方之间的公平性就很重要。比如有10个线程抢一个资源如果使用非公平锁可能出现某个业务线程反复拿锁而另一个业务线程一直失败。虽然最终不会饿死但业务方看到的是自己的请求长期卡在排队阶段很影响体验。这种场景下公平锁让每个等待线程按照请求顺序获得资源虽然没有彻底防止短任务插队长任务但至少消除了“后到先得”的乱象。我自己处理过一个模拟项目X多个上报线程共享一个加密硬件加密过程耗时不短大约20毫秒。最初用非公平锁日志显示某个线程的最大等待时间到了800多毫秒另一个线程只有5毫秒波动非常大。改成公平锁后最大等待时间下降到300毫秒内虽然整体吞吐略有下降但业务方反馈明显更好因为“每个人都在往前走”。4.3 锁内是重操作时公平性对延迟的影响被放大当你把临界区从微秒级放大到几十毫秒、几百毫秒时公平锁带来的排队优势会被放大。因为线程A持锁时间很长其他线程等待时间很长此时非公平锁允许新线程插队会直接导致更长时间的无序。我做过一个数据导入功能每个批次需要锁住一个本地文件输出流写入大约50MB数据后再释放。这个持锁时间可以到两秒。用非公平锁时多个导入任务并发执行任务1持锁写文件时任务2、任务3进入队列睡眠。任务1释放锁后任务4刚启动直接插队成功。任务2在队列里等了2秒后终于准备唤醒结果任务4再次插队任务2又要多等2秒。这种模式下任务2的等待时间出现明显尖峰。改成公平锁后任务2、任务3、任务4按提交顺序排队每个人的最坏等待时间是可预测的。对于这种重操作任务我强烈建议使用公平锁即使牺牲一部分总吞吐量也要保证等待时间的方差可控。4.4 读写锁与公平策略的关系ReentrantReadWriteLock也支持公平和非公平。但它的公平策略并不是简单套用hasQueuedPredecessors()还需要处理写优先还是读优先的问题。公平模式下读写锁会让写线程在等待队列里先于后续读线程获得锁避免“读线程不断插队导致写线程饿死”。非公平模式下读线程和写线程都可能插队如果不加额外控制写线程很容易长时间得不到机会。这个场景和纯互斥锁的公平性取舍完全不同。如果你在用读写锁且系统对写操作延迟很敏感我建议开启公平模式或者在非公平模式下额外利用ReentrantReadWriteLock的内部写优先设计来兜底。但要注意写优先会导致读并发度下降必须结合业务对读延迟的容忍度来判断。5. 一次实测对比锁竞争下的耗时与队列行为光靠理论说服不了自己。我用一个简单的模拟程序做了对比测试线程数固定为8锁的临界区是一个约毫秒级的轻量计算运行10万次循环。分别跑公平锁和非公平锁记录总耗时和单线程最大等待时间。测试环境普通8核开发机JDK 17不作任何JIT黑科技调优。以下是核心测试代码思路。public class ThroughputCompare { public static void main(String[] args) throws Exception { int threadCount 8; int loopCount 100_000; ReentrantLock lock new ReentrantLock(Boolean.parseBoolean(args[0])); CountDownLatch latch new CountDownLatch(threadCount); long start System.nanoTime(); for (int i 0; i threadCount; i) { new Thread(() - { for (int j 0; j loopCount; j) { lock.lock(); try { int x 0; for (int k 0; k 100; k) { x k; } } finally { lock.unlock(); } } latch.countDown(); }).start(); } latch.await(); long cost System.nanoTime() - start; System.out.printf(costMillis%d%n, cost / 1_000_000); } }注意这里没有统计每个线程的最大等待时间因为等待时间统计需要额外包一层队列代码会复杂化。为了观察等待时间差异我换了一种方式两个线程交替抢锁一个线程记录自己上次抢到锁到现在的时间间隔然后统计P99和max。实际操作中我推荐你用JFR或者直接打时间戳到日志不要过度设计。简单测出总量时间就够了因为公平锁在临界区较短时吞吐下降通常在10%到30%之间这个数字足够支撑选型判断。5.1 实测结果公平锁牺牲了多少吞吐在我这台机器上的结果大约如下非公平锁8线程单循环总耗时约1200毫秒。公平锁8线程单循环总耗时约1450毫秒。公平锁相比非公平锁吞吐下降约17%。如果临界区进一步缩短比如只做一个longAdder.increment()公平锁的吞吐下降会更明显可能到30%以上。原因是短临界区下锁竞争更剧烈每次CAS和入队唤醒开销占比更高。但如果把临界区长度增加到“做一次JSON序列化”公平锁和非公平锁的耗时差异会缩小到5%以内。这个规律很重要锁内操作越重公平策略带来的额外开销越不明显。很多人无脑推荐公平锁其实在临界区很重时公平锁的排队优势更容易体现价值因为它减少了“插队导致的无谓等待”而大多数人以为它只会变慢。5.2 队列行为的另一面线程Dump能看到什么压测之外我还做了线程Dump观察。非公平锁模式下锁竞争激烈时经常能看到多个线程处于WAITING状态集中在parkAndCheckInterrupt。而公平锁模式下线程的状态会更规律地呈现“先WAITING再RUNNABLE”的循环因为唤醒顺序基本有序。线程Dump对线上排查很有用。如果发现一个服务里大量线程交替处于WAITING且线程名称模式混乱怀疑锁分配不公平时可以临时把锁改成公平锁观察WAITING线程数量和等待时间是否变得均匀。这不是性能调优的终点但能快速判断“是否锁公平性导致的问题”。我建议把这个手段记录在排查手册里因为它成本极低只要改一个构造参数。6. 常见误区和调优经验总结最后一部分把我在项目里见到的、以及社区里反复被问到的误区集中讲一讲。这些坑不是理论推导出来的是真的在代码审查和线上故障排查里见过。6.1 synchronized和公平锁是完全不同维度的概念最常出现的误解就是“synchronized是公平锁吗”。这个问题本身就不成立。synchronized用的是JVM内置监视器在没有竞争或轻度竞争时走偏向锁和轻量级锁路径根本不存在“排队”的概念。到了重量级锁阶段它的等待队列由JVM内部管理Java代码无法直接控制是否是公平策略。所以如果你需要在业务层保证“谁先请求谁先拿锁”synchronized不是你的选项你必须用ReentrantLock(true)。有些项目为了快把synchronized换成非公平ReentrantLock性能确实提升但延迟尖峰问题可能变得更严重因为非公平带来的插队概率更高。6.2 new ReentrantLock()默认是非公平很多人不知道ReentrantLock默认使用非公平锁这是JDK源码直接决定的。为什么默认非公平因为绝大多数锁的持有时间都很短公平策略带来的吞吐损失远超它带来的排队收益。HotSpot团队在多年实践后选择把非公平作为默认值这不是拍脑袋而是基于大量生产环境的统计。我在帮某跨平台系统做并发模块改造时接手的老代码里到处是new ReentrantLock()后来线上出现某个线程等待时间异常高。第一反应是查死锁结果不是。后来打印线程堆栈才发现有个线程在AQS队列里等了接近2秒而其他线程不停插队。复查代码发现开发者根本没意识到默认非公平他们以为“用了ReentrantLock就已经有公平保障了”。这种认知偏差比代码bug更隐蔽因为它不会报错只会时不时抖一下。6.3 lockInterruptibly和公平锁配合时要注意中断语义公平锁配上lockInterruptibly()在线程等待期间收到中断信号时它会从等待队列中移除并抛出InterruptedException。这里有个值得注意的点被中断线程在队列中可能已经排到了队首移除后队列会重新调整head节点后续线程的等待顺序整体前移但不会改变它们之间的相对顺序。所以公平锁的中断处理是安全的不会造成队列错乱。但是有一种做法是反模式在循环里反复调用lockInterruptibly()并处理中断某次中断后立刻重新请求锁。此时线程可能反复回到队尾导致它实际上被“插队”了很多次。如果你确实需要线程快速响应中断可以在等待阶段用一个独立的atomicBoolean记录中断状态然后在成功获取锁后再统一处理。6.4 性能调优时不要只会换公平策略如果线上出现锁竞争导致的性能问题第一反应不应该是“公平锁改非公平”或“非公平改公平”而是先测量临界区耗时和锁竞争频率。我通常会先做三件事统计每次持锁时间的中位数和P99。统计单位时间内锁竞争失败次数可以通过ReentrantLock的getQueueLength()/hasQueuedThreads()辅助观察。压测对比公平/非公平模式下的总吞吐和P99等待时间。很多场景里锁竞争剧烈是因为临界区里有耗时操作比如远程调用、磁盘IO、数据库查询。这时候换锁策略治标不治本应该优先把重操作移出临界区或者用读写锁/StampedLock降低互斥范围。只有在确实需要保证请求顺序的强语义时才值得为公平性付出一部分吞吐量。6.5 一个实际可参考的选型决策清单根据我的经验总结成几句话锁内操作是纯内存计算耗时低于几微秒线程数在4到16之间选择非公平锁默认配置直接用。锁内涉及IO、锁持有时间可能在几十毫秒以上且等待方是明确的业务请求选择公平锁保证最坏等待时间可控。业务有强顺序要求比如FIFO任务执行、消息消费严格按提交顺序推进必须使用公平锁。不确定时先跑一段压测把公平/非公平两组数据的P99对比出来再做决定不要拍脑门。另外微服务架构里的分布式锁通常基于Redis或数据库实现它们的“公平性”和JVM锁完全是两回事。Redis分布式锁本身不保证公平除非你在客户端自己维护排队队列。不要把AQS公平锁的结论直接搬过去很多人在这里踩坑。7. 最后分享一个调试小工具最后分享一个小技巧。我排查并发问题时会临时写一个简单的LockSnapshot工具类定时调用锁对象的getQueueLength()、isLocked()再把结果导出成带时间戳的日志。这个工具不复杂但能直观看到等待队列长度随时间的变化。如果等待队列长度经常在0和N之间剧烈波动通常说明锁竞争是突发性的如果队列长度长期稳定在高位说明临界区吞吐已经接近上限。此时你把锁从非公平改成公平锁等到的结果大概率是队列长度波动变小但整体吞吐下降。反过来如果队列长度本来就很低比如几乎都是0或1那么公平/非公平的差异几乎可以忽略你就不必纠结选型问题。强烈建议不要在线上直接改公平策略尤其不要在生产环境临时把默认锁改成new ReentrantLock(true)来做实验。先在压测环境跑通再用灰度发布观察P99变化。锁是并发模块的地基地基形式变了整个系统的行为都会跟着变。