1)业务背景:一段最常见的并发代码到底会出什么事
下面这个场景你一定写过:用户下单要扣余额(或扣库存)。并发一高,"同时扣同一个账户"就会发生。
如果你写成这样(伪代码)
// 不加任何锁:错误示范
Account acc = repo.findById(id); // 读余额 = 100
acc.balance = acc.balance - 50; // 变成 50
repo.save(acc); // 写回
两个线程同时进来,有可能都读到 100,然后都写回 50,导致少扣(丢失更新)。锁要解决的就是这类问题:让更新"要么串行,要么检测冲突"。
2)悲观锁:先锁住,再动数据(数据库行锁范式)
悲观锁的典型写法是:事务里先把要改的行锁住,其他人只能等。
下面是一段很常见的 Java + JDBC 示例(关键在 FOR UPDATE):
import java.sql.*;
public class PessimisticLockDemo {
public void debit(Connection conn, long accountId, long amount) throws Exception {
conn.setAutoCommit(false);
try (PreparedStatement ps1 = conn.prepareStatement(
"SELECT balance FROM account WHERE id=? FOR UPDATE")) {
ps1.setLong(1, accountId);
ResultSet rs = ps1.executeQuery();
if (!rs.next()) throw new IllegalStateException("account not found");
long balance = rs.getLong(1);
if (balance < amount) throw new IllegalStateException("insufficient balance");
try (PreparedStatement ps2 = conn.prepareStatement(
"UPDATE account SET balance = balance - ? WHERE id=?")) {
ps2.setLong(1, amount);
ps2.setLong(2, accountId);
ps2.executeUpdate();
}
conn.commit();
} catch (Exception e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(true);
}
}
}
这段代码的并发语义非常直接:
同一账户的同一行,谁先 FOR UPDATE 成功,谁先改;后来的线程会在数据库里等待锁释放。它的优点是"确定性强",缺点是"并发时会排队",慢事务会把队伍拖长。
你会发现悲观锁适合那种"不能失败、不能重试"的地方,比如余额/库存这种,失败不是性能问题,是事故问题。
3)乐观锁:不锁,更新时做版本校验(失败就重试)
乐观锁最常见的做法是:表里加一个 version 字段。
假设表结构里有:balance、version。更新时带条件:WHERE id=? AND version=?。如果更新行数是 0,说明别人先改了,你这次就必须失败,然后重读重算。
代码可以写成这样(仍然是 JDBC,逻辑更清晰):
import java.sql.*;
public class OptimisticLockDemo {
public void debit(Connection conn, long accountId, long amount) throws Exception {
// 一般会做有限次重试
for (int attempt = 0; attempt < 3; attempt++) {
AccountSnapshot snap = readSnapshot(conn, accountId);
if (snap.balance < amount) throw new IllegalStateException("insufficient balance");
int rows = tryUpdate(conn, accountId, amount, snap.version);
if (rows == 1) return; // 成功
// rows == 0 代表版本不对:有人先改过了 -> 重试
}
throw new IllegalStateException("too much contention, please retry later");
}
private AccountSnapshot readSnapshot(Connection conn, long id) throws Exception {
try (PreparedStatement ps = conn.prepareStatement(
"SELECT balance, version FROM account WHERE id=?")) {
ps.setLong(1, id);
ResultSet rs = ps.executeQuery();
if (!rs.next()) throw new IllegalStateException("account not found");
return new AccountSnapshot(rs.getLong(1), rs.getLong(2));
}
}
private int tryUpdate(Connection conn, long id, long amount, long version) throws Exception {
try (PreparedStatement ps = conn.prepareStatement(
"UPDATE account " +
"SET balance = balance - ?, version = version + 1 " +
"WHERE id = ? AND version = ?")) {
ps.setLong(1, amount);
ps.setLong(2, id);
ps.setLong(3, version);
return ps.executeUpdate();
}
}
private static class AccountSnapshot {
final long balance;
final long version;
AccountSnapshot(long balance, long version) { this.balance = balance; this.version = version; }
}
}
这段代码的并发语义是:
大家可以同时读,但只有一个更新能"踩中"版本 。其他线程不会在数据库里排队,它们会更新失败然后重试。
它更适合"冲突概率低"的地方:比如用户资料、配置、文章编辑等。冲突真的多的时候,它会变成"大家都在重试",CPU 忙但有效进展很少,所以通常会加:有限次重试 + 退避(sleep 一下)+ 失败提示。
4)死锁:同一段业务,锁顺序不一致就能把系统卡死
死锁的本质不是"你忘了释放锁",而是"你拿锁的顺序不一致"。
下面这段 Java 代码就很典型:两个线程各拿一把锁,然后互等对方。
public class DeadlockDemo {
private final Object lockA = new Object();
private final Object lockB = new Object();
public void t1() {
synchronized (lockA) {
sleep(50);
synchronized (lockB) {
// do something
}
}
}
public void t2() {
synchronized (lockB) {
sleep(50);
synchronized (lockA) {
// do something
}
}
}
private void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
}
}
为什么它会死锁?运行时可能出现这种顺序:
-
线程1拿到 lockA
-
线程2拿到 lockB
-
线程1等 lockB
-
线程2等 lockA
这时两边都不可能继续推进。
工程里解决死锁,最常用、最便宜、最稳的一招是:统一加锁顺序。比如规定"永远先锁 A 再锁 B",任何地方都不许反过来。只要顺序统一,循环等待的条件就被破坏,死锁就不成立。
5)读写锁:读多写少时,用它比互斥锁更划算
有些共享数据结构是"读非常多、写很少",比如一份配置缓存、路由表、字典映射。用互斥锁会导致读操作也互相堵住,吞吐量浪费。
这时用读写锁:读可以并发,写需要独占。
import java.util.*;
import java.util.concurrent.locks.*;
public class ReadWriteLockDemo {
private final Map<Long, String> cache = new HashMap<>();
private final ReadWriteLock rw = new ReentrantReadWriteLock();
public String get(long id) {
rw.readLock().lock();
try {
return cache.get(id);
} finally {
rw.readLock().unlock();
}
}
public void put(long id, String value) {
rw.writeLock().lock();
try {
cache.put(id, value);
} finally {
rw.writeLock().unlock();
}
}
}
这段代码的关键点是:
读锁之间不互斥,写锁会挡住所有读写。适合"读多写少",写多的话收益会变小甚至不如互斥锁。
6)tryLock:把"永久等待"降级成"拿不到就算了"
你有时不希望线程无限期排队(尤其是可能出现死锁风险、或请求超时敏感时)。可以用 tryLock:拿不到锁就直接失败或走降级逻辑。
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class TryLockDemo {
private final ReentrantLock lock = new ReentrantLock();
public boolean doWork() throws InterruptedException {
if (!lock.tryLock(200, TimeUnit.MILLISECONDS)) {
// 拿不到锁:直接返回失败/降级
return false;
}
try {
// 临界区
return true;
} finally {
lock.unlock();
}
}
}
这类写法的价值是:避免把线程"锁死在等待上",让系统在压力大时更可控。