05、Java并发编程:互斥锁(下):如何用一把锁保护多个资源?
一、痛点场景:线上转账事故,明明加了锁还是丢钱
去年我在某支付核心系统值班,凌晨三点告警突然炸了。业务方反馈:用户 A 转给用户 B 100 元,用户 B 转给用户 C 100 元,跑完之后用户 B 的余额既不是预期的 200 元,也不是多了或者少了 100,而是直接变成了 300 元或者 100 元。钱凭空多了一笔,又凭空消失了一笔,财务对账系统直接亮红灯。
开发同学拍着胸脯说:“我 transfer 方法上加了synchronized关键字啊,不可能有并发问题。”
我们把日志拉出来一看,问题就出在这把synchronized上。代码长这样:
classAccount{privateintbalance;synchronizedvoidtransfer(Accounttarget,intamt){if(this.balance>amt){this.balance-=amt;target.balance+=amt;}}}看起来非常正确,临界区里同时操作了转出账户和转入账户,还用synchronized把方法锁了。但问题在于:这把synchronized锁的是this,也就是当前调用 transfer 的那个对象实例。
线程 1 执行 A 转 B,锁住的是 A.this;线程 2 执行 B 转 C,锁住的是 B.this。两把不同的锁,两个线程根本不互斥,它们同时读到 B 的余额都是 200,然后各自写回一个值,最后 B 的余额就被覆盖了。
这是一个真实发生在生产环境的事故,也是 Java 并发面试中被问烂的一道题:你以为你加了锁,实际上你加了两把不同的锁。
更要命的是,这种 bug 在测试环境根本复现不出来。单线程跑、低并发跑都好好的,一到生产高并发,概率性出现,查都查不到。等你查日志的时候,钱已经对不上了。
文章目录
- 05、Java并发编程:互斥锁(下):如何用一把锁保护多个资源?
- 一、痛点场景:线上转账事故,明明加了锁还是丢钱
- 二、是什么:一把锁保护多个资源的本质
- 2.1 专业解释
- 2.2 大白话翻译
- 2.3 生活案例
- 三、为什么需要区分:无关联资源 vs 关联资源
- 3.1 资源之间没有关联关系
- 3.2 资源之间有关联关系
- 3.3 专业解释:为什么 this 锁不行
- 3.4 大白话翻译
- 3.5 生活案例
- 四、演进过程:从错误写法到正确姿势
- 4.1 第一版错误:this 锁
- 4.2 第二版思路:传入共享锁对象
- 4.3 第三版正确:Account.class 锁
- 4.4 第四版进阶:按顺序加锁避免死锁
- 五、怎么用:企业项目实战代码
- 5.1 场景一:简单转账(中小项目)
- 5.2 场景二:高并发转账(生产级)
- 5.3 场景三:保护无关联资源(细粒度锁)
- 六、竞品对比:synchronized vs ReentrantLock vs 其他方案
- 6.1 专业解释
- 6.2 大白话翻译
- 6.3 生活案例
- 七、常用场景总结
- 7.1 什么时候用一把锁保护多个资源
- 7.2 什么时候用细粒度锁(多锁保护多资源)
- 7.3 企业项目中的最佳实践
- 八、面试官高频面试题
- 问题 1:synchronized 修饰方法和修饰代码块有什么区别?
- 问题 2:为什么 synchronized 修饰 transfer 方法还会出并发问题?
- 问题 3:synchronized 和 ReentrantLock 的区别?
- 问题 4:synchronized 的锁升级过程?
- 问题 5:什么是 Account.class 锁?为什么用它?
- 问题 6:转账操作怎么避免死锁?
- 问题 7:什么是细粒度锁?什么是粗粒度锁?
- 问题 8:synchronized 是公平锁还是非公平锁?
- 九、总结
二、是什么:一把锁保护多个资源的本质
2.1 专业解释
在并发编程中,受保护资源和锁之间的合理关联关系是 N:1,也就是一把锁可以保护多个资源,但不能用多把锁去保护同一个资源。这个原则在上一篇文章里我们已经讲过,这一篇重点讲"N:1"怎么落地。
这里的"N"指的是多个受保护资源,"1"指的是一把锁。关键在于:这把锁必须能覆盖到所有需要被原子性保护的资源。
2.2 大白话翻译
你可以把锁理解成电影院的检票员,把资源理解成座位。
- 如果是一个座位对应一个检票员(1:1),那就是一把锁保护一个资源,最简单。
- 如果是整个影厅的座位只需要一个检票员(N:1),那就是一把锁保护多个资源。
- 最蠢的做法是一个座位配一个检票员,但检票员之间互相不认识,结果两个人同时坐一个座位,这就是多把锁保护一个资源,必出问题。
2.3 生活案例
你家里有一个保险箱,里面放了房产证、护照、户口本。这三样东西平时分开用,你不需要把它们锁在三个不同的保险箱里——那样你开三次锁,既慢又麻烦。你只需要一个保险箱、一把钥匙,谁要用里面的东西,谁就拿钥匙开,开完放回去,下一个人再用。这就是一把锁保护多个资源。
但如果你的保险箱里放的是夫妻共同财产,你老婆要用房产证办贷款,你要用护照出国,这两件事必须同时考虑,那就不能各用各的锁了,必须用同一把锁,否则你俩一个拿了房产证出门,一个拿了护照出门,中间状态别人看到会以为家里被盗了。
三、为什么需要区分:无关联资源 vs 关联资源
3.1 资源之间没有关联关系
如果多个资源之间完全独立,没有原子性要求,那就应该用细粒度锁,每个资源一把锁,互不干扰。
最典型的例子是账户类里的两个字段:余额和密码。取款操作只动余额,改密码只动密码,这两件事半毛钱关系没有,你完全可以让它们并行执行。
classAccount{// 锁:保护账户余额privatefinalObjectbalLock=newObject();privateIntegerbalance;// 锁:保护账户密码privatefinalObjectpwLock=newObject();privateStringpassword;voidwithdraw(Integeramt){synchronized(balLock){if(this.balance>amt){this.balance-=amt;}}}IntegergetBalance(){synchronized(balLock){returnbalance;}}voidupdatePassword(Stringpw){synchronized(pwLock){this.password=pw;}}StringgetPassword(){synchronized(pwLock){returnpassword;}}}这里用了两把锁balLock和pwLock,取款和改密码可以同时进行,性能比用一把this锁好很多。这就是细粒度锁的价值:能并行的地方尽量并行。
3.2 资源之间有关联关系
如果多个资源之间有一致性要求,比如转账操作里 A 减钱和 B 加钱必须是一个原子操作,那这两个资源就必须被同一把锁保护。
问题在于:this这把锁是对象级别的,每个 Account 实例都有自己的 this。你锁了 A.this,只能保护 A.balance,保护不了 B.balance。
3.3 专业解释:为什么 this 锁不行
synchronized修饰实例方法时,锁对象是this,也就是当前实例。每个 Account 对象在 JVM 堆里都是独立的实例,各自有各自的对象头(Mark Word),各自持有自己的 Monitor。
线程 1 调用 A.transfer(B, 100),进入同步块,锁定 A 对象的 Monitor;
线程 2 调用 B.transfer(C, 100),进入同步块,锁定 B 对象的 Monitor。
两个 Monitor 是完全不同的对象,JVM 认为这是两个不相关的临界区,所以不会互斥。这就是问题根源。
3.4 大白话翻译
你家的门锁只能锁你家的门,锁不了隔壁老王家的门。你拿自己家的钥匙去开老王家的保险柜,当然打不开。但转账这个操作偏偏要求你同时锁住"你家的保险柜"和"老王家的保险柜",中间谁都不能动。
3.5 生活案例
公司年会上抽奖,奖池里有一等奖、二等奖、三等奖。抽奖这个动作要同时做两件事:从奖池里扣掉一个奖项,往中奖者账户里加一个奖项。这两件事必须一起成功或者一起失败。
如果你给一等奖池配一把锁、二等奖池配一把锁、中奖者账户配一把锁,三个人同时抽奖,一个人抽一等奖扣了奖池但还没加到中奖者,另一个人正好读到这个中间状态,就乱套了。正确做法是:整个抽奖系统就一把大锁,谁抽奖谁拿这把锁,抽完放下,下一个人再拿。
四、演进过程:从错误写法到正确姿势
4.1 第一版错误:this 锁
就是开头那个事故代码,synchronized修饰 transfer 方法。看起来对,实际上锁的范围只覆盖了 this.balance,没覆盖 target.balance。
4.2 第二版思路:传入共享锁对象
既然每个实例自己的 this 不行,那我们就让所有 Account 对象共享一把外部传进来的锁。
classAccount{privateObjectlock;privateintbalance;privateAccount(){}publicAccount(Objectlock){this.lock=lock;}voidtransfer(Accounttarget,intamt){synchronized(lock){if(this.balance>amt){this.balance-=amt;target.balance+=amt;}}}}这个方案在原理上是对的:只要创建 Account 时传入的是同一个 lock 对象,所有实例就共享一把锁。
但问题是:它太依赖人工保证了。在真实项目里,创建 Account 的代码可能分散在十几个工程、几十个类里,你怎么保证每一处 new Account() 传的都是同一个 lock?万一某个新人图省事 new 了一个新 lock 传进去,立刻就回到第一版的 bug。
4.3 第三版正确:Account.class 锁
JVM 加载类的时候,会为每个类创建唯一的Class对象。Account.class这个对象在整个 JVM 里全局唯一,所有 Account 实例天然共享它,不需要你手动传。
classAccount{privateintbalance;voidtransfer(Accounttarget,intamt){synchronized(Account.class){if(this.balance>amt){this.balance-=amt;target.balance+=amt;}}}}这就是最简洁、最可靠的写法。Account.class是类级别的锁,所有 Account 实例共用,不存在传错的问题。
4.4 第四版进阶:按顺序加锁避免死锁
Account.class锁虽然解决了互斥问题,但它是一个全局粗粒度锁,所有转账操作都串行执行,高并发下性能瓶颈明显。更重要的是,如果你在一个方法里需要同时锁两个对象(比如 A 转 B 和 B 转 A 同时发生),用两把对象锁就会死锁。
工业界更成熟的做法是:给每个账户一个唯一 ID,永远先锁 ID 小的账户,再锁 ID 大的账户,这样所有线程的加锁顺序一致,不会循环等待。
importjava.util.concurrent.locks.Lock;importjava.util.concurrent.locks.ReentrantLock;classAccount{privatefinalStringid;privateintbalance;privatefinalLocklock=newReentrantLock();publicAccount(Stringid,intbalance){this.id=id;this.balance=balance;}publicvoidtransfer(Accounttarget,intamt){Accountfirst=this.id.compareTo(target.id)<0?this:target;Accountsecond=first==this?target:this;first.lock.lock();try{second.lock.lock();try{if(this.balance>amt){this.balance-=amt;target.balance+=amt;}}finally{second.lock.unlock();}}finally{first.lock.unlock();}}}这个方案在 CodeSignal、剑桥大学并发课程里都有提到,是银行转账系统的标准解法。它既避免了 this 锁保护不到 target 的问题,又避免了 Account.class 全局串行的性能问题,还避免了交叉加锁导致的死锁。
五、怎么用:企业项目实战代码
5.1 场景一:简单转账(中小项目)
如果你的项目并发量不高,用Account.class锁就够了,简单粗暴不出错。
publicclassAccount{privateStringaccountNo;privateintbalance;publicAccount(StringaccountNo,intbalance){this.accountNo=accountNo;this.balance=balance;}publicvoidtransfer(Accounttarget,intamt){synchronized(Account.class){if(this.balance<amt){thrownewIllegalArgumentException("余额不足");}this.balance-=amt;target.balance+=amt;System.out.printf("[%s] -> [%s] 转出 %d,当前余额 %d,目标余额 %d%n",this.accountNo,target.accountNo,amt,this.balance,target.balance);}}publicintgetBalance(){synchronized(Account.class){returnbalance;}}}测试代码:
publicclassTransferTest{publicstaticvoidmain(String[]args)throwsInterruptedException{Accounta=newAccount("A",200);Accountb=newAccount("B",200);Accountc=newAccount("C",200);Threadt1=newThread(()->a.transfer(b,100));Threadt2=newThread(()->b.transfer(c,100));t1.start();t2.start();t1.join();t2.join();System.out.println("A="+a.getBalance());System.out.println("B="+b.getBalance());System.out.println("C="+c.getBalance());}}正确输出一定是 A=100, B=200, C=300。
5.2 场景二:高并发转账(生产级)
核心支付系统不能用全局锁,必须用按 ID 排序加锁的方案。完整代码见 4.4 节。这里补充一个带超时的版本,防止某个线程卡住永久占用锁:
importjava.util.concurrent.locks.ReentrantLock;importjava.util.concurrent.TimeUnit;publicclassSafeAccount{privatefinalStringid;privateintbalance;privatefinalReentrantLocklock=newReentrantLock();publicSafeAccount(Stringid,intbalance){this.id=id;this.balance=balance;}publicbooleantransfer(SafeAccounttarget,intamt,longtimeoutMs)throwsInterruptedException{SafeAccountfirst=this.id.compareTo(target.id)<0?this:target;SafeAccountsecond=first==this?target:this;if(!first.lock.tryLock(timeoutMs,TimeUnit.MILLISECONDS)){returnfalse;}try{if(!second.lock.tryLock(timeoutMs,TimeUnit.MILLISECONDS)){returnfalse;}try{if(this.balance<amt){returnfalse;}this.balance-=amt;target.balance+=amt;returntrue;}finally{second.lock.unlock();}}finally{first.lock.unlock();}}}tryLock带超时时间,获取不到锁就快速失败,不会让线程傻等,这在生产环境非常重要。
5.3 场景三:保护无关联资源(细粒度锁)
如果你的类里有多个独立字段,不要图省事用一个this锁全包,那样性能太差。应该给每个独立资源配一把专门的锁。
importjava.util.concurrent.locks.ReentrantLock;publicclassBankAccount{privateintbalance;privateStringpassword;privatefinalObjectbalanceLock=newObject();privatefinalObjectpasswordLock=newObject();publicvoidwithdraw(intamt){synchronized(balanceLock){if(balance>=amt){balance-=amt;}}}publicvoidchangePassword(StringoldPwd,StringnewPwd){synchronized(passwordLock){if(oldPwd.equals(password)){password=newPwd;}}}}取款和改密码完全并行,吞吐量直接翻倍。
六、竞品对比:synchronized vs ReentrantLock vs 其他方案
这是面试必考的对比,也是企业选型的核心依据。
| 对比维度 | synchronized | ReentrantLock | Account.class 锁 |
|---|---|---|---|
| 实现方式 | JVM 关键字,底层 monitorenter/monitorexit | JDK API,基于 AQS | synchronized 的一种用法 |
| 锁释放 | 自动释放,出块或异常都释放 | 必须手动 finally 释放,忘了就死锁 | 自动释放 |
| 可重入 | 是 | 是 | 是 |
| 公平锁 | 非公平 | 可选公平/非公平 | 非公平 |
| 尝试获取锁 | 不支持 | tryLock() 支持 | 不支持 |
| 可中断获取 | 不支持 | lockInterruptibly() 支持 | 不支持 |
| 超时获取 | 不支持 | tryLock(timeout) 支持 | 不支持 |
| 多条件变量 | 只有一个 wait/notify | 多个 Condition | 只有一个 |
| 锁粒度 | 对象级/类级 | 对象级,灵活组合 | 类级,全局唯一 |
| 性能(JDK 6+) | 经过锁升级优化,与 ReentrantLock 接近 | 略好于 synchronized,高并发下优势明显 | 等同 synchronized |
| 适用场景 | 简单互斥,代码简洁 | 需要高级功能(超时、中断、多条件) | 跨实例共享资源保护 |
6.1 专业解释
JDK 1.6 之前,synchronized 是重量级锁,性能比 ReentrantLock 差很多。JDK 1.6 引入了锁升级机制:无锁 -> 偏向锁 -> 轻量级锁(CAS 自旋)-> 重量级锁(OS Mutex)。竞争不激烈的时候走偏向锁和轻量级锁,性能非常好;只有竞争激烈、自旋超过阈值才升级到重量级锁。
所以现在的结论是:大部分场景 synchronized 就够了,性能不差,代码还简洁。只有当你需要 tryLock 超时、可中断、多 Condition、公平锁这些高级特性时,才上 ReentrantLock。
6.2 大白话翻译
synchronized 就像自动挡汽车,踩油门就走,熄火自动拉手刹,不用你操心;ReentrantLock 就像手动挡,离合、档位、手刹都得自己来,但你能精确控制每一个动作。日常开自动挡够用了,赛车比赛才需要手动挡。
6.3 生活案例
公司门禁卡:
- synchronized 是公司大门的自动门,人走到门口自动开,出去自动关,你不用管。
- ReentrantLock 是机房的手动门,你得自己刷门禁、自己推门、出来的时候记得带上门,忘了带门别人就能随便进。
- Account.class 锁是整栋大楼唯一一把万能钥匙,不管你在几楼哪个房间,要进去都得找物业拿这把钥匙。
七、常用场景总结
7.1 什么时候用一把锁保护多个资源
- 银行转账:转出账户和转入账户必须同时修改,中间状态不可见。
- 库存扣减:下单时扣减商品库存和增加订单记录,必须原子完成。
- 资金对账:主账户余额和子账户余额必须保持一致。
- 配置热更新:读配置和写配置必须互斥,否则读到半新半旧的配置。
- 单例模式:双重检查锁定,多个资源(实例引用、状态标志)要一起保护。
- 缓存一致性:缓存失效和数据库回源必须串行,防止缓存击穿。
7.2 什么时候用细粒度锁(多锁保护多资源)
- 用户信息模块:余额、密码、头像、昵称互不关联,各用各的锁。
- 日志系统:不同文件的写入互不影响,每个文件一把锁。
- 连接池:每个连接独立管理,不需要全局锁。
- 多队列消费者:不同队列之间不竞争,各锁各的。
7.3 企业项目中的最佳实践
- 优先用 synchronized,简单场景不要上 ReentrantLock。
- 跨实例共享资源,优先考虑 Account.class 这种类级锁。
- 多对象加锁,必须按固定顺序(比如 ID 排序),防止死锁。
- 高并发场景用 tryLock 带超时,不要无限等待。
- 锁的范围尽量小,只包住真正需要原子性的代码,不要把整个方法都锁了。
- 锁对象用
private final修饰,防止外部篡改锁对象。
八、面试官高频面试题
问题 1:synchronized 修饰方法和修饰代码块有什么区别?
答:修饰实例方法时,锁的是当前实例 this;修饰静态方法时,锁的是类对象 Class;修饰代码块时,锁的是括号里指定的对象。修饰代码块更灵活,可以控制锁的粒度,只包住需要互斥的代码段。
问题 2:为什么 synchronized 修饰 transfer 方法还会出并发问题?
答:因为 synchronized 锁的是 this,也就是当前调用方法的对象。转账操作涉及两个对象:this 和 target。this 锁只能保护 this.balance,保护不了 target.balance。两个线程分别操作不同的 Account 实例,持有的是不同的 this 锁,不会互斥。解决方法是用 Account.class 这种共享锁,或者按顺序锁两个对象。
问题 3:synchronized 和 ReentrantLock 的区别?
答:synchronized 是 JVM 关键字,自动释放锁,代码简洁;ReentrantLock 是 JDK API,必须手动 finally 释放,但是支持 tryLock 超时、可中断获取、公平锁、多 Condition。JDK 1.6 之后 synchronized 经过锁升级优化,性能差距不大,简单场景用 synchronized,需要高级功能用 ReentrantLock。
问题 4:synchronized 的锁升级过程?
答:无锁状态 -> 偏向锁(同一个线程反复进入,记录线程 ID)-> 轻量级锁(多个线程交替进入,CAS 自旋)-> 重量级锁(竞争激烈,自旋失败,升级为 OS 级互斥量,线程阻塞)。锁升级是单向的,不能降级。JDK 15 之后偏向锁被废弃。
问题 5:什么是 Account.class 锁?为什么用它?
答:Account.class 是 JVM 加载类时创建的唯一 Class 对象,在整个 JVM 里全局唯一。所有 Account 实例都共享这把锁,不需要手动传入。它解决的是"this 锁只能保护当前实例,保护不了其他实例"的问题。缺点是锁粒度太粗,所有操作全局串行,高并发下性能差。
问题 6:转账操作怎么避免死锁?
答:两个线程互相持有对方需要的锁就会死锁。解决方法是按固定顺序加锁:给每个账户一个唯一 ID,永远先锁 ID 小的账户,再锁 ID 大的账户。这样所有线程加锁顺序一致,不会循环等待。也可以用 tryLock 带超时,获取不到就放弃重试。
问题 7:什么是细粒度锁?什么是粗粒度锁?
答:细粒度锁是每个资源配一把独立的锁,不同资源之间可以并行,性能好但管理复杂;粗粒度锁是一把锁保护所有资源,简单但所有操作串行,性能差。原则是:有关联的资源用一把粗粒度锁保护,无关联的资源用细粒度锁分开保护。
问题 8:synchronized 是公平锁还是非公平锁?
答:synchronized 只能是非公平锁。新线程有可能直接插队获取锁,而不是等队列里的线程先拿。ReentrantLock 默认也是非公平,但可以构造为公平锁。非公平锁吞吐量更高,因为减少了线程切换开销。
九、总结
这篇文章我们讲了互斥锁保护多个资源的核心逻辑:
第一,先判断资源之间有没有关联。没有关联的资源,用细粒度锁分开保护,能并行就并行;有关联的资源,必须用同一把锁保护,保证操作的中间状态对外不可见。
第二,this 锁只能保护当前实例,保护不了其他实例。跨实例的资源保护,要用类级锁(Account.class)或者外部传入的共享锁对象。
第三,多对象加锁必须按固定顺序,防止死锁。高并发场景用 tryLock 带超时,不要无限等待。
第四,选型上,简单场景用 synchronized 就够了,JDK 1.6 之后性能已经很好;需要超时、中断、多条件变量这些高级功能才上 ReentrantLock。
最后记住一句话:原子性的本质不是"不可分割",而是"中间状态对外不可见"。你加锁的目的,就是让别人看不到你操作到一半的样子。
本文为原创文章,如需转载,请联系作者获得授权,并注明出处。