synchronized 与 ReentrantLock:锁的账单
有个需求很常见,抢一个资源,抢不到等 3 秒就走人。拿 synchronized 写,写不出来,它只会一直等。换 ReentrantLock,一行 tryLock 就完事。这个缺口就是两种锁的分界,一个是语法,一个是工具箱。但选型不是「谁强用谁」,锁是有账单的,先把账看懂。
锁的账单是什么
加锁买的是安全,付的是等待和调度。线程抢不到锁,要么空转(自旋),要么被挂起让出 CPU,抢到的再唤醒它。挂起和唤醒要进操作系统,代价是微秒级的上下文切换。竞争越激烈,账单越贵,这也是「能不锁就不锁、锁的粒度能小就小」的原因。
两种锁在账单上的差别不大,差别在功能边界。
synchronized:语法级的锁
三种用法,锁方法(锁当前对象)、锁静态方法(锁类对象)、锁任意对象的代码块。它在字节码上就是一对 monitorenter 和 monitorexit,JVM 层面的内置锁。
四个特点让它的下限很高。自动释放,异常也不会忘记解锁。可重入,同一线程重复拿锁不死锁。非公平,谁抢到算谁的(吞吐通常更高)。用起来没有心智负担,忘了解锁这种事故在它身上不存在。
它还有个隐藏福利是锁升级。经典的三段路径,偏向锁(第一个线程几乎无成本)到轻量级锁(CAS 自旋几个回合)再到重量级锁(真进 OS)。单线程反复进出同步块的场景,JVM 帮你把锁的成本摊到接近零。版本口径,JDK 15 之后偏向锁默认关闭,起跑线变成轻量级,但升级兜底的思路还在。
ReentrantLock:工具箱
它比 synchronized 多出来的全是场景件。
可中断的等锁,lockInterruptibly,等锁的线程能被叫停。超时等锁,tryLock(timeout),抢不到就走,开头那个需求就它。公平锁,按排队顺序给,等得久的先拿,代价是吞吐下降。多条件队列,一个锁配多个 Condition,生产者消费者不用一个 waitAll 搞混。可查询的锁状态,isLocked、getQueueLength,监控用。
代价写在明面上。必须手动解锁,惯例是 lock 之后 try-finally 里 unlock,漏了解锁整个系统死在这。用法比语法复杂,代码也更吵。
底下站着的是 AQS,AbstractQueuedSynchronizer。机制三句话,一个 volatile 的 state 记录锁的持有状态,CAS 抢,抢不到的线程进一个 FIFO 等待队列排队挂起,持有者释放后唤醒队头。
怎么选
| 你的情况 | 选什么 |
|---|---|
| 就是保护一段代码或一个方法 | synchronized |
| 要超时、可中断、可轮询 | ReentrantLock |
| 要公平排队 | ReentrantLock 的公平模式 |
| 一个锁要管多个等待条件 | ReentrantLock 加多个 Condition |
| 团队新手多、锁的使用面广 | synchronized,事故率低 |
| 需要看锁状态做监控 | ReentrantLock |
记住一句话:synchronized 是语法,ReentrantLock 是工具箱。原则就一条:先用 synchronized,需求里出现工具箱里的东西再换,别为“看起来专业”付复杂度的账。
亲手看账单
下面用一段 Java 跑三个计数器,无锁、synchronized、ReentrantLock 各四线程累加,再演示 tryLock 的超时行为。直接运行java LockBill.java(JDK 11+)。
// 直接运行:java LockBill.java// 三个计数器对比丢更新,外加 tryLock 超时演示。importjava.util.concurrent.TimeUnit;importjava.util.concurrent.locks.ReentrantLock;publicclassLockBill{staticintunsafe=0;staticintsync=0;staticintreentrant=0;staticfinalObjectlock=newObject();staticfinalReentrantLockreLock=newReentrantLock();staticvoidhammer(Runnableop)throwsInterruptedException{Thread[]ts=newThread[4];for(inti=0;i<ts.length;i++){ts[i]=newThread(()->{for(intj=0;j<100_000;j++){op.run();}});ts[i].start();}for(Threadt:ts){t.join();}}publicstaticvoidmain(String[]args)throwsException{hammer(()->unsafe++);hammer(()->{synchronized(lock){sync++;}});hammer(()->{reLock.lock();try{reentrant++;}finally{reLock.unlock();}});System.out.println("无锁:"+unsafe+"(应为 400000,丢的就是更新)");System.out.println("synchronized:"+sync);System.out.println("ReentrantLock:"+reentrant);// tryLock 超时:拿不到就走人reLock.lock();try{Threadt=newThread(()->{try{if(reLock.tryLock(300,TimeUnit.MILLISECONDS)){System.out.println("tryLock 成功");reLock.unlock();}else{System.out.println("300ms 没拿到锁,走人,synchronized 只能干等");}}catch(InterruptedExceptione){Thread.currentThread().interrupt();}});t.start();t.join();}finally{reLock.unlock();}}}无锁那一行几乎必然小于 400000,丢更新的形状就在数字里。后两行稳稳 400000,这就是锁买来的安全。最后一段的「300ms 没拿到锁,走人」,是工具箱的入口。
适用边界
这一篇管两种锁的选择。CAS 的底层实现、锁消除和锁粗化的 JIT 优化,以及虚拟线程对重量级锁的冲击(JDK 21 之后 pinning 的问题),是另外的题。演示里的公平锁没开公平模式,公平的吞吐代价有兴趣单独试。跑完演示把你代码里在用的锁扫一遍,哪些其实只需要 synchronized,哪些卡在「想要超时却只能干等」,当场就有答案。