锁的这些概念——synchronized、ReentrantLock、悲观锁、乐观锁、分布式锁——平时写代码零零散散都接触过,但一直没有系统、全面地梳理过。这篇把自己散落的知识点整理成一张全景图:锁有哪些种类、每一类是怎么实现的、什么时候该用哪个。
一、锁解决什么问题
并发下,多个线程(或进程)同时访问一份共享资源,如果不加控制,就会数据错乱——典型的就是"超卖":库存只剩 1,两个请求同时读到 1,都下单,卖出去 2 件。
锁 = 保证"同一时刻只有一个执行单元能访问共享资源"的机制。 谁拿到锁谁访问,别人等着。
二、按作用范围分:本地锁 vs 分布式锁
这是最顶层、也最容易搞混的一个划分。
| 类型 | 作用范围 | 典型实现 | 管不管别的机器 |
|---|---|---|---|
| 本地锁 | 单个 JVM 进程内 | synchronized、ReentrantLock | 不管,别的机器看不见 |
| 分布式锁 | 跨 JVM / 跨机器 | Redis、ZooKeeper、数据库 | 管,全局互斥 |
一句话判断:你的服务是单机部署还是多机部署?单机用本地锁就够;多台服务器抢同一份资源(扣库存、抢优惠券),本地锁管不到别的机器,必须上分布式锁。
三、本地锁怎么实现
1. synchronized(JVM 内置)
synchronized 是 Java 关键字,锁的是对象。JDK 1.6 之后做了大量优化,有个经典的"锁升级"过程:
无锁 → 偏向锁 → 轻量级锁 → 重量级锁- 偏向锁:只有一个线程反复访问时,对象头记录这个线程 ID,进来时比一下 ID 就行,几乎零开销。
- 轻量级锁:出现第二个线程竞争时,用 CAS(Compare And Swap)自旋尝试抢,线程在用户态空转,不阻塞。
- 重量级锁:自旋失败、竞争激烈,升级成操作系统层面的互斥量(mutex),线程阻塞、挂起,交给操作系统调度。
补充:偏向锁因为维护成本高、收益小,JDK 15 已废弃(JEP 374),这里作为历史概念了解即可。
synchronized 的好处是简单(写个关键字就完事,自动加锁释放),坏处是不可控(不能中断、不能超时、只能是非公平锁)。
2. ReentrantLock(基于 AQS)
ReentrantLock 是 java.util.concurrent.locks 里的显式锁,底层是 AQS(AbstractQueuedSynchronizer):
- 用 CAS 原子地改一个
state变量抢锁(state=0 空闲,state=1 占用) - 抢不到就把线程放进一个等待队列,阻塞挂起
- 释放锁时唤醒队列里的下一个线程
比 synchronized 强在:可重入、可中断(lockInterruptibly)、可超时(tryLock)、支持公平锁/非公平锁。
| synchronized | ReentrantLock | |
|---|---|---|
| 用法 | 关键字,自动加/释放 | 显式 lock/unlock(要放 finally) |
| 可重入 | 是 | 是 |
| 公平锁 | 不支持 | 支持(构造参数) |
| 可中断/超时 | 不支持 | 支持 |
| 性能 | 已优化得很好 | 更灵活,场景复杂时用 |
四、数据库锁:悲观锁 vs 乐观锁
数据库层面也有锁,而且面试常问"悲观锁和乐观锁的区别"。
悲观锁:认为冲突一定会发生,先加锁再操作。典型是 SELECT ... FOR UPDATE(行锁),锁住后别人改不了,直到事务提交。
SELECT stock FROM item WHERE id = 1 FOR UPDATE; -- 锁住这一行
-- 扣减库存
UPDATE item SET stock = stock - 1 WHERE id = 1;乐观锁:认为冲突很少发生,操作时不加锁,更新时检查"数据有没有被别人改过"。典型是版本号:
UPDATE item
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = #{oldVersion}; -- 版本变了就说明被别人改过,更新失败| 悲观锁 | 乐观锁 | |
|---|---|---|
| 思路 | 先锁后改 | 先改后校验 |
| 实现 | FOR UPDATE、synchronized | 版本号、CAS、时间戳 |
| 适用 | 冲突多、写多 | 冲突少、读多 |
| 代价 | 阻塞等待 | 冲突时重试/失败 |
乐观锁的底层思想就是 CAS(比较并交换)——"我认为值还是 A,如果是就改成 B,不是就说明别人动过了"。Java 的
AtomicInteger也是这个思路。
五、分布式锁三种实现
多台服务器要互斥,得靠一个"大家都能访问的公共第三方"。常见三种:
1. Redis(最常用)
用 SET NX EX 原子命令占锁,靠过期时间防死锁。优点是快、简单;缺点是不可重入、不自动续期、主从切换可能丢锁——这些 Redisson 都补上了。完整演进见我整理的《Redis 分布式锁详解》和《Redisson 实战》。
2. ZooKeeper(最可靠)
利用 临时顺序节点 + watch 机制:每个客户端创建一个带序号的临时节点,序号最小的拿到锁;没拿到锁的 watch 前一个节点,前一个释放(节点删除)后自己被唤醒。优点是可靠性高、天然可重入、自动释放(客户端挂了临时节点自动删);缺点是性能比 Redis 低、维护成本高。
3. 数据库(最简单)
往一张表插一条唯一记录当锁(INSERT 成功 = 拿到锁),用完删掉。优点是不用额外组件;缺点是性能差、无过期机制(要自己加超时清理),一般只在没 Redis/ZK 时兜底用。
| Redis | ZooKeeper | 数据库 | |
|---|---|---|---|
| 性能 | 高 | 中 | 低 |
| 可靠性 | 中(主从可能丢锁) | 高 | 中 |
| 复杂度 | 低(Redisson 开箱即用) | 高 | 低 |
| 适用 | 大多数场景 | 对一致性要求极高 | 无额外组件时兜底 |
六、其他常见的锁分类
除了上面这些,还有几个按"特性"划分的锁,概念上要知道:
- 可重入锁 vs 不可重入锁:同一个线程能否重复加锁。
synchronized、ReentrantLock、Redisson 的RLock都可重入;裸SETNX不可重入。 - 公平锁 vs 非公平锁:是否按"先来后到"顺序获取。公平锁按队列顺序,非公平锁谁抢到是谁的(默认是非公平,性能更好)。
- 读写锁:
ReentrantReadWriteLock,读读不互斥、读写互斥、写写互斥,适合"读多写少"。 - 自旋锁:抢不到锁时不阻塞,而是循环重试(CAS),适合"锁持有时间极短"的场景,避免线程切换开销。
七、怎么选(场景对照)
| 场景 | 选什么 |
|---|---|
| 单机、简单互斥 | synchronized |
| 单机、需要公平/中断/超时 | ReentrantLock |
| 单机、读多写少 | ReentrantReadWriteLock |
| 数据库、写多冲突多 | 悲观锁(FOR UPDATE) |
| 数据库、读多冲突少 | 乐观锁(版本号) |
| 多机互斥、追求性能 | Redis 分布式锁(Redisson) |
| 多机互斥、追求强一致 | ZooKeeper 分布式锁 |
小结
- 锁的本质:保证"同一时刻只有一个执行单元访问共享资源"
- 顶层分两类:本地锁(单 JVM,synchronized/ReentrantLock)和分布式锁(跨机器,Redis/ZK/DB)
- 本地锁实现:synchronized 走锁升级(偏向→轻量级→重量级),ReentrantLock 走 AQS + CAS + 队列
- 数据库锁分悲观锁(先锁后改,FOR UPDATE)和乐观锁(先改后校验,版本号/CAS)
- 分布式锁三选:Redis(快)、ZooKeeper(可靠)、数据库(简单兜底)
- 按特性还有:可重入、公平/非公平、读写锁、自旋锁
深入某个方向可以接着看:《Redis 分布式锁详解》讲 Redis 锁的完整演进,《Redisson 实战》讲生产级锁的实现,乐观锁在真实高并发下的用法看《秒杀系统架构分析》。
