本文目录导读:

- 目录导读
- 乐观锁是什么?核心原理图解
- 为什么Java项目中必须掌握乐观锁?
- 常见面试题:乐观锁 vs 悲观锁,何时用谁?
- 经典案例:基于CAS实现库存扣减(含完整代码)
- 进阶实战:数据库乐观锁(版本号机制)在订单系统中的应用
- 踩坑指南:乐观锁的ABA问题、自旋开销与解决方案
- FAQ常见问答(开发者最关心的5个问题)
- 乐观锁的最佳实践与代码模板
Java案例怎么使用乐观锁?从原理到实战,一篇搞定并发冲突
目录导读
-
乐观锁是什么?核心原理图解
-
为什么Java项目中必须掌握乐观锁?
-
常见面试题:乐观锁 vs 悲观锁,何时用谁?
-
经典案例:基于CAS实现库存扣减(含完整代码)
-
进阶实战:数据库乐观锁(版本号机制)在订单系统中的应用
-
踩坑指南:乐观锁的ABA问题、自旋开销与解决方案
-
FAQ常见问答(开发者最关心的5个问题)
-
乐观锁的最佳实践与代码模板
乐观锁是什么?核心原理图解
乐观锁是一种“乐观”的并发控制策略,它假设多线程同时操作同一数据时,大部分情况下不会发生冲突,因此只在数据提交更新时,才检查数据是否被其他线程修改过,如果未被修改,则正常写入;如果已被修改,则进行重试或报错。
核心机制有三种实现方式:
- CAS(Compare And Swap):Java原子类底层核心,比较当前内存值与期望值是否相等,相等则替换为新值。
- 版本号机制:在数据表中增加一个
version字段,每次更新时检查版本号是否一致,一致则更新并version+1。 - 时间戳机制:类似版本号,使用时间戳判断数据是否过期。
图解流程(文字描述):
线程A读取数据(版本号=1)→ 执行业务逻辑 → 准备更新时检查版本号:若仍为1则更新并设版本号=2;若其他线程已改为2,则重试读取最新数据。
为什么Java项目中必须掌握乐观锁?
在Web应用、微服务、高并发秒杀场景中,悲观锁(如synchronized、ReentrantLock)会导致线程阻塞、性能下降,尤其在读多写少场景下,乐观锁凭借无锁化设计,能大幅提升吞吐量。
典型应用场景:
- 电商库存扣减(防止超卖)
- 分布式系统唯一ID生成
- 缓存一致性更新(如Redis + 数据库双写)
- 账户余额增减操作
性能对比数据(非精确,仅示意):
- 悲观锁:1000并发下TPS约2000,CPU空转较少但有锁竞争。
- 乐观锁:1000并发下TPS约8000,但重试次数增加时CPU消耗上升。
常见面试题:乐观锁 vs 悲观锁,何时用谁?
| 维度 | 乐观锁 | 悲观锁 |
|---|---|---|
| 原理 | 无锁,更新时检查冲突 | 直接加锁,阻塞其他线程 |
| 适用场景 | 读多写少、冲突频率低 | 写多读少、冲突激烈 |
| 性能特点 | 高吞吐,但自旋消耗CPU | 低吞吐,但公平性好 |
| 实现方式 | CAS、版本号、时间戳 | synchronized、Lock |
| 典型问题 | ABA问题、自旋开销 | 死锁、性能瓶颈 |
建议:
- 库存扣减、积分调整等高并发写但冲突相对可控的场景,优先选乐观锁。
- 银行转账、订单支付等强一致性、冲突不可避免的场景,选悲观锁更安全。
经典案例:基于CAS实现库存扣减(含完整代码)
需求:模拟电商商品库存扣减,要求高并发下不超卖。
方案:使用java.util.concurrent.atomic.AtomicInteger实现乐观锁。
import java.util.concurrent.atomic.AtomicInteger;
public class OptimisticLockDemo {
// 模拟库存,初始100
private static AtomicInteger stock = new AtomicInteger(100);
public static boolean reduceStock(int quantity) {
while (true) {
int current = stock.get();
if (current < quantity) {
System.out.println(Thread.currentThread().getName() + " 库存不足,当前库存:" + current);
return false;
}
// CAS操作:期望值为current,新值为current-quantity
boolean success = stock.compareAndSet(current, current - quantity);
if (success) {
System.out.println(Thread.currentThread().getName() + " 扣减成功,剩余库存:" + stock.get());
return true;
}
// 失败则自旋重试(实际生产建议加限制,避免无限循环)
}
}
public static void main(String[] args) throws InterruptedException {
// 模拟100个线程并发扣减1件商品
for (int i = 0; i < 100; i++) {
new Thread(() -> reduceStock(1)).start();
}
Thread.sleep(3000);
System.out.println("最终库存:" + stock.get());
}
}
核心要点:
compareAndSet是原子操作,由底层CPU指令保障。- 自旋重试确保最终成功,但高冲突时可能造成CPU飙升,解决方案:加入重试次数限制(如失败3次则放弃)或结合
LongAdder减少自旋。
进阶实战:数据库乐观锁(版本号机制)在订单系统中的应用
场景:用户取消订单,同时系统进行自动退款,需保证订单状态不被重复更新。
表结构设计:
CREATE TABLE `order` ( `id` bigint(20) NOT NULL, `status` tinyint(4) DEFAULT '0' COMMENT '0-待支付,1-已支付,2-已取消', `version` int(11) DEFAULT '0' COMMENT '乐观锁版本号', PRIMARY KEY (`id`) );
Java代码实现:
public int cancelOrder(Long orderId, Integer expectVersion) {
// 1. 查询当前订单版本号(略)
// 2. 执行更新,条件加版本号
String sql = "UPDATE `order` SET status=2, version=version+1 WHERE id=? AND version=?";
int rows = jdbcTemplate.update(sql, orderId, expectVersion);
// 3. 判断影响行数
if (rows == 0) {
throw new RuntimeException("订单已被其他操作修改,请刷新后重试");
}
return rows;
}
注意事项:
- 更新语句中必须同时带
version条件,否则乐观锁失效。 - 高并发时,可配合
@Retryable注解实现自动重试(如Spring Retry)。 - 更新时必须
version+1,而不是随意修改。
踩坑指南:乐观锁的ABA问题、自旋开销与解决方案
1 ABA问题
现象:线程1读取A,线程2将其改为B再改回A,线程1误认为数据未被修改。
解决方法:
- 使用
AtomicStampedReference或AtomicMarkableReference,加入版本号/时间戳。 - 数据库场景使用
version字段即可避免,因为每次更新version都会变。
2 自旋开销过大
场景:高冲突场景下,线程不断循环执行CAS,导致CPU飙升。
解决方案:
- 限制自旋次数(如失败20次后挂起线程)。
- 退避策略:每次失败后
Thread.yield()或短暂休眠(Thread.sleep(1))。 - 改用
LongAdder(分段累加)或LongAccumulator(分组更新)。
3 数据库乐观锁重试死循环
推荐做法:
- 在业务层最大重试3~5次。
- 使用Spring
@Retryable+@Recover优雅处理。
@Retryable(value = Exception.class, maxAttempts = 3, backoff = @Backoff(delay = 100))
public void optimisticRetryUpdate() {
// 乐观锁更新逻辑
}
@Recover
public void recover(Exception e) {
log.error("乐观锁重试3次仍失败,记录告警日志", e);
}
FAQ常见问答(开发者最关心的5个问题)
Q1:乐观锁一定比悲观锁快吗? A:不一定,当冲突概率很高时,乐观锁不断重试消耗大量CPU,此时悲观锁加锁阻塞反而效率更高,建议根据压测结果选择。
Q2:Redis可以实现乐观锁吗?
A:可以,Redis的WATCH命令配合事务实现CAS机制。WATCH stock → 获取库存 → MULTI → DECR stock → EXEC,若库存被其他客户端修改,则事务失败。
Q3:MySQL中乐观锁更新时,为什么不能用set version=version+1替代where条件?
A:必须同时有WHERE version=旧版本号,否则所有更新都会成功,乐观锁失效,只有版本号匹配,才说明数据是期望的。
Q4:Java中的synchronized是悲观锁吗?
A:是的,在Java中,synchronized和ReentrantLock都是典型的悲观锁实现,它们会阻塞未获取锁的线程。
Q5:微服务间事务如何使用乐观锁?
A:可以通过分布式锁(如Redis Redisson)或数据库行锁(SELECT ... FOR UPDATE)实现乐观锁,但分布式场景下,推荐使用版本号机制,在每个服务调用时传递版本号。
乐观锁的最佳实践与代码模板
最佳实践四步法:
- 评估冲突概率:如果读多写少、冲突概率<20%,直接使用乐观锁。
- 选择实现方式:
- 单机简单场景:
AtomicInteger/LongAdder - 数据库场景:
version字段 +WHERE version=期望值 - 分布式场景:Redis
WATCH+ 事务,或ZooKeeper“版本号”节点
- 单机简单场景:
- 加入重试容错:限制自旋次数(最多3~5次),失败后回退或告警。
- 监控与告警:记录乐观锁重试次数,如果重试率过高(如>5%),检查是否需要改用悲观锁或优化数据分片。
通用代码模板(数据库乐观锁):
@Transactional
public boolean optimisticUpdate(Long id, int expectedVersion, String newStatus) {
String updateSql = "UPDATE my_table SET status=?, version=version+1 WHERE id=? AND version=?";
int rows = jdbcTemplate.update(updateSql, newStatus, id, expectedVersion);
if (rows == 0) {
// 可重试:重新读取新版本,再次更新
throw new OptimisticLockException("数据已被修改,请重试");
}
return true;
}
一句话总结:乐观锁不是银弹,但它用合理的重试成本和原子操作,换来了高并发下极致的吞吐量——只要掌握好它的适用边界和容错机制,它就是Java并发编程的“神兵利器”。
本文参考了官方Java文档、MySQL实战经验及多家互联网公司的生产落地案例,结合伪原创处理,确保内容既专业又符合搜索引擎收录规范,请放心转载使用。