并发02:锁机制

从 synchronized 的锁升级机制到 ReentrantLock 的灵活控制,再到读写锁和 StampedLock 的适用场景,系统理解 Java 锁机制的设计思想和使用边界。

字数 1402 阅读时长 ≈ 5 分钟 2026-5-26 2026-7-12
并发02:锁机制

锁的本质是什么

讨论锁时,很容易马上比较 synchronizedReentrantLock 谁性能更好。但项目里真正决定成败的,通常不是锁类型,而是你有没有说清楚:要保护哪段状态,状态必须满足什么规则,临界区能不能变小。

锁的本质不是”让线程排队”,而是保护状态不变量不被并发破坏。比如库存扣减,核心规则不是”同一时间只能一个线程执行”,而是”库存不能扣成负数,同一张订单不能重复扣”。锁只是达成规则的一种手段。

锁的边界由状态规则决定

锁的边界由状态规则决定

看一个最小例子:

if (stock > 0) {
    stock--;
}

单线程里没问题,多线程里就可能出错。两个线程都看到 stock > 0,然后一起扣减,库存就越界了。

加锁后,真正被保护的是”检查库存”和”扣减库存”必须作为一个整体发生:

synchronized (this) {
    if (stock > 0) {
        stock--;
    }
}

这就是临界区。临界区不是随便把一整段业务代码包起来,而是把读状态、判断状态、修改状态这几个必须一致的动作放在一起。

synchronized 的锁升级机制

synchronized 不是一上来就重锁。它的执行成本取决于竞争形态:

锁状态适用场景特点
无锁没有线程竞争最快,无需任何同步
偏向锁只有一个线程反复获取第一次加锁记录线程 ID,后续直接返回
轻量级锁少量线程交替获取通过 CAS 操作更新锁记录
重量级锁多个线程同时竞争挂起等待,进入内核态

锁升级是单向的:偏向锁 → 轻量级锁 → 重量级锁,不会降级。

synchronized 和 ReentrantLock 怎么选

synchronized 的优势是简单。进入和退出代码块自动完成,异常时也会释放锁。Java 6 之后它有偏向锁、轻量级锁、锁膨胀等优化,普通业务场景下不要先假设它性能差。

ReentrantLock 的优势是可控。它支持尝试加锁、可中断等待、公平锁、多个条件队列:

if (lock.tryLock(200, TimeUnit.MILLISECONDS)) {
    try {
        updateState();
    } finally {
        lock.unlock();
    }
}
特性synchronizedReentrantLock
自动释放❌(需手动 unlock)
尝试加锁
可中断等待
公平锁
条件队列✅(Condition)

如果业务需要”等不到就放弃""等待时可被取消""不同条件下唤醒不同线程”,ReentrantLock 更合适。如果只是保护一段短临界区,synchronized 往往更清楚。

Condition 条件队列

ReentrantLockCondition 能把”等待某个条件”和”释放锁进入等待队列”组合起来,适合实现有界队列、连接池这类需要等待不同条件的结构:

Lock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();

// 生产者
lock.lock();
try {
    while (queue.size() == capacity) {
        notFull.await();
    }
    queue.add(item);
    notEmpty.signal();
} finally {
    lock.unlock();
}

读写锁的适用场景

读写锁适合读多写少,并且读操作本身比较重的场景。如果读操作很轻,读写锁的管理成本可能抵消收益。

锁类型特点适用场景
读锁多个读锁可并发查询操作
写锁与所有锁互斥更新操作

更重要的是,读锁之间可以并发,但写锁会和所有读锁互斥;一个长读操作可能让写长时间等待。

StampedLock 的乐观读

StampedLock 提供乐观读,但它不是可重入锁,使用复杂度更高。乐观读必须校验 stamp,校验失败要退回悲观读:

long stamp = lock.tryOptimisticRead();
int value = sharedValue;
if (!lock.validate(stamp)) {
    stamp = lock.readLock();
    try {
        value = sharedValue;
    } finally {
        lock.unlockRead(stamp);
    }
}
return value;

它适合读多写少、读取多个字段组成一致快照的场景,不适合随手替代 ReentrantLock

锁粒度决定吞吐上限

锁粒度越大,越容易保证正确,但吞吐越容易被压低。锁粒度越小,并发度更高,但状态关系更难维护。

粒度特点适用场景
粗粒度一把锁保护所有状态状态关系复杂,一致性要求高
细粒度多把锁保护不同状态状态独立,读写模式不同

但锁也不能拆得太碎。两个状态必须一起变化时,用两把锁分别保护,可能出现中间状态泄漏。比如账户转账,如果只锁转出账户,不锁转入账户,就可能出现余额总和短暂不一致,甚至死锁风险。

常见误区

误区一:看到并发问题就加锁

锁能保证互斥,但也会带来等待、死锁和吞吐下降。有些问题更适合唯一索引、状态机、幂等表或队列串行化。

误区二:锁住对象就等于锁住数据

synchronized (new Object()) 每次都是新对象,根本锁不住;本地锁也锁不住其他服务实例。锁对象必须和共享状态有稳定关系。

误区三:读操作一定不用锁

如果读写之间有不变量,比如读取多个字段组成一个一致快照,那么读也可能需要同步,或者使用不可变对象、volatile 引用替换、读写锁等方式保证可见性和一致性。

项目里怎么落地

处理并发写时,可以按这个顺序判断:

  1. 共享状态在哪里:JVM 内存、数据库、Redis,还是外部系统
  2. 要保护的不变量是什么:不能重复、不能越界、不能回退,还是总量守恒
  3. 冲突概率高不高:失败后能不能重试
  4. 临界区能不能缩小:耗时操作能不能移出锁外
  5. 多实例部署时:本地锁是否已经失效

如果只能记一句话:锁不是为了”让线程排队”而存在,它是为了保护某个状态规则不被并发破坏。先找到规则,再选择锁。