一、核心思想:不同的并发哲学
乐观锁和悲观锁是处理数据竞争(多个线程可能同时修改同一数据)的两种不同策略。它们的区别源于对“冲突发生概率”的不同假设。
二、对比总结先行
特性 | 悲观锁 | 乐观锁 |
---|---|---|
哲学 | 假设冲突很可能发生 | 假设冲突不太可能发生 |
机制 | 先取锁,再操作 | 先操作,更新前再检查冲突 |
实现 | synchronized, ReentrantLock, FOR UPDATE | CAS、版本号、原子变量 |
线程状态 | 其他线程会**被阻塞(挂起) ** | 其他线程不被阻塞,可继续执行,但可能需要重试 |
开销 | 大(加锁、解锁、上下文切换) | 小(无锁操作,但冲突时重试有开销) |
适用场景 | 写操作多,冲突频繁的场景 | 读操作多,冲突稀少的场景 |
三、悲观锁
1. 定义
悲观锁假定冲突非常可能发生。因此,它在访问共享数据之前,会先独占性地锁定资源,阻止其他任何线程访问,直到它完成操作并释放锁。
2. 工作流程
- 获取锁:线程A在读取数据前,先成功获取到资源的锁。
- 阻塞他人:线程B也想访问该资源,但发现锁已被A持有,于是线程B被挂起(进入阻塞状态),等待锁被释放。
- 操作数据:线程A可以安心地读取数据、进行计算、修改数据。这个过程是排他的。
- 释放锁:线程A完成操作,释放锁。
- 唤醒他人:锁被释放后,系统唤醒正在等待的线程B(和其他线程),它们可以尝试重新获取锁以访问资源。
3. 实现方式
synchronized
关键字(Java)ReentrantLock
等显式锁(Java)SELECT ... FOR UPDATE
(数据库中的行锁/表锁)
4. 优缺点
-
优点:简单粗暴,能保证最高的数据安全性和一致性。
-
缺点:性能开销大。加锁和释放锁的操作本身消耗资源,更重要的是,线程的挂起和唤醒是非常昂贵的操作,会导致上下文切换。如果一个线程持有锁的时间很长,其他所有线程都会被阻塞,严重影响系统吞吐量。
5.适用场景
写多读少的场景,即冲突发生的概率确实很高。在这种情况下,乐观锁会频繁失败重试,反而可能比悲观锁的直接阻塞性能更差。
四、乐观锁
1. 定义
乐观锁假定冲突不太可能发生。因此,它不会在访问数据时立即加锁,而是先直接操作数据。但在更新数据的那一刻,它会检查在此期间是否有其他线程修改过这个数据。如果没有,就更新成功;如果有,就更新失败,并进行相应的处理(通常是重试或报错)。
2. 工作流程
- 读取与记录:线程A读取数据,并记录下数据的版本号(Version)或某种校验值(如CAS中的旧值)。
- 操作数据:线程A在本地进行计算修改(这一步不加锁,所以其他线程可能已经修改了数据)。
- 写前验证:线程A准备将更新写回时,会检查当前的版本号是否和它最初读取的版本号一致。
- 如果一致:说明没有其他线程修改过数据,于是执行更新操作,并通常会增加版本号。
- 如果不一致:说明数据已被其他线程修改,本次更新失败。
- 失败处理:更新失败后,常见的策略是重试(重新读取最新数据和版本号,然后再次执行整个计算和更新流程)。
3. 实现方式
-
CAS指令:CPU级别的原子操作(Compare-And-Swap)。它是乐观锁技术的底层基石。
- 操作:
CompareAndSet(oldValue, newValue)
。只有在当前值等于oldValue
时,才会将其设置为newValue
。
- 操作:
-
原子类:Java中的
AtomicInteger
、AtomicLong
、AtomicReference
等就是基于CAS实现的。 -
版本号机制:数据库和乐观锁框架(如Hibernate)中常用。在数据表中增加一个
version
字段。
4. 优缺点
-
优点:性能高。在没有冲突的情况下,它完全避免了加锁、解锁、线程阻塞和上下文切换的开销,吞吐量极高。
-
缺点:
- ABA问题:一个值原来是A,被另一个线程改为B,后又改回A。使用CAS的线程会误以为它没变过。通常通过附加版本号或时间戳来解决。
- 循环时间长开销大:如果冲突频繁,重试操作会持续进行,可能CPU开销很大。
*** 只能保证一个共享变量的原子操作**。对多个共享变量操作时,CAS无法保证原子性,可能需要用锁。
5.适用场景
读多写少的场景,即冲突发生的概率很低。这是它的理想舞台,能发挥其无锁化的巨大性能优势。
五、结论
选择哪种锁,取决于你对冲突概率的判断。在高并发的互联网应用中,读远多于写是非常普遍的情况,因此乐观锁(及其变种)的应用更为广泛。