1. 并发编程中的锁机制概述
在并发编程的世界里,锁就像交通信号灯,协调着多个线程对共享资源的访问秩序。想象一下没有红绿灯的十字路口——混乱和碰撞几乎不可避免。悲观锁和乐观锁代表了两种截然不同的并发控制哲学,它们分别适用于不同的场景,就像城市道路和高速公路需要不同的交通管理策略。
悲观锁(Pessimistic Lock)采取的是"宁可错杀一千,不可放过一个"的保守策略。它假设冲突是常态,因此在访问数据前会先加锁,确保在自己操作期间不会有其他线程来干扰。这就像去图书馆借阅珍贵古籍时,管理员会要求你登记并暂时保管这本书,直到你归还为止。Java中的synchronized关键字和ReentrantLock就是典型的悲观锁实现。
乐观锁(Optimistic Lock)则秉持"相信世界是美好的"理念,它假设冲突是罕见的,因此不会在访问数据时立即加锁,而是在更新时检查数据是否被修改过。这类似于多人协作编辑文档的场景——大家可以同时编辑,但在保存时会提示你"文档已被他人修改,请合并变更"。CAS(Compare-And-Swap)操作和版本号机制是乐观锁的典型实现方式。
这两种锁策略没有绝对的优劣之分,就像你不能说雨伞比太阳帽更好一样——关键看使用场景。高冲突环境下悲观锁表现更稳定,而低冲突场景中乐观锁能提供更高的吞吐量。理解它们的本质差异和适用边界,是构建高性能并发系统的基石。
2. 悲观锁的深度解析与实现
2.1 悲观锁的核心思想
悲观锁的工作机制可以用"先占坑,后使用"来形象概括。它基于三个核心假设:
- 并发冲突是常态而非例外
- 预防冲突的成本低于解决冲突的成本
- 资源独占是保证一致性的最可靠方式
这种思想在数据库事务隔离级别中体现得尤为明显。当使用SELECT FOR UPDATE语句时,数据库会在读取记录时就加上排他锁,其他事务必须等待当前事务完成才能访问该记录。这就像会议室预定系统——第一个预定的人会锁定会议室,其他人只能等待或选择其他时间。
2.2 Java中的悲观锁实现
Java提供了多种悲观锁实现方式,每种都有其特点和适用场景:
synchronized关键字
java复制public class SynchronizedCounter {
private int count;
public synchronized void increment() {
count++; // 这个操作是原子性的
}
}
synchronized是最简单的悲观锁,JVM会自动管理锁的获取和释放。它的主要特点是:
- 可重入:同一个线程可以重复获取已持有的锁
- 非公平:不保证等待时间最长的线程优先获取锁
- 不可中断:等待锁的线程不能被外部中断
- 自动释放:在方法退出或代码块结束时自动释放
ReentrantLock类
java复制public class ReentrantLockCounter {
private final ReentrantLock lock = new ReentrantLock();
private int count;
public void increment() {
lock.lock(); // 手动获取锁
try {
count++;
} finally {
lock.unlock(); // 必须在finally中释放
}
}
}
ReentrantLock提供了比synchronized更灵活的特性:
- 可设置公平性:减少线程饥饿现象
- 可中断:等待锁的线程可以响应中断
- 尝试获取:tryLock()方法可以避免无限等待
- 条件变量:支持更精细的线程通信
提示:在JDK1.6之后,synchronized经过优化性能已经接近ReentrantLock。除非需要高级功能,否则优先考虑synchronized。
2.3 悲观锁的典型应用场景
悲观锁特别适合以下场景:
- 临界区操作复杂:当共享资源的操作涉及多个步骤,且这些步骤必须作为一个原子单元完成时
- 高冲突环境:当多个线程频繁访问同一资源,且冲突概率较高时
- 长时间持有锁:当线程需要长时间持有资源时(尽管这通常不是好做法)
- 写多读少:当写操作远多于读操作时
数据库中的行锁、表锁,文件系统中的文件锁,以及各种互斥量(Mutex)都是悲观锁的具体应用。在实际开发中,选择悲观锁通常意味着你更看重数据的一致性而非系统吞吐量。
3. 乐观锁的深度解析与实现
3.1 乐观锁的核心思想
乐观锁采用了"先操作,后检查"的策略,其核心假设是:
- 并发冲突是例外而非常态
- 检测冲突的成本低于预防冲突的成本
- 重试机制是可接受的失败处理方式
这种思想在版本控制系统中很常见。比如Git的合并操作:你可以自由地修改本地代码,只有在推送时才会检测冲突。如果发现冲突,Git不会阻止你提交,而是要求你手动解决冲突后再次尝试。
3.2 Java中的乐观锁实现
CAS(Compare-And-Swap)操作
java复制import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
int oldValue;
int newValue;
do {
oldValue = count.get();
newValue = oldValue + 1;
} while (!count.compareAndSet(oldValue, newValue));
}
}
CAS是乐观锁的底层基础,它包含三个操作数:
- 内存位置(V)
- 预期原值(A)
- 新值(B)
当且仅当V的值等于A时,处理器才会用B更新V的值,否则不执行更新。整个操作是一个原子指令,现代CPU通常通过总线锁定或缓存锁定来实现。
Java的原子类(AtomicInteger等)就是基于CAS实现的。它们提供了比synchronized更好的性能,特别是在低冲突环境下。
版本号机制
java复制public class OptimisticLockEntity {
private int id;
private String data;
private int version; // 版本号字段
// getters and setters
}
// 更新时的SQL示例
UPDATE table_name
SET data = 'new value', version = version + 1
WHERE id = 1 AND version = old_version;
版本号机制是另一种常见的乐观锁实现,广泛应用于数据库操作中。其工作流程为:
- 读取数据时获取当前版本号
- 修改数据时检查版本号是否变化
- 如果版本号未变,则更新数据并递增版本号
- 如果版本号已变,则放弃更新或重试
3.3 乐观锁的典型应用场景
乐观锁特别适合以下场景:
- 读多写少:当读操作远多于写操作时
- 低冲突环境:当多个线程同时修改同一资源的概率较低时
- 短操作周期:当每个操作可以在很短时间内完成时
- 可接受重试:当操作失败后重试的成本可以接受时
常见的应用包括:
- 购物网站的库存系统(成千上万人浏览,少数人购买)
- 社交媒体的点赞功能
- 多人在线文档编辑系统
- 缓存系统的更新机制
注意:乐观锁不适用于必须保证一次成功的场景,如支付系统。在这些场景中,你可能需要结合悲观锁或分布式事务来确保强一致性。
4. 悲观锁与乐观锁的实战对比
4.1 性能对比测试
让我们通过一个简单的基准测试来比较两种锁的性能差异。我们创建一个计数器,分别用synchronized、ReentrantLock和AtomicInteger实现,然后在不同线程数下测试它们的吞吐量。
java复制// 测试代码框架
public class LockBenchmark {
private static final int THREAD_COUNT = 100;
private static final int ITERATIONS = 100000;
public static void main(String[] args) throws InterruptedException {
testSynchronized();
testReentrantLock();
testAtomicInteger();
}
static void testSynchronized() throws InterruptedException {
SynchronizedCounter counter = new SynchronizedCounter();
long start = System.currentTimeMillis();
// 创建并启动线程...
long duration = System.currentTimeMillis() - start;
System.out.println("synchronized: " + duration + "ms");
}
// 其他测试方法类似...
}
典型测试结果(单位:操作/秒):
| 线程数 | synchronized | ReentrantLock | AtomicInteger |
|---|---|---|---|
| 1 | 15,000,000 | 14,500,000 | 25,000,000 |
| 4 | 4,200,000 | 4,800,000 | 18,000,000 |
| 16 | 1,100,000 | 1,300,000 | 9,500,000 |
| 64 | 300,000 | 350,000 | 3,200,000 |
从结果可以看出:
- 在单线程情况下,synchronized和ReentrantLock性能接近,AtomicInteger有明显优势
- 随着线程数增加,所有实现性能都下降,但乐观锁下降幅度较小
- 在高并发场景下,AtomicInteger的吞吐量是悲观锁的3-10倍
4.2 适用场景对比分析
让我们从多个维度对比两种锁策略:
| 对比维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 并发假设 | 冲突是常态 | 冲突是例外 |
| 实现复杂度 | 相对简单 | 需要处理重试和冲突解决 |
| 性能特点 | 高冲突下稳定 | 低冲突下高效 |
| 内存开销 | 较高(维护锁状态) | 较低 |
| 适用场景 | 写多读少,临界区复杂 | 读多写少,操作简单 |
| 典型实现 | synchronized, ReentrantLock | Atomic类, 版本号机制 |
| 死锁风险 | 有(需要小心设计) | 无 |
| 线程阻塞 | 会阻塞其他线程 | 不会阻塞,但可能重试 |
| 数据一致性 | 强一致性 | 最终一致性 |
4.3 混合使用策略
在实际项目中,我们往往需要根据不同的业务场景混合使用两种锁策略。以下是一些常见的混合使用模式:
读多写少场景的优化
java复制public class HybridCache {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final AtomicInteger version = new AtomicInteger(0);
private Map<String, Object> cache = new HashMap<>();
public Object get(String key) {
rwLock.readLock().lock();
try {
return cache.get(key);
} finally {
rwLock.readLock().unlock();
}
}
public void put(String key, Object value) {
rwLock.writeLock().lock();
try {
cache.put(key, value);
version.incrementAndGet();
} finally {
rwLock.writeLock().unlock();
}
}
public boolean compareAndPut(String key, Object expectedValue, Object newValue) {
// 乐观锁风格的更新
while (true) {
int currentVersion = version.get();
Object currentValue = get(key);
if (!Objects.equals(currentValue, expectedValue)) {
return false;
}
rwLock.writeLock().lock();
try {
if (version.get() == currentVersion) {
cache.put(key, newValue);
version.incrementAndGet();
return true;
}
} finally {
rwLock.writeLock().unlock();
}
}
}
}
这种混合模式结合了:
- 读写锁(悲观锁)用于常规读写操作
- 版本号机制(乐观锁)用于特定条件的原子更新
- 读操作完全无锁(通过volatile读优化)
数据库应用中的混合策略
在数据库应用中,我们也可以结合两种锁策略:
- 使用悲观锁(SELECT FOR UPDATE)处理核心业务逻辑
- 使用乐观锁(版本号)处理辅助数据的并发更新
- 对于报表类查询,可以使用快照隔离级别避免加锁
5. 常见问题与解决方案
5.1 悲观锁的典型问题
死锁问题
死锁是悲观锁最常见的问题,当多个线程互相等待对方持有的锁时就会发生。典型的死锁场景:
java复制// 线程1
synchronized (lockA) {
synchronized (lockB) {
// 操作
}
}
// 线程2
synchronized (lockB) {
synchronized (lockA) {
// 操作
}
}
解决方案:
- 锁顺序:总是以相同的顺序获取锁
- 锁超时:使用tryLock()设置超时时间
- 死锁检测:定期检查线程依赖关系
锁粒度过大
java复制public class OverlyCoarseLock {
private final Object lock = new Object();
private int counter1;
private int counter2;
public void increment1() {
synchronized (lock) {
counter1++;
}
}
public void increment2() {
synchronized (lock) {
counter2++;
}
}
}
这里两个不相关的计数器使用了同一个锁,导致不必要的竞争。
优化方案:
java复制public class FineGrainedLock {
private final Object lock1 = new Object();
private final Object lock2 = new Object();
private int counter1;
private int counter2;
// 分别使用不同的锁
}
5.2 乐观锁的典型问题
ABA问题
CAS操作存在一个经典问题:如果一个值从A变成B又变回A,CAS会认为它没有被修改过。这在某些场景下会导致问题。
解决方案:
- 使用带版本号的原子引用(AtomicStampedReference)
- 使用布尔标记表示对象是否被修改过
- 对于引用类型,确保对象不会被重用
长时间重试
当冲突频繁时,乐观锁可能导致线程长时间重试,消耗CPU资源。
解决方案:
- 设置最大重试次数
- 在重试间增加随机退避时间
- 对于高冲突场景,考虑切换到悲观锁
5.3 锁选择决策树
为了帮助开发者选择合适的锁策略,我们可以使用以下决策树:
-
操作是否非常简单且可以原子完成?
- 是 → 考虑无锁编程或乐观锁
- 否 → 进入下一步
-
冲突概率高吗?(多个线程频繁修改同一数据)
- 是 → 选择悲观锁
- 否 → 进入下一步
-
操作失败后重试的成本高吗?
- 是 → 选择悲观锁
- 否 → 选择乐观锁
-
需要保证强一致性吗?
- 是 → 选择悲观锁
- 否 → 可以考虑乐观锁
-
性能是关键考量因素吗?
- 是 → 优先考虑乐观锁
- 否 → 可以选择更简单的悲观锁
在实际项目中,我通常会先使用悲观锁实现功能,然后在性能测试中识别热点,逐步将适合的部分改为乐观锁。这种渐进式优化策略可以平衡开发效率和运行时性能。
