
这个问题我太有发言权了这么多年面试别人的时候十个候选人里至少有八个能把ReentrantLock和synchronized的区别背得滚瓜烂熟但真扔给他们一个线上问题或者让他们在真实业务场景里选型立刻就露怯了。不是他们不够努力是市面上的Java并发教程太喜欢讲原理和底层却很少讲什么时候用哪个以及用的时候到底会踩什么坑。今天我就把这几年在项目里和锁打交道的经验从单机的synchronized到分布式场景下的Redis锁完整地梳理一遍。这篇文章不只是讲API怎么用而是讲清楚它们各自适合什么场景、有什么致命短板、线上出了问题怎么排查。无论你是准备面试还是在业务代码里被并发问题折磨过这篇文章应该都能给你一些实在的参考。1. 锁到底在解决什么问题并发场景下的竞态与临界区1.1 一个最经典的超卖问题先看一段几乎所有电商系统里都出现过的代码public class StockService { private int stock 100; public void deduct() { if (stock 0) { // 模拟耗时操作比如数据库查询、网络IO Thread.sleep(10); stock--; } } }这段代码在单线程下毫无问题但一旦放到多线程环境里比如秒杀场景下100个线程同时来调deduct()你会发现最终库存可能变成负数。用大白话解释线程A和线程B同时读到stock 1都判断stock 0成立然后都执行stock--最后stock从1变成了-1。这就是典型的竞态条件多个线程同时访问共享资源执行顺序不同导致的结果也不同。这段代码里if (stock 0)和stock--这两行代码之间就是临界区锁要保护的其实就是这个临界区的原子性。1.2 锁的本质是串行化但串行化要付出代价很多人以为锁是一个高科技的东西其实锁的本质就是把并发变成串行让同一时刻只有一个线程能进入临界区。这就像一条单车道虽然路很宽但到了窄桥的位置车只能排着队过。但这个串行化是有代价的。你的代码本来可以10个线程并行跑用了锁之后同一时刻只有1个线程在跑另外9个线程要么阻塞等待要么自旋空转。锁的粒度越粗串行化的范围越广性能损失越大。所以选锁和设计锁的核心逻辑永远是在保证正确性和尽量少地牺牲并发度之间找平衡。2. java内置锁synchronized的底层演进与正确用法Synchronized是最古老也最基础的锁Java 1.0就存在了。我以前刚学Java的时候老师说synchronized是重量级锁性能很差少用这句话放到现在已经不准确了。2.1 synchronized的三种使用方式Synchronized修饰的位置不同锁住的对象也不同这个细节在实际开发里特别容易搞混面试也常考修饰位置锁住的对象作用范围实例方法当前实例对象this同一个实例的多个线程之间互斥不同实例之间不互斥静态方法当前类的Class对象所有实例之间互斥代码块手动指定的任意对象由开发者精确控制锁的范围和粒度这里最经典的问题就是一个类里有两个synchronized实例方法两个线程分别调这两个方法会互斥吗答案是会。因为synchronized修饰实例方法时锁的是this两个方法锁的是同一个对象所以会互斥。有个面试题经常换个角度问两个线程一个调实例方法一个调静态方法会互斥吗答案是不会一个锁this一个锁Class对象根本不是同一把锁。2.2 锁升级JDK 1.6之后synchronized的性能逆袭JDK 1.6之前synchronized确实是重量级实现底层直接依赖操作系统的Mutex Lock。这里有个非常关键的细节线程从用户态切换到内核态这个切换动作本身开销极大会让锁竞争剧烈的场景下性能急剧恶化。JDK 1.6之后HotSpot虚拟机对synchronized做了大量优化引入了锁升级机制这也是高频面试点无锁状态对象刚开始没有任何线程访问Mark Word里记录的是一些对象本身的哈希码等信息。偏向锁只有一个线程反复进入同步块时JDK会把这个线程的ID记录在Mark Word里。这个线程第二次进来的时候不需要任何CAS操作直接检查一下Mark Word里的线程ID是不是自己是就直接进入开销极小。用生活化的比喻就像你家里的指纹锁录入过指纹的人一按就开。轻量级锁当第二个线程来竞争锁时偏向锁会被撤销升级为轻量级锁。这个阶段通过CAS自旋来获取锁不阻塞线程保持用户态运行。如果CAS自旋一段时间还是抢不到锁就继续升级。重量级锁自旋获取失败的线程会被阻塞进入内核态等待唤醒这就是真正的重量级锁。2.3 关于JDK 8之后默认自旋锁这个热点问题热搜词里出现了jdk8之后默认自旋锁么?自旋锁是什么?这个问题我在面试里也经常被问到。先说结论JDK 8之后synchronized在锁竞争不激烈时确实会通过自旋获取轻量级锁但这不是说默认都是自旋锁。准确的说法是JDK 1.6引入了自适应自旋锁JVM会根据上一次在同一个锁上自旋等待的成功率动态决定本次自旋的时间。如果上次自旋成功率高这次就多转一会如果经常自旋半天还是拿不到锁就直接放弃自旋让线程阻塞。自旋锁的代价很明确——用CPU时间换取线程切换的开销。单核CPU上自旋毫无意义白白浪费CPU时间片多核CPU上如果锁持有时间很短自旋等待比线程阻塞唤醒要快得多。所以实际项目中如果临界区代码很长就不要指望自旋能救你锁竞争激烈时该上分布式锁、该拆锁粒度都得认真考虑。2.4 锁对象的选取为什么String做锁会出问题还有一个特别容易踩的坑就是用String字面量作为锁对象private final String lock order_lock; public void doSomething() { synchronized (lock) { // 业务逻辑 } }问题在于字符串常量在Java里是有池化机制的。假如两个不同类里分别定义了String lock order_lock实际上它们指向的是常量池里的同一个对象。也就是说你在两个完全无关的模块里用了同一个字符串字面量做锁看似各锁各的实际上共用了一把锁这会导致毫不相关的业务逻辑之间互相阻塞而且极难排查。我的建议是锁对象一定要用private final Object lock new Object()专门为了锁而创建一个独立对象或者在能接受的情况下用this。synchronized加锁的对象越精确、越独立锁碰撞的概率就越低。3. JUC显式锁的工程价值ReentrantLock的取舍与非阻塞尝试Synchronized虽然经过优化已经够用但JDK 5引入的ReentrantLock位于java.util.concurrent.locks包下在复杂场景下依然不可替代。它和synchronized的区别就像手动挡和自动挡synchronized是自动挡省心省力ReentrantLock是手动挡能应对更多复杂工况但需要司机更懂车。3.1 ReentrantLock那几个synchronized给不了的能力可中断响应。synchronized一旦获取不到锁线程就无限期阻塞在那儿了外部无法中断它。但ReentrantLock的lockInterruptibly()方法允许线程在等待锁的过程中响应中断信号。这在业务场景里很实用比如客户端断开了连接服务端线程还傻等锁是没意义的应该让它响应中断、释放资源。超时获取锁。tryLock(long timeout, TimeUnit unit)尝试在指定时间内获取锁拿不到就返回false代码可以走其他逻辑而不是死等。分布式系统里这个能力尤其好用可以避免线程无限期等待。公平锁与条件队列。ReentrantLock构造器传入fair true可以启用公平锁让等待时间最长的线程先获取锁避免线程饥饿。同时它还提供了newCondition()方法可以实现类似Object.wait/notify的等待通知机制但更加灵活一个锁可以创建多个条件队列。实际用起来大概是这样的结构Lock lock new ReentrantLock(); Condition notFull lock.newCondition(); Condition notEmpty lock.newCondition(); // 生产者线程 lock.lock(); try { if (queue.isFull()) { notFull.await(); // 等待队列不满 } queue.put(item); notEmpty.signalAll(); // 通知消费者 } finally { lock.unlock(); }3.2 为什么必须在finally里unlock关于ReentrantLock我见过最多的线上bug就是忘记在finally中释放锁lock.lock(); if (doSomething()) { lock.unlock(); return; } // 如果doSomething返回false或者中间抛了异常这里根本执行不到unlock锁一旦没释放轻则其他线程全部阻塞重则直接拖垮整个服务。所以正确姿势必须是lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }这个习惯不是选择题而是红线。synchronized的好处是JVM自动帮你释放锁而ReentrantLock的所有控制和风险都交给了开发者。3.3 StampedLock读多写少场景下的新选择ReentrantReadWriteLock解决了读读不互斥的问题但读线程和写线程之间依然会互斥。JDK 8引入了StampedLock它提供了三种模式写锁、读锁、乐观读。乐观读这个思路很有意思——它不真正加锁而是先执行读操作读完之后再验证一下这个过程中有没有写操作发生过。如果没发生过脏读风险就不存在直接返回结果如果发生过就升级为真正的读锁重读一遍。这在缓存场景下非常实用比如一个配置中心配置变更频率低但读的频率极高。用StampedLock能最大程度地提高读并发。但它有个限制StampedLock不是可重入锁而且不支持条件变量适合度高但对场景有要求别乱用。4. 业务代码中的乐观锁与悲观锁选择时机才是核心功力面试里经常问乐观锁和悲观锁的区别网上的答案也很多。但实际项目里怎么选才是真正的考点。4.1 悲观锁的适用场景悲观锁的思维是我总觉得会有人跟我抢所以我在操作数据之前先把锁占了别人碰都不能碰。synchronized、ReentrantLock、数据库的SELECT ... FOR UPDATE都是悲观锁。适合悲观锁的场景特征是写多读少锁竞争激烈冲突率很高。比如库存扣减一旦开始扣其他请求必须等待此时悲观锁能保证强一致性。当然悲观的代价就是并发度低。这里有一个实际的MySQL例子SELECT * FROM order WHERE order_id ? FOR UPDATE这个FOR UPDATE会锁住这条记录其他事务想更新这条记录必须等当前事务提交。注意这个锁是在事务里才生效的如果不在事务里查询完锁就释放了等于没锁。4.2 乐观锁的实现与CAS陷阱乐观锁的思维是我赌没人跟我抢所以不加锁只在更新时检查版本号是否变化。最常见的是基于版本号UPDATE stock SET stock stock - 1, version version 1 WHERE id ? AND version 1如果影响行数为0说明version已经不是1了有其他线程改过了这次更新失败重试或放弃。Java里的CASCompare And Swap操作本质上也是乐观锁思想比较并交换三个参数内存值、期望值、新值只有内存值等于期望值时才把新值写入整个操作是原子的。AtomicInteger、AtomicLong、ConcurrentHashMap等类底层都是依赖CAS。但CAS有个著名的ABA问题线程1读到A线程2把A改成B又改回A线程1再做比较时发现还是A就认为没人改过其实已经改过两次了。解决方式是用带版本号的AtomicStampedReference就像数据库的version字段一样。4.3 实际业务里的选择逻辑我在项目里做选型时通常按这样的逻辑判断并发冲突概率高、写多读少 → 悲观锁保证正确性优先并发冲突概率低、读多写少 → 乐观锁牺牲偶尔的重试换取高并发单机内共享数据 → JVM锁跨进程/跨服务共享数据 → 数据库锁或分布式锁对一致性要求极高比如账户余额 → 悲观锁允许偶尔失败重试比如点赞数、浏览数 → 乐观锁关键是把锁当作一种系统设计层面的选择而不是一个代码层面的API调用。市面上很多架构师喜欢无脑推乐观锁性能好但实际上在超高并发、超高冲突的场景里乐观锁的重试成本反而比悲观锁的等待成本要高得多。5. Redis分布式锁单机锁失效之后的实战方案与常见坑当你的应用从单机部署变成多实例部署synchronized和ReentrantLock就都失效了——每个JVM实例各自管各自的锁彼此之间完全不知道对方的存在。这时就需要分布式锁而实战中最主流的就是Redis分布式锁。5.1 从setnx到set nx ex原生Redis分布式锁的正确姿势我见过很多新人写的分布式锁第一版往往长这样// 错误示范 Boolean setResult jedis.setnx(lockKey, requestId); if (setResult) { // 业务逻辑 jedis.del(lockKey); }这个版本问题很大如果加锁后业务逻辑抛异常了del根本没执行锁永远不释放其他线程全部死等。于是有人改成// 仍然有问题的做法 Boolean setResult jedis.setnx(lockKey, requestId); if (setResult) { try { // 业务逻辑 } finally { jedis.del(lockKey); } }这个版本加锁成功了会在异常时释放但假如服务重启了、宕机了、或者网络闪断了finally里的del还是可能执行不到。我们需要一个兜底机制——给锁加过期时间。setnx和expire必须放在一个原子操作里否则可能出现setnx成功后、expire前服务挂了锁照样不释放。正确姿势是用Redis的SET key value NX EX timeout这个命令把加锁和设置过期时间合并成一个原子操作String result jedis.set(lockKey, requestId, NX, EX, 30); if (OK.equals(result)) { try { // 业务逻辑 } finally { // 释放锁时校验value是否还是自己的requestId if (requestId.equals(jedis.get(lockKey))) { jedis.del(lockKey); } } }5.2 锁过期时间与业务执行时间的关系看门狗续租模式上面的代码能应付大部分场景但依然存在一个痛到骨髓的问题如果业务逻辑执行时间超过了锁的过期时间比如30秒锁自动过期释放了此时另一个线程拿到了锁进入临界区原来的线程还在跑两个线程就同时进入了临界区锁就形同虚设了。解决思路有两种第一种把过期时间设置得足够长。比如业务高峰负载时可能跑10秒就把锁设成60秒。但如果遇到极端Slow Query或Full GC耗时60秒也不够这样就变成心里没底的赌博了。第二种引入看门狗续租机制。这也是Redisson框架的做法——加锁成功后后台有个守护线程每过锁过期时间的三分之一就自动续期一次只要业务还在执行就一直续锁直到业务完成释放锁。这个机制保证了锁不会因为业务超时被提前释放。我个人的建议是如果锁的过期时间允许设置得很保守比如内部接口可以不用看门狗但如果业务执行时间可能波动比如涉及外部服务调用、文件处理、批量任务一定要用Redisson这类支持自动续期的框架别自己用set nx ex裸写。5.3 释放锁时校验value防止误删别人的锁还有一个常见坑是释放锁时没有校验value直接jedis.del(lockKey)。场景是这样的线程A拿到锁执行时间过长锁超时了线程B拿到锁开始执行此时线程A执行完毕调用del直接把线程B的锁删了线程C又进来了。三个线程同时跑数据和逻辑全乱套。解决办法就是前面代码里写的释放锁时先get一下看value是不是自己加锁时放的requestId或UUID是才删不是则不能删。这里还有个进阶问题get和del是两个独立操作中间依然有竞态窗口。严格来说需要用Lua脚本把校验和删除原子化-- Lua 脚本保证比较和删除是原子操作 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endRedisson内部就是这么干的。这也是很多分布式锁面试题考察的核心细节——不加这层校验你在生产环境大概率会踩到误删锁的坑。5.4 RedLock算法的争议与我的实践观点提到Redis分布式锁避不开RedLock算法。Redis作者提出的思路是部署多个独立的Redis节点加锁时依次向所有节点执行加锁命令只有当超过一半节点加锁成功且加锁总耗时小于锁的过期时间才算加锁成功。但业内对这个算法争论很大著名分布式系统专家Martin Kleppmann专门写过一篇长文质疑RedLock的安全性认为它在发生GC停顿、时钟跳跃等场景下仍可能失效。简单来说RedLock在理论上不是绝对安全Redisson的实现里也存在一些取舍。我在实际项目里的经验是高并发场景下Redis分布式锁追求的是基本正确高性能而不是绝对正确。如果你真的需要绝对正确应该考虑基于ZooKeeper或etcd的分布式锁它们通过顺序节点和Watcher机制能提供更强的强一致性保证。Redis锁和Zookeeper锁的选型原则大概是追求高吞吐、可以容忍极小概率的锁失效 → Redis锁秒杀、活动页这种场景追求强一致、一点风险都不能有 → Zookeeper/etcd锁交易、财务资金这类场景6. 锁冲突与死锁排查线上工具和实战思路锁写得再好线上出问题时也要能快速定位。这块经验是平时最容易忽略但恰恰最值钱的。6.1 JVM自带工具排查线程阻塞当服务出现大量线程阻塞、接口响应变得极慢时第一步是拿到线程快照。最常用的命令是# 通过 jps 找到Java进程PID jps -l # 打印线程快照 jstack pid thread_dump.txt然后重点看java.lang.Thread.State为BLOCKED状态的线程。如果很多线程都卡在同一把锁上通常会看到这样的信息pool-1-thread-5 #15 prio5 os_prio0 cpu1.50ms elapsed123.45s tid0x... nid0x... waiting for monitor entry [0x...] java.lang.Thread.State: BLOCKED (on object monitor) at com.example.OrderService.deduct(OrderService.java:45) - waiting to lock 0x00000007bcf0f820 (a java.lang.Object) at com.example.StockService.lambda$main$0(StockService.java:20)看到waiting to lock后面的对象地址再在dump文件里搜这个地址就能找到哪线程持有了这把锁进而分析为什么持锁不释放。是GC停顿是远程调用超时还是业务死循环原因不同解法完全不同。6.2 死锁的经典模型和排查命令死锁的经典模型是这样的// 线程1 synchronized (lockA) { Thread.sleep(100); synchronized (lockB) { // 业务逻辑 } } // 线程2 synchronized (lockB) { Thread.sleep(100); synchronized (lockA) { // 业务逻辑 } }线程1持有A等B线程2持有B等A互相等对方释放谁都无法继续。用jstack可以发现死锁JVM会直接在dump文件末尾输出Found one Java-level deadlock的提示并列出死锁链条。实际项目中的死锁往往更隐蔽不一定那么对称。锁的嵌套顺序不一致是最常见原因。比如支付逻辑里有的分支先锁账户再锁订单有的分支先锁订单再锁账户当两个线程交叉执行的时候就可能死锁。规避死锁的通用思路一是所有地方都按同一个全局顺序获取锁比如按ID从小到大二是用tryLock(long timeout)获取锁而不是无限期等待超时后释放已持有的锁并进行重试。6.3 从应用层到数据库层MySQL锁等待排查热词里出现了mysql锁表数据库并发锁达梦锁表查询和解锁方法说明很多人被数据库层的锁困扰过。当Java应用层的锁没设计好最后往往会演变成数据库锁等待比如大量线程在SELECT ... FOR UPDATE上阻塞。MySQL排查锁等待最实用的三条SQL-- 查看当前有哪些事务正在执行以及它们的状态 SELECT * FROM information_schema.INNODB_TRX; -- 查看当前有哪些锁等待 SELECT * FROM sys.innodb_lock_waits; -- 查看当前有哪些锁(MySQL 8.0) SELECT * FROM performance_schema.data_locks;innodb_lock_waits表会直接告诉你哪个事务在等待哪个事务持有的锁。定位到阻塞源头后如果事务确实卡死可以杀掉这个事务让数据库恢复KILL trx_mysql_thread_id;但KILL只是治标治本还是要回到业务代码看为什么事务持有锁这么久不提交。常见原因是事务里混了远程调用、大范围查询、或者事务嵌套这些才是锁等待的根源。7. 锁的性能优化思路和不同场景下的选型速查7.1 减小锁粒度ConcurrentHashMap和分段锁思想锁的粒度决定了并发的上限。JDK 7的ConcurrentHashMap是分段锁的经典实现整个Map分成16个Segment每个Segment独立加锁不同Segment的读写互不干扰。JDK 8改成CAS synchronized锁单个桶链表头节点或红黑树根节点粒度更细并发度更高。实际业务中也有类似的思路。比如做缓存更新原来的做法是对整个缓存对象加锁所有线程更新缓存都要排队。优化后可以按key维度加锁不同key的更新互不干扰。用ConcurrentHashMap维护每个key对应的锁对象private final ConcurrentHashMapString, Object lockMap new ConcurrentHashMap(); public void updateCache(String key) { Object lock lockMap.computeIfAbsent(key, k - new Object()); synchronized (lock) { // 只锁当前key对应的更新逻辑 } }这个设计的核心是把竞争分散到不同的锁上而不是所有线程挤同一把锁。7.2 锁分离读写锁和CopyOnWrite思想读多写少的场景用读写锁可以显著提升吞吐。ReentrantReadWriteLock允许多个线程同时持有读锁但写锁是独占的。简单类比一个自习室允许多人同时在里面看书读但只有一个人能进去写板书写写的时候谁都别进。还有一种极端的锁优化思路是CopyOnWrite——修改的时候复制一份副本在副本上修改然后替换引用。读操作永远不加锁因为读的都是不可变对象。Java里的CopyOnWriteArrayList就是这个思想的实现。这种方案适合读极多、写极少且可以容忍短暂数据不一致的场景比如白名单配置、路由表更新。7.3 不同锁的适用场景速查表锁类型适合场景不适用场景典型问题/风险synchronized单机内临界区比较短、锁竞争不太激烈的场景高并发超长临界区、需要超时锁无法中断、无法超时ReentrantLock单机内需要超时获取、可中断、公平性的场景简单同步杀鸡用牛刀忘记unlock导致死锁StampedLock读多写极少的缓存场景不可重入、无条件的场景使用门槛高容易误用乐观锁(CAS/Version)冲突率低、读多写少、可重试冲突率高、重试成本大的场景ABA问题、重试风暴悲观锁(DB for update)写多读少、一致性要求极高长事务、高并发场景锁等待、死锁Redis分布式锁分布式环境、高吞吐、容忍极小概率失效对强一致性要求极高的资金交易场景锁过期、误删锁、主从切换ZK/etcd分布式锁分布式环境、强一致性诉求高吞吐、低延迟敏感场景性能较低运维复杂8. 我在项目里锁的实践原则和一些想说的话干了这么多年Java踩过的锁相关的坑确实不少。我自己总结了几条很实用的原则分享出来供参考。第一条能用synchronized解决的问题不要轻易上ReentrantLock。很多人觉得ReentrantLock更高级代码里到处用其实在锁竞争不激烈的场景下两者性能差距几乎可以忽略但synchronized的自动释放和可读性强太多了出问题的概率低得多。第二条锁的粒度一定要细但锁的范围一定要能覆盖完整的操作。粒度太粗会牺牲并发度范围太小会留下竞态窗口。经典错误是只锁了stock--那行代码却没锁住前面的if (stock 0)判断结果缓存和数据库之间的间隙还是被并发钻了空子。第三条分布式锁一定设置过期时间且业务执行时间必须小于过期时间要么用Redisson的看门狗自动续期要么在设计上保证业务执行时间可控。别把过期时间设成30秒就以为万事大吉一旦下游接口慢得像蜗牛你的锁就形同虚设了。第四条锁写好后要用并发压测验证不要靠肉眼review。用CountDownLatch模拟并发请求写一段1000个线程同时抢锁的测试代码跑一下就能看出锁设计是否有问题。很多死锁和性能瓶颈只有并发场景下才会暴露单线程测试是永远发现不了的。最后一点面试里关于锁的八股文可以背但真正理解锁的本质是在你被线上问题折磨过、看过线程转储文件之后。我见过太多面试者对答如流写出来的代码却连finally里释放锁都做不到。锁是并发编程的基本功多花时间理解场景、理解取舍比死记硬背API有价值得多。