你有没有遇到过这种场景:代码逻辑完全正确,测试环境跑得欢天喜地,一上生产环境就死锁,整个系统卡得动不了,运维报警电话被打爆。而当你去问组里那位十年工龄的老同事:“咋办?”对方往往只是淡淡一笑,敲了两行代码,问题消失。
这其中的差距,往往不是算法能力,而是对并发控制底层直觉的积累。今天我们就用一个最经典、也最容易踩坑的场景——银行账户转账,来彻底讲清楚悲观锁、死锁产生的原因,以及为什么“固定加锁顺序”是解决死锁的银弹。
一、 先别急着写代码,看看这个“经典悲剧”
假设我们有一个简单的银行转账方法:A 给 B 转 100 块。
新人小王写的代码可能是这样的:
public void transfer(Account from, Account to, int amount) {
// 1. 锁定转出账户
synchronized (from) {
// 检查余额
if (from.getBalance() < amount) {
throw new InsufficientBalanceException("余额不足");
}
// 2. 锁定转入账户
synchronized (to) {
from.setBalance(from.getBalance() - amount);
to.setBalance(to.getBalance() + amount);
}
}
}
看起来没问题对吧?先锁 A,再锁 B,保证原子性。
但是,当两个线程同时执行呢?
- 线程 1:A 给 B 转账。先拿到 A 的锁,准备拿 B 的锁。
- 线程 2:B 给 A 转账。先拿到 B 的锁,准备拿 A 的锁。
这时候,线程 1 拿着 A 的锁等 B 的锁,线程 2 拿着 B 的锁等 A 的锁。** circular wait(循环等待)** 形成了。两个线程互相僵持,谁也不让谁,系统永久卡死。这就是死锁。
很多新人以为加锁就是 synchronized 套 synchronized,或者用 ReentrantLock 随便锁,结果频繁遇到死锁,排查起来简直是噩梦。
二、 为什么“固定加锁顺序”能解决问题?
老员工不会这样写。他们会告诉你:“锁的顺序必须固定,无论谁转给谁,永远按 ID 小的先锁,ID 大的后锁。”
这就是解决死锁的核心心法之一:打破循环等待条件。
只要所有线程都按照同一种规则获取锁(比如账户 ID 从小到大),就不可能出现“你等我,我等你”的环形结构。
让我们看看改造后的代码:
public void transfer(Account from, Account to, int amount) {
// 固定加锁顺序:先锁 ID 小的,再锁 ID 大的
Account first = from.getId() < to.getId() ? from : to;
Account second = from.getId() < to.getId() ? to : from;
synchronized (first) {
synchronized (second) {
// 注意:这里要根据实际是哪个账户转出,来决定加减金额
if (from.getId() == first.getId()) {
if (from.getBalance() < amount) {
throw new InsufficientBalanceException("余额不足");
}
from.setBalance(from.getBalance() - amount);
to.setBalance(to.getBalance() + amount);
} else {
if (to.getBalance() < amount) {
throw new InsufficientBalanceException("余额不足");
}
to.setBalance(to.getBalance() - amount);
from.setBalance(from.getBalance() + amount);
}
}
}
}
这个过程发生了什么?
假设 A 的 ID 是 1,B 的 ID 是 2。
- 线程 1(A→B):先锁 A(ID=1),再锁 B(ID=2)。
- 线程 2(B→A):虽然逻辑上是 B 转给 A,但因为 ID 规则,它也必须先锁 A(ID=1),再锁 B(ID=2)。
这意味着,线程 2 想要获取 B 的锁,必须先拿到 A 的锁。但如果线程 1 已经拿着 A 的锁了,线程 2 就会在等待 A 的锁,而不是抱着 B 的锁去等 A。环形依赖被彻底打破了。
这就是为什么老员工能靠“加锁顺序”规避死锁——他们不是在赌概率,而是在用数学上的偏序关系约束并发行为。
三、 悲观锁的正确姿势:不止是加锁顺序
当然,现实中的银行系统远比这个例子复杂。仅仅知道“固定顺序”还不够,我们还需要理解悲观锁的完整最佳实践。
1. 锁的粒度要尽可能小
很多新人喜欢这么写:
synchronized (this) { // 锁住了整个对象!
// 执行一堆无关的操作
doSomethingElse();
// 才真正操作账户
transfer(from, to);
}
这是大忌。synchronized (this) 锁住了整个对象,其他无关的读取操作也被阻塞了,吞吐量极低。
正确姿势:只对需要保护的数据加锁。如果是对象锁,锁住具体的资源对象(如上面的 Account 对象),而不是整个 Service 或 Controller。
2. 避免在持锁状态下进行耗时操作
synchronized (account) {
// 错误示范:网络请求、日志打印、复杂计算都在锁内
sendNotification(account.getUserId()); // 网络IO,慢!
log.info("转账成功");
account.setBalance(...);
}
持锁期间做耗时操作,会延长锁的持有时间,增加其他线程排队等待的概率,进而提高死锁和性能瓶颈的风险。
正确姿势:先获取数据,释放锁,再处理业务逻辑。
// 1. 加锁读取余额
synchronized (account) {
if (account.getBalance() < amount) {
throw new InsufficientBalanceException();
}
// 记录需要执行的操作,而不是直接执行
operation = new TransferOperation(from, to, amount);
}
// 2. 释放锁后,再执行耗时操作(如果需要)
operation.execute();
3. 使用尝试锁(TryLock)作为安全网
即使你坚信自己的加锁顺序正确,生产环境也可能因为异常分支、超时重试等复杂情况导致顺序被打乱。这时候,可中断的尝试锁 就成了一层重要的安全保障。
使用 ReentrantLock 的 tryLock 方法:
ReentrantLock lockA = new ReentrantLock();
ReentrantLock lockB = new ReentrantLock();
public void transferWithTryLock(Account from, Account to, int amount)
throws InterruptedException {
// 同样固定顺序
ReentrantLock firstLock = from.getId() < to.getId() ? lockA : lockB;
ReentrantLock secondLock = from.getId() < to.getId() ? lockB : lockA;
if (firstLock.tryLock(1, TimeUnit.SECONDS)) {
try {
if (secondLock.tryLock(1, TimeUnit.SECONDS)) {
try {
// 执行转账逻辑
performTransfer(from, to, amount);
} finally {
secondLock.unlock();
}
} else {
// 获取第二把锁超时,放弃并回滚
throw new LockTimeoutException("无法获取第二把锁,请重试");
}
} finally {
firstLock.unlock();
}
} else {
// 获取第一把锁超时
throw new LockTimeoutException("无法获取第一把锁,请重试");
}
}
这样即使出现极端情况,线程也不会无限期阻塞,而是抛出异常,让上层决定是重试还是失败,避免系统彻底死锁。
四、 从银行案例看悲观锁的本质
悲观锁的核心思想是:假设最坏的情况,每次获取数据时都先加锁,确保独占。
在银行转账这个场景里,悲观锁的“悲观”体现在:
- 我们假设并发冲突一定会发生,所以每次操作前都先抢锁。
- 我们不依赖乐观的“无冲突”假设,不像乐观锁那样先读、再 CAS 比较版本。
- 我们通过顺序约束,确保多人抢锁时不会形成死循环。
对于高并发、写入密集、冲突频繁的场景(如银行核心账务系统),悲观锁往往是更稳妥的选择。因为乐观锁在冲突率高时,重试次数会指数级增长,反而更浪费资源。
五、 给新人的实战建议
如果你刚入职,被要求重构一个老旧的转账模块,或者你自己正在设计一个金融类系统,请记住以下几点:
- 永远不要信任“随机”的锁顺序。哪怕只是
if (a < b) lockA then lockB这样的简单规则,也比lock(from) then lock(to)安全得多。 - 用单元测试复现死锁。写一个多线程测试,让 100 个线程随机双向转账,运行几秒钟,看看是否卡死。如果卡死,说明你的锁顺序有问题。
- 日志打锁的获取和释放。在生产环境开启详细的锁日志,一旦出问题,可以通过日志还原现场,看看是哪个线程在等什么。
- 考虑使用数据库级锁。如果业务允许,直接使用
SELECT ... FOR UPDATE在数据库层面加锁,由数据库引擎保证死锁检测和自动回滚,可以减少应用层的复杂度。但要注意,数据库锁的性能开销和锁粒度需要仔细权衡。
结语
死锁不是玄学,它是并发编程中一个可预测、可预防的问题。老员工和新人的差距,往往就在于是否理解并遵守了那些看似简单的“潜规则”——比如加锁顺序。
当你下次再写并发代码时,不妨先问自己:如果两个线程同时请求,我的锁顺序会不会形成环? 如果不会,那你就已经超越了 80% 的并发编程新手。
