锁详解
# 锁详解
# 死锁问题
# 什么是线程死锁?
线程死锁是指:两个或多个线程互相持有对方所需要的资源而互相等待的状态,导致程序无法继续执行下去,进而陷入死循环,无法完成任务。
# 死锁产生的原因
通常情况下,线程死锁产生的原因是: 两个或多个线程对资源的竞争和不当的资源分配。
# 死锁产生的条件
线程死锁的产生通常需要同时满足以下四个条件:
- 互斥条件:该资源任意一个时刻只由一个线程占用。
- 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件: 若干线程之间形成一种头尾相接的循环等待资源关系。
# 如何预防死锁?
预防死锁只要破坏死锁产生的必要条件即可:
- 破坏请求与保持条件:一次性申请所有的资源。
- 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
# 如何避免死锁?
避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
# 乐观锁和悲观锁
乐观锁和悲观锁是并发编程中非常重要的两种并发控制策略,在数据库和 Java 应用中都非常常见。
# 什么是乐观锁?
乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源是否被其它线程修改了。
定义
乐观锁认为并发修改是小概率事件,每次修改前不加锁,而是在更新时检查是否有别的线程/事务更新过。
特点
- 非阻塞,不会造成线程阻塞(优点)
- 更适合读多写少的场景
- 冲突时通过重试机制来保障正确性
缺点
- 在并发更新的情况下,可能会出现 ABA 问题,需要使用版本号或时间戳等机制来解决。
ABA 问题是: 在使用 CAS 算法时可能出现的一个问题。
它的本质是: 由于线程之间的竞争,导致共享数据的值在某个时间点被修改为 A,然后又被修改为 B,最后再被修改回 A,
这时候使用 CAS 算法时,比较的是共享数据的值是否等于 A,如果等于 A,则执行操作,但实际上共享数据的值已经被修改过了。
简单来说,就是在使用 CAS 算法的时候发生了误判。
典型代表
比如:使用版本号机制、CAS 算法
# 什么是悲观锁?
悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
定义
悲观锁认为操作数据时会有并发冲突,因此在访问数据前就先上锁。
- 阻塞型锁;
- 常通过数据库或同步关键字加锁;
- 一旦加锁,其他线程/事务就必须等待。
优点
- 安全,能够保证数据操作的正确性和一致性。
缺点
- 并发性能较差,在高并发的情况下,会造成大量的线程阻塞,降低系统的性能
- 容易导致死锁或性能瓶颈
典型代表
比如:Java 中 的 synchronized
和 ReentrantLock
等独占锁,数据库中的行级锁和表级锁。
# 如何实现乐观锁?
# 版本控制
在操作共享资源之前,先读取数据的版本号,然后将操作结果与当前版本号进行比较,
- 如果版本号一致,则可以进行操作,
- 如果版本号不一致,则说明数据已被其他线程修改,需要回滚并重试。
# 数据库中加一个 version 字段,每次更新时带上旧版本号并 +1
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 5;
2
3
4
# 时间戳机制(Timestamp)
- 和版本号类似,每次更新时检查上次修改时间。
- 适用于对“最后修改时间”有天然依赖的业务逻辑。
UPDATE table SET ... WHERE update_time = '2025-01-01 12:00:00'
# CAS 算法
CAS 的全称是 Compare And Swap(比较与交换),用于实现乐观锁,被广泛应用于各大框架中。
CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
是一个原子操作,底层依赖于一条 CPU 的原子指令。
在 Java 中,
AtomicInteger
,AtomicLong
,AtomicReference
这些都是基于乐观锁的典型实现。在高并发下无需加锁,但可能会出现 ABA 问题。
AtomicInteger counter = new AtomicInteger(0);
counter.compareAndSet(0, 1); // 如果当前是0,就更新为1
2
原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。
# 数据库唯一索引控制幂等(也是一种乐观控制)
通过业务唯一约束 + 插入失败来做并发控制,比如消息幂等性处理:
INSERT INTO message_log(id, content) VALUES(1001, 'message')
-- 如果ID重复插入失败,说明已处理过
2
# 如何实现悲观锁?
悲观锁的实现方式主要有两种:基于数据库的悲观锁和基于代码的悲观锁。
基于数据库的悲观锁
基于数据库的悲观锁是通过数据库的锁机制来实现的。在数据库中,可以通过
SELECT ... FOR UPDATE
语句或SELECT ... FOR SHARE
语句来获取悲观锁。- 当一个事务执行
SELECT ... FOR UPDATE
语句时,数据库会将所选的行加上排他锁,其他事务不能修改这些行; - 当一个事务执行
SELECT ... FOR SHARE
语句时,数据库会将所选的行加上共享锁,其他事务只能读取这些行,不能修改。在使用完锁后,需要及时释放锁,避免长时间占用数据库资源。
- 当一个事务执行
基于代码的悲观锁
基于代码的悲观锁是通过程序代码来实现的。在 Java 中,可以使用
synchronized
关键字或Lock
接口来实现悲观锁。- 使用
synchronized
关键字时,需要在方法或代码块上加锁,以确保同一时间只有一个线程可以执行这段代码; - 使用
Lock
接口时,需要先获取锁(调用lock()
方法),然后执行操作,最后释放锁(调用unlock()
方法),以确保同一时间只有一个线程可以操作共享资源。
- 使用
# 对比总结
特性 | 悲观锁 | 乐观锁 |
---|---|---|
加锁方式 | 操作前加锁 | 操作后校验 |
性能 | 较低(有锁竞争) | 高(无锁,但可能重试) |
适用场景 | 写多并发高 | 读多写少、冲突小 |
实现方式 | synchronized , ReentrantLock , FOR UPDATE | 版本号、时间戳、CAS、唯一索引 |
# 项目中如何选择?
- 库存扣减、金融交易类高并发写场景 → 乐观锁优先(如版本号)
- 复杂修改操作、强一致场景(如银行转账) → 悲观锁
- 如果使用 MySQL,读多写少的场景更适合乐观锁,否则可能死锁、锁表。