锁正确使用

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 字段。

假设表结构里有:balanceversion。更新时带条件: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();
        }
    }
}

这类写法的价值是:避免把线程"锁死在等待上",让系统在压力大时更可控。

相关推荐
long3162 小时前
K‘ 未排序数组中的最小/最大元素 |期望线性时间
java·算法·排序算法·springboot·sorting algorithm
xqqxqxxq2 小时前
洛谷算法1-1 模拟与高精度(NOIP经典真题解析)java(持续更新)
java·开发语言·算法
MengFly_2 小时前
Compose 脚手架 Scaffold 完全指南
android·java·数据库
PPPPickup2 小时前
application.yml或者yaml文件不显示绿色问题
java·数据库·spring
*小海豚*2 小时前
springcloud项目运行启动类无法启动,IDEA也没有任何提示
java·ide
zhougl9962 小时前
Java 枚举类(enum)详解
java·开发语言·python
想七想八不如114082 小时前
2019机试真题
java·华为od·华为
恋爱绝缘体12 小时前
Java语言提供了八种基本类型。六种数字类型【函数基数噶】
java·python·算法
MX_93593 小时前
使用Spring的BeanFactoryPostProcessor扩展点完成自定义注解扫描
java·后端·spring