在Java并发编程的世界里,保证线程安全是我们的第一要务。当多个线程同时争抢同一个共享资源时,我们通常有两种截然不同的“人生哲学”来处理这种冲突:一种是严防死守的悲观锁,另一种是心态平和的乐观锁。
这两者没有绝对的优劣之分,只有适用场景的不同。今天我们就来深入探讨这两种锁的实现原理、应用场景以及它们背后的设计哲学。
一、悲观锁(Pessimistic Locking)—— “狼性锁”
1. 核心思想
悲观锁正如其名,它对数据被修改持悲观态度。
它总是假设最坏的情况: “我一定要在使用数据的时候,绝对会有别的线程来捣乱修改数据。”
因此,为了确保数据安全,悲观锁采取了 “先上锁,后操作” 的策略。在获取数据前,必须先拿到锁,否则就阻塞等待。这种霸道的独占方式,确保了在同一时间内,只有一个线程能操作数据。
一句话总结: 狼性十足,显式锁定,不服就干(阻塞)。
2. Java中的实现
在Java中,悲观锁的典型代表是:
-
synchronized关键字 -
Lock 接口的实现类(如ReentrantLock)
3. 代码示例
import java.util.concurrent.locks.ReentrantLock;
public class PessimisticLockDemo {
// 方式一:synchronized
public synchronized void m1() {
// 加锁后的业务逻辑...
// 整个方法执行期间,其他线程无法进入
}
// 方式二:Lock (注意:Lock对象必须是类的成员变量,保证多线程共享同一个锁对象)
private final ReentrantLock lock = new ReentrantLock();
public void m2() {
lock.lock(); // 显式加锁
try {
// 操作同步资源
System.out.println("正在操作资源...");
} finally {
lock.unlock(); // 必须在finally中解锁,防止死锁
}
}
}4. 适用场景
- 写操作多(Write-Heavy) :由于写操作频繁引发的冲突概率高,悲观锁能有效防止数据错乱。虽然加锁有开销,但相比于乐观锁在冲突时频繁重试带来的CPU空转,悲观锁的阻塞机制在重冲突场景下反而更有效率。
二、乐观锁(Optimistic Locking)—— “佛系锁”
1. 核心思想
乐观锁对数据修改持乐观态度。
它总是假设最好的情况: “我在使用数据的时候,应该不会有别的线程来修改它,所以我不需要加锁。”
它在操作过程中不加锁,而是在更新数据的一瞬间去判断:在此期间有没有别的线程修改过这个数据?
- 如果数据没被修改:当前线程修改成功。
- 如果数据已被修改:根据实现策略,可以选择放弃、报错,或者重试(自旋)。
一句话总结: 佛系心态,得之我幸,不得我命(或者再努力试一次)。
2. Java中的实现
乐观锁通常采用无锁编程来实现,主要有两种判断规则:
(1) 版本号机制 (Versioning)
在数据表中增加一个 version 字段。读取数据时连同版本号一起读出,更新时检查数据库中的版本号是否与读取时一致。如果一致则更新并版本号+1,否则更新失败。
(2) CAS 算法 (Compare And Swap)
这是Java原子类(Atomic包)底层的实现原理。CAS包含三个操作数:
- V:内存值 (Value)
- A:预期的旧值 (Expected)
- B:要修改的新值 (New)
只有当 V == A 时,才将 V 修改为 B,否则什么都不做。
3. 代码示例
Java中的 java.util.concurrent.atomic 包下的类就是典型的乐观锁实现。
import java.util.concurrent.atomic.AtomicInteger;
public class OptimisticLockDemo {
// 保证多个线程使用的是同一个AtomicInteger
private AtomicInteger atomicInteger = new AtomicInteger(0);
public void update() {
// 自增操作 incrementAndGet
// 内部实现了CAS自旋,如果失败会一直重试直到成功
int result = atomicInteger.incrementAndGet();
System.out.println("当前值:" + result);
}
}4. 适用场景
- 读操作多(Read-Heavy) :在读多写少的场景下,冲突很少发生。乐观锁省去了加锁、释放锁以及线程切换的开销,能大幅提升系统的吞吐量和性能。
三、总结与对比
| 特性 | 悲观锁 (Pessimistic) | 乐观锁 (Optimistic) |
|---|---|---|
| 心态 | 悲观,认为一定会有冲突 | 乐观,认为大概率没冲突 |
| 操作方式 | 先加锁,后操作 | 先操作,更新时检测冲突 |
| Java实现 | synchronized, ReentrantLock | Atomic类, CAS算法, 版本号机制 |
| 优点 | 逻辑严谨,保证数据强一致性,适合高并发写 | 性能高,无锁开销,吞吐量大 |
| 缺点 | 线程阻塞、上下文切换开销大 | 高并发写时由于频繁重试导致CPU飙升 |
| 适用场景 | 写多读少 | 读多写少 |
四、面试高频考点 Q&A
在面试中,这部分内容通常会以以下形式考察,建议背诵并理解:
Q1:请简述悲观锁和乐观锁的区别?
A:
- 悲观锁适合写多的场景。它认为并发冲突概率高,因此在获取数据时直接加锁,保证同一时刻只有一个线程能操作数据(如
synchronized)。 - 乐观锁适合读多的场景。它认为并发冲突概率低,因此不加锁,而是在更新数据时通过判断数据是否被修改过(如 CAS 或 版本号)来决定是否更新。
Q2:乐观锁在Java中是如何实现的?
A:
Java中主要通过 CAS (Compare and Swap) 算法实现。例如 AtomicInteger 类,其底层的 incrementAndGet() 方法就是利用 Unsafe 类的 CAS 操作。它包含三个参数:内存地址、预期原值、新值。只有当内存里的值等于预期原值时,才更新为新值,否则通常会通过自旋(循环)的方式不断重试。
Q3:乐观锁有什么潜在的问题?
A:
- ABA问题:如果你读取时是A,准备赋值时它依然是A,但中间它可能被改成B又改回A。CAS会误认为它没变。解决办法是加版本号(如
AtomicStampedReference)。 - CPU开销大:如果并发写很多,CAS会一直失败并一直自旋重试,导致CPU占用率飙升。
- 只能保证一个共享变量的原子性:对于多个变量的复合操作,CAS无法直接保证,通常需要用悲观锁。
Q4:在项目中你是如何选择这两种锁的?
A:
- 如果业务场景是抢购、秒杀、账户转账等并发写操作非常频繁的场景,为了保证数据绝对安全,我会选择悲观锁。
- 如果业务场景是浏览量统计、点赞、评论查询等读多写少,或者允许偶尔重试的场景,为了提升系统性能,我会选择乐观锁。