
1. 为什么并发编程里面锁永远是一个绕不开的话题做后端开发这些年我越来越觉得“锁”这个东西就像老小区的门禁——看似简单但装错了、用错了、锁粒度选错了要么让坏人溜进来要么让所有住户堵在门口进不去。我们做并发编程也一样数据安全要保证但同时也不能因为加锁太狠把系统的吞吐量直接打到地板。回到标题里的几个词乐观锁、悲观锁、局部锁。先说个容易混淆的点。“局部锁”这个叫法其实不太统一我在日常交流里听得更多的说法是“本地锁”或者“进程内锁”跟它对应的概念是“分布式锁”。为了让文章更有针对性我干脆把标题里的“局部锁”理解为“JVM 内、单进程内生效的锁”也就是我们最常用的synchronized和ReentrantLock这类。它们的特点是在自己这个进程里绝对好使但换个进程或者换台机器就管不着了。这篇内容适合谁看我觉得只要是写过并发代码、被数据不一致坑过、或者在技术方案评审会上被问“你这里为什么用乐观锁而不是悲观锁”的同学都可以读一读。我会把这些锁的原理、区别、适用场景、坑点都讲清楚而且会尽量少用绕口术语多用实际例子。毕竟锁这个东西光看概念记不住得理解它到底解决什么问题才知道怎么选。2. 悲观锁先锁再干安全但代价不低2.1 悲观锁的核心思想悲观锁的核心假设很直白拿数据的人很可能同时在改数据。所以每次去读写共享资源之前先把资源锁住让别人碰不了等自己操作完了再释放。整个过程可以概括为先拿锁再干活最后放锁。这个思路跟我们日常生活里很像。比如图书馆里有一本很珍贵的参考书为了避免两个人同时借走管理员规定谁要看这本书先到服务台登记把书拿走别人这段时间就不能借。等你还回来下一个人才能借。这就是一种悲观的保护策略——它默认所有人都可能同时抢这本书所以干脆限制同一时刻只能有一个人持有。在技术层面悲观锁最典型的实现就是数据库里的SELECT ... FOR UPDATE以及 Java 里基于监视器实现的synchronized。它们都是先让当前线程或事务拿到锁其他线程或事务只能等待。2.2 synchronized 和数据库悲观锁的注意点Synchronized是 Java 里最基本的悲观锁。它可以用在方法上也可以用在代码块上。比如我们写一个库存扣减方法public synchronized boolean deductStock(long goodsId, int count) { // 检查库存 // 扣减库存 // 记录流水 }加了synchronized以后同一时刻只有一个线程能执行这个方法体。其他线程想进来只能等当前线程执行完。这解决了并发扣减的问题但代价是串行化——如果这个方法每秒要被调用几千次一次执行几十毫秒那系统的吞吐量就会非常难看。数据库层面的悲观锁也是同理。比如SELECT stock FROM goods WHERE goods_id 123 FOR UPDATE;这一行 SQL 执行后数据库会对这条记录加行级排他锁。直到当前事务提交或回滚其他事务再执行相同的FOR UPDATE查询时就会阻塞等待。用这种方式做秒杀系统的库存扣减是很多老项目常用的方案。它的优点是代码逻辑直观、安全可靠缺点是数据库的锁等待可能带来连接池占用和死锁风险高并发下非常容易把数据库打崩。2.3 悲观锁的死锁问题悲观锁在复杂场景下很容易出现死锁。经典的死锁场景是线程 A 持有锁 1 去申请锁 2线程 B 持有锁 2 去申请锁 1两个线程互相等待谁都不松手程序直接卡死。我自己遇到过的一个真实案例是一个转账系统A 给 B 转账时需要锁 A 账户再锁 B 账户B 给 A 转账时顺序反过来了先锁 B 再锁 A。并发量一上去死锁就频繁出现。后来改成按账户 ID 排序后再加锁问题就没了。所以用悲观锁时加锁顺序一定要一致这是血泪教训。3. 乐观锁不锁数据只校验冲突3.1 乐观锁的核心思想乐观锁的思路跟悲观锁完全相反它默认冲突没那么多所以不直接加锁而是先正常操作等到写回数据的时候检查一下看这个数据在操作期间有没有被别人改过。如果没被改过那就提交成功如果被改过了就放弃操作或者重试。乐观锁最常见的实现就是版本号机制。给数据表加一个version字段每次更新都带着这个版本号去更新。比如UPDATE goods SET stock stock - #{count}, version version 1 WHERE goods_id #{goodsId} AND version #{oldVersion};这条 UPDATE 语句巧妙的点在于它把“校验”和“更新”合并成了一条具有原子性的 SQL。如果执行完后受影响行数为 0说明版本号不对数据在操作期间被其他事务改过更新失败。从用户体验来看乐观锁更像是乐观的人生态度我拿了数据回去慢慢算等到提交的时候再对账。对上了就过对不上就重新再来。这个思路特别适合读多写少、冲突概率比较低的场景。3.2 CAS 与自旋版本号是一种比较朴素的乐观锁实现。在更底层的并发原语里还有一类叫 CASCompare And Swap也就是比较并交换。Java 的AtomicInteger就是基于 CAS 实现的。它的操作逻辑是如果内存当前值等于预期值就把它更新成新值否则就不更新。这个比较和交换操作是由 CPU 提供的原子指令保证的。AtomicInteger stock new AtomicInteger(100); stock.compareAndSet(100, 99);由于 CAS 本身不会把线程阻塞所以通常配合循环重试使用也就是自旋while (!stock.compareAndSet(current, current - 1)) { current stock.get(); }这种写法可以用在库存预扣、计数器递增这些场景。不过要注意自旋会占用 CPU 资源如果冲突特别频繁循环次数多CPU 消耗会比悲观锁还高。3.3 ABA 问题CAS 有个经典陷阱叫 ABA 问题。简单说线程 A 从内存中读到了值 1准备改成 2但在线程 A 操作之前线程 B 先把值从 1 改到 3再从 3 改回 1。等线程 A 的 CAS 实际执行时它发现内存值还是 1跟自己的预期值一致就认为数据没有被改动过于是更新成功。但问题是这个值虽然“数值”没变可它的“内容”经历了一次完整的变更。如果这个值代表的是账户余额A 把 1 改成 2 是没有问题的但如果这个值代表的是一个对象引用而这个对象中间被 B 重新构建过、状态发生了某些语义变化那 ABA 就可能带来隐藏问题。解决 ABA 的方法是加版本号或时间戳Java 里可以用AtomicStampedReference。实战中如果只是简单的计数增减ABA 往往不致命但如果做复杂对象的状态流转最好加上版本标识。4. 乐观锁和悲观锁怎么选才是真正的问题4.1 一张表看懂核心区别把乐观锁和悲观锁放在一起对比很多细节会更清楚。下面这个表格我整理了很久基本上把工作中最关心的维度都包含进去了。对比维度悲观锁乐观锁核心思想先加锁再操作防止一切冲突不加锁操作后校验冲突实现方式synchronized、ReentrantLock、SELECT FOR UPDATE版本号、CAS、AtomicInteger并发性能低线程容易阻塞、排队高无阻塞但冲突时需重试适用场景写多读少、冲突严重读多写少、冲突频率低是否会产生死锁会尤其是多把锁交叉使用不会不存在长期持锁等待对数据库压力高锁等待和连接池占用明显较低靠 UPDATE 条件判断编码复杂度相对简单但要注意加锁顺序相对复杂要考虑重试和版本处理4.2 项目里到底应该怎么选这里我给一个比较实用的判断方法。第一步先看并发冲突概率。如果业务模型里同一个数据被并发修改的概率非常高比如秒杀场景中同一件商品的库存那乐观锁的失败率会很高重试反而放大了数据库压力这时候悲观锁或者队列化处理更合适。第二步看系统对数据一致性的容忍度。悲观锁是强一致的一旦加锁数据在事务提交前对外不可见。乐观锁最终也一致但存在操作失败需要重试的情况。如果业务要求失败后必须快速感知那乐观锁配合友好的前端提示是可以的如果业务要求在事务里严格串行那乐观锁就不合适。第三步还要考虑性能瓶颈在哪。很多时候我们纠结用乐观锁还是悲观锁其实真正的问题在数据库。如果单表单行记录的热点非常高任何锁方案都不如削峰填谷、异步化或者分库分表来的实际。锁永远只是解决并发问题的工具之一不是银弹。5. 局部锁同一个进程内才买账的锁5.1 为什么说它是“局部”的先澄清概念。文章开头说了我把标题里的“局部锁”理解为进程内锁也就是运行在同一个 JVM 进程里只对本进程的线程生效的锁。这类锁在单体应用时代非常够用——所有线程都活在同一个进程里锁住一个对象、一个类几乎所有共享资源的访问冲突都能被挡住。但是当应用拆成多个服务实例或者单实例部署多份时进程内锁就“失效”了。比如一个订单系统部署了 3 个实例每个实例都有自己独立的 JVM 内存A 实例上的synchronized锁不住 B 实例上的线程。这就是为什么需要分布式锁——它的作用域不在进程内而在多个进程或机器之间共享的资源上。5.2 两把最常用的局部锁Java 里最常用的两把局部锁一个叫synchronized一个叫ReentrantLock。synchronized用起来最简单不需要手动释放锁出了异常自动释放。它从 Java 6 开始做了大量优化有偏向锁、轻量级锁、重量级锁的升级过程。日常写代码如果不需要特别复杂的锁控制优先用synchronized就好。[ReentrantLock则更灵活。它支持尝试获取锁。ReentrantLock lock new ReentrantLock(); if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 业务代码 } finally { lock.unlock(); } }tryLock方法可以设定等待时间超过时间就放弃获取锁避免无限阻塞。它还支持公平锁模式。公平锁会按照线程申请锁的先后顺序分配锁避免线程饥饿非公平锁不保证顺序性能更好但可能让某些线程长时间等不到锁。5.3 锁升级机制理解后真的很重要提到局部锁synchronized的锁升级机制值得展开说说。早期 Java 里的synchronized是纯重量级锁每次加锁都依赖操作系统互斥量性能很差。后来 JVM 引入了偏向锁、轻量级锁的优化。偏向锁的意思是如果一个线程反复进入同一个同步块锁会偏向这个线程后续进入时不再做复杂的同步操作。轻量级锁则是通过 CAS 尝试在对象头中记录锁记录如果成功就不需要进入操作系统级阻塞。只有当竞争真的激烈时才升级为重量级锁线程进入阻塞状态。理解了这套机制你就能明白为什么synchronized在低并发下性能很好高并发下却可能成为瓶颈。也能理解为什么锁粒度要尽量小——锁内部的代码执行时间越短锁上的线程等待时间越短偏向锁和轻量级锁的效果就越好。6. 其他锁类型分类维度不同叫法也不同6.1 公平锁与非公平锁公平锁和非公平锁是按获取锁的策略来划分的。公平锁像排队打饭谁先到谁先拿不存在插队。非公平锁则允许线程抢锁——新来的线程可能直接竞争成功已经等了很久的线程反而抢不到。从性能上说非公平锁通常吞吐量更高因为可以减少线程上下文切换。但代价是可能造成饥饿也就是某些线程一直拿不到锁。ReentrantLock默认是非公平的可以通过构造函数传入true开启公平模式。实际业务里除非有很强的公平性要求否则没必要用公平锁性能损耗不值得。6.2 可重入锁与不可重入锁可重入锁的意思是同一个线程可以多次获取同一把锁。比如一个类里方法 A 加了锁方法 A 内部又调用了方法 B而方法 B 也加了同样的锁可重入锁允许这种嵌套直接通过因为锁的持有者是当前线程。Java 的synchronized和ReentrantLock都是可重入锁。不可重入锁则不允许这种情况如果一个线程已经持有锁再次尝试获取同一把锁时就会死锁。自己用synchronized重入没感觉但在实现一些自定义锁时特别容易踩这个坑。这也是为什么反复强调不要自己造锁轮子。6.3 共享锁与独占锁、读写锁共享锁和独占锁是按能否多个线程同时持有划分的。独占锁也叫写锁同一时刻只允许一个线程持有比如ReentrantLock。共享锁也叫读锁允许多个线程同时持有。把两者结合起来就是读写锁ReadWriteLock。它的规则是多个线程可以同时读但没有线程持有写锁时才能读写锁是独占的写的时候谁都不能读也不能写。这非常适合缓存场景——缓存内容被频繁读取、偶尔更新。Java 里ReentrantReadWriteLock就是标准的实现。要注意的是读写锁在读多写少时性能很好但如果写操作频繁写锁会频繁阻塞读线程反而可能比普通独占锁性能更差。另外读写锁还有一个坑叫“锁降级”从写锁降级为读锁是允许的但从读锁升级为写锁会导致死锁写代码时一定要注意。6.4 自旋锁与分段锁自旋锁其实是一种“等待策略”而不是一种独立的锁实现。它指的是线程在获取锁失败时不进入阻塞状态而是循环忙等尝试获取锁。前面提到的 CAS 重试就属于这种思路。优点是避免线程上下文切换的开销缺点是白白占用 CPU。分段锁则值得单独说一说它是一种用空间换并发度的设计思想。典型代表是 Java 8 之前的ConcurrentHashMap——它把整个哈希表分成一段一段的不同段的数据用不同的锁保护这样多个线程可以同时操作不同段的数据互不干扰。Java 8 之后改为对每个桶节点加锁并发度进一步提升。分段锁的思想在很多场景都能用对一批数据进行操作时不锁整张表而是根据数据 ID 的哈希值分到不同的锁上。这就是“锁粒度”的优化效果往往立竿见影。7. 实战中的常见问题与排查技巧7.1 为什么synchronized锁不住“想要锁住的东西”这是新人最容易踩的坑。synchronized锁的是对象不是代码。如果两个线程锁的是两个不同的对象实例那它们之间是没有互斥效果的。比如public class StockService { public void deduct() { synchronized (new Object()) { // 业务逻辑 } } }每次调用都new一个新对象锁完全没有意义。正确的做法是锁一个常量对象、类对象或者直接用synchronized修饰方法。排查这类问题的时候先确认锁的目标是不是同一个对象这是最基础也是最重要的一步。7.2 乐观锁重试风暴怎么破乐观锁在高冲突下会出现“重试风暴”。一个线程更新失败后立刻重试重试又失败反复打数据库。我见过一个系统在活动秒杀时乐观锁的 UPDATE 重试把数据库 CPU 打到 100%最后不得不切回悲观锁。解决思路有两个一是控制重试次数和频率重试时间退避二是在入口处做限流和预扣降低进入更新环节的并发量。切记乐观锁不是银弹它在低冲突时才高效。7.3 线程池里的锁坑线程池配合锁使用时有一个隐蔽问题如果线程池的线程数量远小于任务数量任务之间又互相等待锁可能造成线程池里的所有线程都在等锁新任务因为线程池已满被拒绝整个业务就“假死”了。一个常见场景是任务 A 持锁等待任务 B任务 B 在线程池里排队拿不到线程执行于是 A 永远等不到 B。遇到这种情况要调整线程池大小、增加超时等待或者把同步等待改成异步回调。总之锁和线程池的组合拳要考虑整体资源有限性。7.4 锁粒度如何评估判断锁粒度是否合理就看一个指标锁内代码执行时间占比。锁内代码越短锁的代价越低。实际项目中尽量做到锁内只做必要操作不要做远程调用、数据库慢查询、批量耗时计算。如果这些操作必须存在考虑把耗时部分移到锁外或者用乐观锁代替悲观锁。另外打印日志、序列化、JSON 转换这类看似不起眼但特别耗时的操作经常被无意间放进锁内。压测时表现正常一旦并发上涨锁等待时间急剧增加特别难排查。8. 我个人在锁选型上的一些心得做了这么多年后端每次设计并发方案我都会先问自己三个问题这个资源可以被并发改吗并发冲突的概率有多大冲突发生后业务能不能接受失败的代价这三个问题问完锁的类型基本就定了。如果是单体应用进程内锁优先synchronized能解决绝大多数问题不够灵活才上ReentrantLock。如果是分布式系统优先考虑能不能通过业务设计避免跨节点锁定同一资源比如分库分表、按用户维度路由实在避不开再引入分布式锁。乐观锁适合当“最后一道防线”放在数据更新环节用来兜底防止极端并发带来的脏数据。还有一点想特别提醒锁的选择不是越高级越好也不是越复杂越好。很多时候最简单的方案就是最好的方案。一段代码里用了多种锁后续维护的人很容易改错。锁这种东西少而合适远比多而齐全重要。最后分享一个小技巧在排查锁相关的线上问题时不要只盯着代码逻辑。先把线程堆栈打印下来看看线程到底阻塞在哪一把锁上、等待了多久、锁被谁持有。很多时候线程堆栈一打出来问题原因就水落石出了。实践出真知锁这个东西理清概念是第一步真实跑一跑、压一压、踩一踩坑才算真正入门。