
先解决一个问题面试官问“悲观锁和乐观锁怎么实现区别是什么”他到底想听什么如果只背出“悲观锁是悲观认为一定会冲突乐观锁是乐观认为不会冲突”大概率只是及格分。因为并发编程已经卷到场景题面试官真正想考察的是你有没有在真实业务里处理过并发问题有没有踩过数据库、Redis、Spring 事务带来的坑能不能把“锁”落到具体实现上。这篇文章就按 Java 面试的完整链路来拆悲观锁和乐观锁的底层机制、Java 层实现、数据库层实现、Spring 事务的隐藏坑、高并发场景怎么选型以及一套可以直接用的面试回答模板。1. 核心能力速览悲观锁 vs 乐观锁先给一张速览表后面再展开代码和原理。对比项悲观锁乐观锁核心思想先拿锁再操作冲突前就阻塞先操作提交时检查冲突冲突则重试适用场景写多读少、冲突概率高读多写少、冲突概率低实现方式synchronized、ReentrantLock、数据库for updateCAS、版本号、时间戳会不会阻塞会线程进入阻塞/等待状态不会线程直接失败或自旋重试锁释放自动释放或手动unlock无需主动释放通过版本比对完成性能消耗线程切换、上下文切换开销大CPU 自旋会占用 CPU无锁竞争时性能高数据库实现SELECT ... FOR UPDATEUPDATE ... WHERE version ?典型应用库存扣减、订单创建、转账商品更新、配置表更新、缓存更新一句话记住悲观锁怕冲突所以先锁乐观锁不怕冲突所以先干干完再对账。2. 悲观锁的三种实现Java 锁、JUC 锁、数据库锁2.1 synchronizedJVM 层面的悲观锁synchronized是 Java 内置的悲观锁它依赖 JVM 的 Monitor 机制锁的获取和释放都由字节码指令monitorenter/monitorexit完成。public class SynchronizedCounter { private int count 0; public synchronized void increment() { count; } public synchronized int getCount() { return count; } }这里要注意一个面试高频点synchronized在 JDK 1.6 之后做了大量优化引入了偏向锁、轻量级锁、重量级锁的升级路径。所以现在问“synchronized 是不是重量级锁”已经不准了正确说法是它默认是偏向锁出现竞争后升级为轻量级锁再升级为重量级锁而且锁只能升级不能降级。2.2 ReentrantLockJUC 的可中断悲观锁ReentrantLock是java.util.concurrent包下的锁它比synchronized更灵活支持公平锁和非公平锁支持尝试获取锁tryLock支持超时获取支持多个条件队列Conditionimport java.util.concurrent.locks.ReentrantLock; public class LockCounter { private final ReentrantLock lock new ReentrantLock(); private int count 0; public void increment() { lock.lock(); try { count; } finally { lock.unlock(); } } }面试加分点lock()必须放在 try 外面unlock()必须放在 finally 里。如果不小心在lock()和 try 之间抛了异常锁永远释放不了线程直接死锁。2.3 数据库悲观锁SELECT ... FOR UPDATE这是数据库层面的悲观锁也是场景题里最常考的一种。假设有一个库存表CREATE TABLE product_stock ( id BIGINT NOT NULL AUTO_INCREMENT, product_code VARCHAR(64) NOT NULL, stock INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, PRIMARY KEY (id) ) ENGINEInnoDB;悲观锁扣减库存的 SQL 如下-- 开启事务 BEGIN; -- 给指定商品的行记录加排他锁 SELECT stock FROM product_stock WHERE product_code P001 FOR UPDATE; -- 在 Java 代码里判断 stock 是否充足然后执行扣减 UPDATE product_stock SET stock stock - 1 WHERE product_code P001; -- 提交事务释放锁 COMMIT;这里有一个关键面试点FOR UPDATE必须配合事务使用。因为数据库锁在COMMIT或ROLLBACK时才会释放。如果不用事务SELECT ... FOR UPDATE执行完连接就释放了锁也就没了根本锁不住并发请求。另一个关键点FOR UPDATE只在 InnoDB 引擎下有效MyISAM 不支持行锁它会直接退化成表锁。面试时如果能主动说出这一点会明显加分。3. 乐观锁的三种实现CAS、版本号、数据库乐观锁3.1 CASCompare And SwapCPU 原语支持的乐观锁CAS 是乐观锁最底层的实现它包含三个操作数内存地址V、旧值A、新值B。只有当前内存值等于A时才把值更新为B否则什么都不做。整个操作是原子的由 CPU 指令直接保证。Java 里的AtomicInteger、AtomicLong、LongAdder等原子类底层都是 CAS。import java.util.concurrent.atomic.AtomicInteger; public class AtomicCounter { private final AtomicInteger count new AtomicInteger(0); public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } }面试必问CAS 的三大问题这里必须展开因为面试官几乎必问第一个是 ABA 问题。线程 A 读到值是 1线程 B 把值改成 2又改回 1线程 A 的 CAS 仍然成功但中间已经被改过两次。解决方案是使用AtomicStampedReference或AtomicMarkableReference加版本号或时间戳。import java.util.concurrent.atomic.AtomicStampedReference; AtomicStampedReferenceInteger ref new AtomicStampedReference(1, 0); int[] stampHolder new int[1]; Integer value ref.get(stampHolder); // 更新时不仅比较值还比较版本号 boolean success ref.compareAndSet(value, 2, stampHolder[0], stampHolder[0] 1);第二个是自旋开销问题。CAS 失败后会一直循环重试如果竞争激烈会持续占用 CPU。JDK 8 以后引入了LongAdder通过分段累加来降低自旋冲突。第三个是只能保证一个共享变量的原子操作。如果要对多个变量做原子更新CAS 做不到。解决方案是用AtomicReference把多个变量封装成一个对象。3.2 版本号机制最通用的乐观锁实现版本号机制比 CAS 更直观更适合数据库层的乐观锁。核心逻辑每次读取数据时同时读取版本号version更新时SET version version 1更新条件必须带上WHERE version 旧版本号如果更新影响行数为 0说明数据已被其他事务修改需要重试普通场景下也可以使用字段更新时间update_time代替版本号只要保证在业务上精确到足够小的粒度即可但使用版本号字段更直观。3.3 数据库乐观锁WHERE version ?数据库乐观锁是代码里最常用、面试里最常写的实现。Mapper public interface ProductStockMapper { Select(SELECT id, product_code, stock, version FROM product_stock WHERE product_code #{productCode}) ProductStock selectByCode(String productCode); Update(UPDATE product_stock SET stock stock - 1, version version 1 WHERE product_code #{productCode} AND version #{version}) int deductStock(String productCode, int version); }Service 层写法如下Service public class StockService { Resource private ProductStockMapper stockMapper; Autowired private TransactionTemplate transactionTemplate; public boolean deductStockWithOptimisticLock(String productCode) { for (int retry 0; retry 3; retry) { ProductStock stock stockMapper.selectByCode(productCode); if (stock null || stock.getStock() 0) { return false; } int rows stockMapper.deductStock(productCode, stock.getVersion()); if (rows 0) { return true; } // 更新失败说明 version 已变自旋重试 } throw new BizException(系统繁忙请稍后重试); } }成功条件UPDATE返回的影响行数为 1说明版本号匹配扣减成功。返回 0说明版本号不匹配并发冲突需要重新读数据再试。4. 悲观锁和乐观锁的根本区别这是面试官问“区别是什么”时最想听到的结构化回答。维度悲观锁乐观锁冲突假设假设冲突一定会发生假设冲突大概率不会发生加锁时机操作数据之前先加锁操作数据时不加锁提交时再检查并发特性阻塞等待牺牲吞吐量换强一致性无阻塞靠重试保证最终一致性开销来源线程阻塞、唤醒、上下文切换自旋重试、版本号维护失败处理排队等待不丢失请求返回冲突失败需要业务层决定重试或放弃一致性强度强一致事务期间数据不可被修改最终一致可能出现短暂写入失败死锁风险有锁顺序不当会死锁无但重试次数多会影响性能回答时的高阶表达“悲观锁和乐观锁本质是对并发冲突的两种不同策略。悲观锁认为冲突是常态所以先通过阻塞机制把并发请求串行化适合写多读少的场景乐观锁认为冲突是偶发所以让事务并发执行只有到更新那一刻才通过版本号或 CAS 发现冲突适合读多写少的场景。两者没有绝对的好坏需要根据冲突概率、响应时间要求和一致性要求来选择。”这一段如果能在面试时脱稿说出来基本就能拿到这道题的优等分。5. 场景题高并发扣减库存怎么选锁单纯问“锁的区别”已经不够了现在面试官更爱追问场景“数据库里的库存是 10用户同时发起 100 个下单请求你怎么保证不会超卖”这个场景题有四种主流解法建议按顺序全部讲透。5.1 方案一数据库乐观锁推荐优先说核心逻辑使用版本号或条件更新让超卖请求自然失败。Update(UPDATE product_stock SET stock stock - 1, version version 1 WHERE product_code #{productCode} AND stock 0) int deductStockWithCheck(String productCode);这里使用stock 0替代version #{version}也可以原理是让 CPU 层面的比较转换成 SQL 层面的条件利用数据库自带的行锁在更新时自动保持并发的正确性。优点实现简单不需要额外引入 Redis无死锁风险并发量不大时完全够用缺点并发冲突高时失败请求需要重试数据库压力大5.2 方案二数据库悲观锁在事务里先锁行再更新适合必须保证强一致的场景。Transactional public void deductStockWithPessimisticLock(String productCode) { ProductStock stock stockMapper.selectByCodeForUpdate(productCode); if (stock null || stock.getStock() 0) { throw new BizException(库存不足); } stockMapper.deductStock(productCode); }对应 SQLSELECT * FROM product_stock WHERE product_code P001 FOR UPDATE;优点强一致不会出现任何超卖。缺点并发性能差所有请求排队等待锁而且事务一定要短否则会拖垮数据库连接池。5.3 方案三Redis 分布式锁当并发量再往上走数据库锁会成为瓶颈这时候常引入 Redis。Resource private StringRedisTemplate stringRedisTemplate; public boolean tryLock(String lockKey, String requestId, long expireSeconds) { return stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(expireSeconds)); } public void unlock(String lockKey, String requestId) { String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; stringRedisTemplate.execute( new DefaultRedisScript(script, Long.class), Collections.singletonList(lockKey), requestId ); }这里要特别注意两个坑第一个坑释放锁时不能直接del必须用 Lua 脚本先判断value是不是自己的请求 ID。否则可能出现线程 A 的锁超时释放了线程 B 获取到锁然后线程 A 执行完直接del把线程 B 的锁删掉了。第二个坑锁的过期时间要大于业务执行时间。如果业务执行超过锁的过期时间需要引入看门狗机制自动续期。Redisson 的tryLock默认就有看门狗续期逻辑能避免这个问题实际业务里建议直接用 Redisson 而不是手写 Redis 锁。5.4 方案四Redis 原子扣减 异步对账如果并发量非常大一个常见的优化思路是直接在 Redis 里扣减库存再异步同步到数据库。Long stock stringRedisTemplate.opsForValue() .decrement(product:stock:P001); if (stock null || stock 0) { // 扣减失败需要把多加的 1 加回去 stringRedisTemplate.opsForValue().increment(product:stock:P001); throw new BizException(库存不足); } // 扣减成功异步发送 MQ 消息同步数据库订单和库存这种方案的坑在于 Redis 和数据库的一致性要配合延迟消息、对账任务和补偿机制。面试时能把这四种方案的演进逻辑讲清楚面试官会认为你不只是背了八股文而是真的理解高并发库存扣减的架构设计。6. Spring 事务 锁的隐藏坑为什么 for update 没生效这是很多工作两三年的人都会踩的坑也是面试官非常爱追问的点。6.1 坑一FOR UPDATE 不在事务里SELECT ... FOR UPDATE在执行完 SQL 后如果外层没有事务数据库会自动提交锁立即释放完全锁不住并发。解决方案在 Service 方法上标注Transactional。在 Spring 中Transactional默认只对运行时异常回滚遇到受检异常不会回滚非常容易踩坑。一个常见写法是配合TransactionTemplate手动控制事务范围能精确控制到一行代码Service public class StockServiceImpl { Autowired private TransactionTemplate transactionTemplate; Autowired private ProductStockMapper productStockMapper; public void deduct(int stockId, int num) { transactionTemplate.execute(status - { ProductStock stock productStockMapper.selectByIdForUpdate(stockId); if (stock.getStock() num) { status.setRollbackOnly(); throw new BizException(库存不足); } productStockMapper.deduct(stockId, num); return null; }); } }6.2 坑二Transactional只对 public 方法生效如果锁逻辑写在private方法里Spring 事务代理不会拦截锁失效。6.3 坑三同类内部调用 this.method() 导致事务失效一个经典问题Service public class OrderServiceImpl { public void createOrder() { // 这里调用内部方法事务不会生效 this.deductStock(); } Transactional public void deductStock() { // 数据库悲观锁 } }this.deductStock()走的是当前对象引用不是 Spring 代理所以Transactional不会建立新的事务。解决方案使用Autowired注入自己把事务方法拆分到另一个 Service使用AopContext.currentProxy()获取代理对象需配置exposeProxytrue6.4 坑四事务方法中抛出异常锁回滚但不释放连接Transactional默认只对RuntimeException和Error回滚。如果方法抛出了一个受检异常事务不会回滚但FOR UPDATE锁会一直持有到事务结束极端情况下会把整个表锁住。解决方案Transactional(rollbackFor Exception.class)这个配置是 Spring 面试老八股但非常符合本主题的关键词分布。7. 面试回答模板从八股到场景题7.1 一分钟基础版“悲观锁默认冲突一定会发生所以读数据之前先加锁Java 里是 synchronized 和 ReentrantLock数据库里是 SELECT ... FOR UPDATE。乐观锁默认冲突不会发生所以读数据时不加锁更新时通过版本号或 CAS 判断是否有其他线程改过数据有就重试。”7.2 三分钟进阶版“我实际项目中做库存扣减低并发用的是数据库乐观锁加一个 version 字段更新时 WHERE version 旧版本号影响行数为 1 就成功否则重试。高并发并且强一致要求高时用了 Redis 分布式锁基于 SET NX EX 实现释放锁用 Lua 脚本保证原子性。另外事务和锁的配合很关键FOR UPDATE 必须包在事务里才有效而且用 TransactionTemplate 比 Transactional 更容易控制锁的释放时机。还要考虑锁粒度比如库存扣减可以按商品维度加锁避免所有商品共用一个全局锁提高并发吞吐。”这段回答把八股和场景融合在一起面试官最吃这一套。7.3 高频追问什么场景必须要用悲观锁标准答案当冲突概率很高或者一旦冲突就会产生严重的数据错误且重试成本很高时用悲观锁。典型场景数据库主键或唯一键的重复插入校验转账时余额强一致校验对账系统中不允许并发修改同一笔账目秒杀场景里对单个爆品的精确扣减一个记忆技巧你能重试的用乐观锁不能重试的用悲观锁。7.4 高频追问乐观锁的重试策略怎么设计这个问题经常被追问。一个高质量回答控制重试次数一般不超过 3 次重试前加入随机退避比如 10ms、20ms、50ms避免多个线程同时重试导致冲突放大重试超过上限后直接返回“系统繁忙”不要无限自旋public boolean deductWithRetry(String productCode) { for (int i 0; i 3; i) { ProductStock stock stockMapper.selectByCode(productCode); if (stock null || stock.getStock() 0) { return false; } int rows stockMapper.deductStock(productCode, stock.getVersion()); if (rows 0) { return true; } try { Thread.sleep(10L ThreadLocalRandom.current().nextLong(30)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } } return false; }8. 悲观锁和乐观锁常见问题与排查思路8.1 问题表问题现象可能原因排查方式解决方案FOR UPDATE锁不住数据未开启事务 / MyISAM 引擎检查方法是否有事务注解检查表引擎使用 InnoDB开启事务Transactional不生效方法非 public / 同类内部调用查看日志中是否打印事务边界拆分 Service使用TransactionTemplate乐观锁一直更新失败并发量超过预期 / 重试策略不合理打印失败次数看数据库请求日志增加重试次数引入 Redis 预扣减CAS 线程占用 CPU 过高自旋次数过多竞争强烈jstack 查看线程状态使用LongAdder分段原子类或改用ReentrantLocksynchronized锁的是不同对象静态方法锁 Class实例方法锁 this两者锁的不是同一把锁检查锁的对象是否同一个统一锁对象使用常量字符串或 Class 对象数据库连接池耗尽事务方法里释放锁太慢或事务过大查看活跃连接数、慢 SQL缩小事务范围锁操作提前事务尽快提交Redis 锁被误删释放锁时没校验请求 ID查看删除前的get返回值使用 Lua 脚本原子释放锁或直接用 Redisson事务内锁和业务逻辑耦合太重把远程调用或消息发送放到事务里观察事务耗时事务外做远程调用用本地消息表或事务消息解耦8.2 真实验证流程怎么证明锁生效了面试或联调时如果写的是乐观锁可以通过以下方式验证并发表现启动多个线程模拟并发请求例如 100 个线程同时扣减 10 个库存。观察数据库最终的库存量。观察扣减接口的成功与失败次数。使用jstack查看阻塞线程数量来判断锁的效果。并发单元测试的参考写法Test void testConcurrentDeduct() throws InterruptedException { ExecutorService executor Executors.newFixedThreadPool(20); CountDownLatch readyLatch new CountDownLatch(20); CountDownLatch startLatch new CountDownLatch(1); CountDownLatch endLatch new CountDownLatch(20); for (int i 0; i 20; i) { executor.submit(() - { readyLatch.countDown(); try { startLatch.await(); stockService.deductWithRetry(P001); } catch (Exception ignored) { } finally { endLatch.countDown(); } }); } readyLatch.await(); startLatch.countDown(); endLatch.await(); executor.shutdown(); ProductStock finalStock stockMapper.selectByCode(P001); // 如果初始库存是 1020 个并发请求后库存应当是 0且没有负数 System.out.println(final stock: finalStock.getStock()); }9. 并发编程锁面试的最佳实践与进阶方向9.1 面试准备建议synchronized和ReentrantLock的对比必须能说出 4 个以上区别CAS 的 ABA 问题、自旋开销、只能原子操作一个变量这三个坑必须讲熟数据库乐观锁、悲观锁、Redis 分布式锁每种锁都要能写出完整代码场景题要准备一套“从数据库乐观锁 → 数据库悲观锁 → Redis 分布式锁 → Redis 原子扣减”的演进方案一定要掌握TransactionTemplate手动控制事务这是实战派和八股流的明显分水岭9.2 工程实践建议优先用数据库乐观锁解决大多数业务问题不要动不动上 Redis如果引入 Redis 分布式锁优先使用 Redisson不要手写 SET NX锁的粒度尽量小锁单个商品不要锁整个库存表事务内不要做远程调用锁持有时间越短越好所有重试逻辑都要有上限避免死循环涉及金额、订单、库存等核心数据时优先考虑强一致可用悲观锁9.3 进阶路线并发编程除了锁还有几个绕不开的话题ThreadLocal的内存泄漏问题线程池的核心参数设置和拒绝策略ConcurrentHashMap在 JDK 7 和 JDK 8 的区别volatile的可见性和指令重排AQS的锁获取和释放流程分布式锁在集群环境下的可靠性问题如果这次面试问的是锁那么下一次面试很可能会顺藤摸瓜问到 AQS 或 ConcurrentHashMap建议提前把这些点都串起来准备。10. 总结锁没有好坏只有合不合适悲观锁和乐观锁没有绝对的好坏本质上是对冲突概率的两种判断。判断标准可以归纳为三个问题并发冲突大概率发生吗如果是用悲观锁避免无谓重试。冲突发生后能快速重试吗如果能用乐观锁吞吐量更高。数据一致性要求是强一致还是最终一致强一致优先悲观锁最终一致可以上乐观锁。再回到开头那个问题面试官问“悲观锁和乐观锁怎么实现区别是什么”他真正想听的不是你能背出定义而是你能否在数据库、Java 并发包、Spring 事务、Redis 这几层里自由切换并且用场景题证明你踩过坑、填过坑。这篇文章里给出的代码建议全部手写一遍。尤其是TransactionTemplate那段、数据库乐观锁那段、Redis Lua 释放锁那段写一遍和看一遍的记忆深度完全不同。能把这三个代码片段讲明白面试时这道题基本就是送分题。