并发02:锁机制
从 synchronized 的锁升级机制到 ReentrantLock 的灵活控制,再到读写锁和 StampedLock 的适用场景,系统理解 Java 锁机制的设计思想和使用边界。
锁的本质是什么
讨论锁时,很容易马上比较 synchronized 和 ReentrantLock 谁性能更好。但项目里真正决定成败的,通常不是锁类型,而是你有没有说清楚:要保护哪段状态,状态必须满足什么规则,临界区能不能变小。
锁的本质不是”让线程排队”,而是保护状态不变量不被并发破坏。比如库存扣减,核心规则不是”同一时间只能一个线程执行”,而是”库存不能扣成负数,同一张订单不能重复扣”。锁只是达成规则的一种手段。
锁的边界由状态规则决定
看一个最小例子:
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();
}
}
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 自动释放 | ✅ | ❌(需手动 unlock) |
| 尝试加锁 | ❌ | ✅ |
| 可中断等待 | ❌ | ✅ |
| 公平锁 | ❌ | ✅ |
| 条件队列 | ❌ | ✅(Condition) |
如果业务需要”等不到就放弃""等待时可被取消""不同条件下唤醒不同线程”,ReentrantLock 更合适。如果只是保护一段短临界区,synchronized 往往更清楚。
Condition 条件队列
ReentrantLock 的 Condition 能把”等待某个条件”和”释放锁进入等待队列”组合起来,适合实现有界队列、连接池这类需要等待不同条件的结构:
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 引用替换、读写锁等方式保证可见性和一致性。
项目里怎么落地
处理并发写时,可以按这个顺序判断:
- 共享状态在哪里:JVM 内存、数据库、Redis,还是外部系统
- 要保护的不变量是什么:不能重复、不能越界、不能回退,还是总量守恒
- 冲突概率高不高:失败后能不能重试
- 临界区能不能缩小:耗时操作能不能移出锁外
- 多实例部署时:本地锁是否已经失效
如果只能记一句话:锁不是为了”让线程排队”而存在,它是为了保护某个状态规则不被并发破坏。先找到规则,再选择锁。