☰
synchronized 和 ReentrantLock 差在哪?别只答一个关键字一个锁
2026/11/6 2:02:00 网站建设 项目流程

差在能力,不在性能

synchronized 是关键字,加锁和释放都由 JVM 负责,出了代码块自动放手。ReentrantLock 是 JDK 层面基于 AQS 写出来的一个类,锁得你自己 lock,也 unlock 得你自己来。

两者都可重入,都保证互斥和可见性。真正拉开差距的是四个能力:等待能不能中断、抢不到能不能带超时退让、要不要公平排队、有没有多个条件队列。

至于性能,JDK 6 之后两者差距已经很小,拿"哪个快"当选型理由,基本站不住脚。正确答案是先说相同点,再说能力差异,最后才顺口提一句性能已不是考量项。

顺序别搞反。上来就答"ReentrantLock 更快",答案停在十年前的 JDK 5。

一个典型的场景:线程池被一把锁耗光

支付回调是个很典型的入口。第三方重试策略会把同一个订单在一秒内推好几次,接口要做幂等,最直接的写法就是拿订单号加锁。

假设线程池配了 200 个线程,接口超时 3 秒。某天风控服务的下游抖动,一次调用从 20ms 涨到 5 秒。第一个进来的线程卡在 HTTP 调用上,后面抢同一把锁的线程全部挂起。

synchronized 的等待既不能中断,也不能超时。这些线程不肯退出,也没被唤醒去做别的事,只能干等。几分钟里 200 个线程被同一把锁耗光,而这个线程池是全服务共用的,于是连跟支付无关的接口也一起不可用。

改法是换 ReentrantLock,用tryLock(800, TimeUnit.MILLISECONDS),拿不到锁就返回"处理中,请稍后刷新",把线程还给线程池。这一次的回调失败在业务上完全可以接受,支付平台还会重推。

这个 case 里救命的是可中断和可超时,跟性能没有一点关系。

这里最容易犯的错误是改个超时参数就以为万事大吉。如果下游的平均耗时本来就超过线程能等的极限(这里是接口超时那 3 秒),单纯调大等锁时间只是把线程池耗尽的时间往后推。真正要解决的是"为什么一次调用会卡 5 秒",比如给风控调用单独设超时和熔断。

顺带说一句,同样是批处理任务的场景,很多团队会用synchronized锁一个静态对象来保证单实例执行。这在单 JVM 内没问题,一旦服务多副本部署,它就完全失效了,因为两个进程之间根本不认识同一把锁。这时候该上的是分布式锁,不是换 ReentrantLock。

这道题其实在问三层

面试官问"区别",同时在看三件事。

第一层,你知不知道 ReentrantLock 有哪些 synchronized 没有的 API。最表层,背过表就能答。

第二层,你懂不懂 synchronized 在 JVM 里经历了什么。偏向锁、轻量级锁、重量级锁这条升级路径,为什么要有偏向锁,又为什么在 JDK 15 之后默认关闭。

第三层,你会不会选。绝大多数业务代码里 synchronized 就够了,写错的概率还更低。什么时候值得上 ReentrantLock,得说得出具体场景。

顺带纠一个常见的混淆:ReentrantLock 不等于 AQS。AQS 是AbstractQueuedSynchronizer,一个同步器框架,ReentrantLock 内部有个 Sync 继承了它,CountDownLatch、Semaphore、ReentrantReadWriteLock 也都在用它。说"ReentrantLock 基于 AQS",指的是排队、阻塞、唤醒这套机制由 AQS 提供。

主线:一条升级路径,四个能力

一、synchronized 的锁升级

JDK 6 之前,synchronized 上来就是重量级锁,要向操作系统申请互斥量,线程的阻塞和唤醒都得进内核态,代价确实高。那时候 ReentrantLock 快得多,这句话当年是真的。

JDK 6 引入了偏向锁和轻量级锁之后,结论就变了:

无锁 → 偏向锁 → 轻量级锁(自旋) → 重量级锁

偏向锁针对的是"这把锁从头到尾只有一个线程在用"。它连 CAS 都不做,把线程 id 记进对象头的 Mark Word,下次进来比对一下就行。

轻量级锁针对的是"有竞争但很轻,抢一下就好"。线程在用户态自旋,不阻塞,不进内核。

只有当自旋代价超过收益(自旋次数到阈值、或者自旋线程数超过 CPU 核数的一半),才会膨胀成重量级锁,进 ObjectMonitor 排队挂起。

这条路径单向不可逆。升到重量级锁之后就不会再退回轻量级。(JVM 在安全点上会对空闲的 monitor 做收缩,日常理解成不可逆就够了,具体到你的 JVM 版本以实际为准。)

偏向锁的代价要说清楚:一旦真有第二个线程来抢,就得撤销偏向,而撤销要等到 safepoint,这是一个 STW 操作。如果你的服务里所有锁都在多线程真抢,偏向锁就纯亏。JDK 15 默认关闭它(JEP 374)就是这个原因。

自旋的时间也不是写死的。JDK 6 之后是自适应自旋:JVM 会记住这把锁上次自旋多久拿到过、这个线程上次有没有成功,然后动态决定这次转多久或者干脆不转直接挂起。这个策略的意思是赌一把,赌这个锁马上就被放掉,赌赢一次就省下一次内核切换。

还有一个适用范围的限制:这套机制的前提是线程数有限、冲突来得快去得也快。CPU 核数少、临界区本身就长的场景,自旋等于空烧 CPU,这时候改什么都不管用,得先从代码层面缩短临界区。

还有 synchronized 独享的两个优化:锁消除和锁粗化,是 JIT 编译时做的,你在源码上不用写任何东西。锁消除指的是 JIT 发现某个锁对象根本不会逃逸出当前方法,就直接把同步去掉;锁粗化指的是相邻的几个同步块用的是同一把锁,JIT 会把它们合并成一个,省去反复加锁的成本。StringBuffer连续 append 就是典型受益方。

ReentrantLock 是个普通对象,编译器不好对它下手,这两项优化都吃不到。

二、可中断与可超时
private final ReentrantLock payLock = new ReentrantLock(); public Result callback(String orderNo) { try { // 最多等 800ms,等不到就把线程还给线程池 if (!payLock.tryLock(800, TimeUnit.MILLISECONDS)) { return Result.processing(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 恢复中断标记,别吞掉 return Result.of("interrupted"); } try { return doIdempotentPay(orderNo); // 幂等判断 + 业务 } finally { payLock.unlock(); // 必须在 finally } }

synchronized 补不了这个短板,这是能力上的硬差距,不是写法问题。

三、公平锁:能开,但我一般不开

new ReentrantLock(true)是公平锁,等待最久的线程优先拿锁。默认 false,非公平。

非公平的意思是新来的线程可以先插队试一次,CAS 抢成功就直接进临界区,抢不到才老老实实排队。看着不讲理,吞吐却明显更高,因为线程的挂起和唤醒都涉及上下文切换,能少一次就少一次。

公平锁换来的公平是有代价的,某些压测下吞吐会掉几成。除非业务明确容不下饥饿,否则不用开。这条看你们场景,我给不了死结论。

四、Condition:一个锁配多个等待队列

synchronized 配的是 wait 和 notify,一个对象只有一个等待队列。notifyAll 把所有等待者一起叫醒,多数线程醒来发现条件还不满足,又得睡回去。

ReentrantLock 可以开多个队列:

private final ReentrantLock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); private final Condition notEmpty = lock.newCondition(); private final Object[] items = new Object[16]; // 有界队列,容量 16 public void put(Object x) throws InterruptedException { lock.lock(); try { while (count == items.length) notFull.await(); items[tail] = x; notEmpty.signal(); // 只叫醒等非空的那一个 } finally { lock.unlock(); } }

生产者等在 notFull 上,消费者等在 notEmpty 上,按队列精确唤醒。ArrayBlockingQueue 内部就是这个写法。

一个注意点:await()必须写在 lock() 和 unlock() 之间,跟Object.wait()得先拿到监视器是同一个道理。

面试官在等的那句

多数候选人答到那张六行表格就停了。如果停在这,面试官一般会追一句"还有吗"。

真正拉开差距的是这两句话。

第一句:synchronized 的性能优化只对没有真实竞争的场景有效。偏向锁是为"只有一个线程"准备的,轻量级锁是为"抢一下就好"准备的。一旦膨胀成重量级锁,它也得老老实实把线程挂起排队,两条路的代价就基本持平了。所以"哪个更快"这个问题本身就问错了,答案是"有没有真竞争,有竞争的话差不多"。

第二句:synchronized 的锁是对象,粒度你控不住。如果你锁的是this或者某个被多处共享的字符串常量,外部代码只要锁同一个对象,就能干扰你的临界区。更隐蔽的是装箱对象,Integer在 -128 到 127 之间会走缓存,两个完全不相干的模块可能拿到同一个实例。ReentrantLock 是你手里private final的字段,谁能拿到一目了然。这个差别不是性能问题,是封装问题。

还有一个几乎没人提的细节:偏向锁的撤销要等 safepoint,而 safepoint 要停所有线程。这也是我在 JDK 11 的项目上,遇到锁竞争严重的模块会考虑直接关掉偏向锁的理由(-XX:-UseBiasedLocking)。要不要这么做,以你的环境和压测数据为准,别照抄。

四个常见的错误答案

错误一:ReentrantLock 一定比 synchronized 快十年前的结论。JDK 6 引入偏向锁和轻量级锁之后,两者在无竞争和低竞争下的差距已经不大;synchronized 还能享受 JIT 的锁消除和锁粗化,这部分 ReentrantLock 反而拿不到。

错误二:忘了 unlockReentrantLock 必须写在 finally 里,否则异常一抛锁就丢了,别的线程永远进不来。重入几次就要 unlock 几次,配不上对就是死锁。这条在 code review 里出现频率比想象中高,建议直接上静态扫描规则。

错误三:默认开公平锁公平锁能让每次获取都排队,代价是丢掉了"插队"带来的吞吐。默认非公平是有意为之,不是少实现了功能。

错误四:以为 wait/notify 就等于 Condition一个对象只有一个等待队列,做不到分组等待和精确唤醒。要多队列就得搞多个锁对象,那是在给自己找麻烦。

一个我还没想明白的问题

重入次数在 synchronized 里记在 monitor 上,在 ReentrantLock 里记在 AQS 的 state 字段里,两边都是 int,理论上重入到溢出会报错。

但我从来没在线上见过真实代码因为递归太深触发它。只在一次压测里见过接近溢出,最后是靠重构绕开的,没把这个现象追到底。

你们线上有没有见过?另外,你们一般在什么情况下会主动把 synchronized 换成 ReentrantLock?评论区聊聊。

完整的十二个模块(Java / C++ / Go / JVM / MySQL / Redis / MQ / 分布式 / 事务 / 网络 / 系统设计 / 项目深挖)我整理成了一份手册,每题都标了考察点和加分答法。【草丛后端架构】,持续分享后端架构与底层原理。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询