锁机制详解:synchronized、ReentrantLock、读写锁与分布式锁的边界
对应简历段落
在保险销售活动、商机流转和月底结算链路中,负责高并发下的共享状态保护、幂等控制和重复处理治理。熟悉 Java 锁机制,包括 synchronized、ReentrantLock、读写锁以及 Redis/ZooKeeper 分布式锁的适用边界,能够结合数据库唯一索引、状态机、事务消息和补偿机制保证业务一致性。
这段简历面试时非常容易被追问。面试官不是想听“synchronized 是关键字、ReentrantLock 是类”,而是想知道你是否明白:单 JVM 锁和分布式锁保护的边界不同;锁不是越大越安全;锁只能保护临界区,不能替代幂等;高并发系统的正确性往往要靠锁、事务、唯一约束和状态流转共同完成。
业务背景
保险销售系统里有大量“不能重复”的动作。一个客户不能在同一活动下重复领取权益;同一投保意向不能被两个代理人同时认领;同一结算批次不能重复生成佣金明细;同一保司回调不能反复推进订单状态。高峰期这些动作会被用户重复点击、MQ 重试、定时任务补偿、运营后台批量操作同时触发。
如果只在代码里加一个本地锁,在单机部署时可能暂时有效;一旦服务多实例部署,同一个业务单号的请求可能打到不同机器,本地锁就失效。如果所有地方都上分布式锁,又会增加 Redis 压力、锁等待时间和故障复杂度。资深工程实践的关键,是先识别资源边界:这个共享状态是在单 JVM 内、单数据库行内、跨进程、跨服务,还是跨外部系统。
比如活动库存扣减。如果库存存在数据库表中,最核心的保护应该是数据库原子更新和唯一流水,而不是单纯 Redis 锁。比如本地缓存刷新,多个线程同时构建同一份缓存,可以用 JVM 内锁避免缓存击穿。比如月底结算批次被多台机器扫描,可以用数据库任务抢占或分布式锁协调。不同场景的锁完全不同。
核心原理
synchronized 是 JVM 内置监视器锁,具备互斥和可见性。进入同步块前会获取对象监视器,退出同步块会释放锁。它是可重入锁,同一线程可以重复进入同一把锁保护的代码。现代 JVM 对它做了很多优化,包括偏向锁历史机制、轻量级锁、自旋、锁消除、锁粗化等。虽然不同 JDK 版本实现细节会变化,但面试表达时抓住语义即可:它简单、可靠、自动释放,适合临界区短、竞争不复杂的场景。
ReentrantLock 是基于 AQS 的显式锁。相比 synchronized,它支持可中断等待、超时获取、公平锁选择、多个条件队列。代价是必须手动释放,通常要写在 finally 中。它适合需要 tryLock、等待可中断、精细条件唤醒的场景。比如批量任务抢占时,拿不到锁就跳过当前批次,而不是阻塞线程。
读写锁的核心思想是读读共享、读写互斥、写写互斥。ReentrantReadWriteLock 适合读多写少、读操作耗时相对明显、共享数据结构较稳定的场景。它不适合写频繁场景,也不能解决跨 JVM 一致性。使用读写锁时要关注锁降级、写锁饥饿、读锁持有期间禁止升级写锁等问题。
分布式锁用于协调多个进程或多台机器访问同一逻辑资源。常见实现是 Redis SET key value NX PX ttl 加 Lua 脚本释放,或者 ZooKeeper 临时顺序节点。Redis 锁性能高但要处理过期、误删、续期、主从切换风险;ZooKeeper 锁一致性更强但性能和运维成本更高。分布式锁不是银弹,它只能降低并发冲突概率,最终业务正确性仍要靠数据库约束、幂等键和状态机兜底。
项目落地
在客户重复提交投保意向场景中,最可靠的方案不是先加锁,而是设计幂等键。比如以 activityId + customerId + productId 作为唯一约束,投保意向创建时先查再插不可靠,因为并发下两个线程都可能查不到。正确方式是在数据库层加唯一索引,插入冲突后查询已有单据返回。锁可以减少冲突,但不能替代唯一约束。
在活动权益领取场景中,如果权益库存是全局共享资源,可以使用 Redis 预扣库存提升性能,但最终要有权益流水唯一键。高峰期先通过 Lua 脚本完成库存判断和扣减,再异步落库。落库失败时要有补偿任务校正 Redis 和数据库差异。如果权益价值很高,宁可降低并发,也要让数据库事务成为最终准绳。
在商机认领场景中,同一商机只能被一个代理人认领。推荐用数据库状态条件更新:update opportunity set owner_id=?, status='CLAIMED' where id=? and status='WAIT_CLAIM'。返回影响行数为 1 表示认领成功,0 表示已经被别人认领。这比单纯分布式锁更直接,因为状态变更和结果判断在同一条 SQL 中完成。
在本地缓存重建场景中,可以用 ReentrantLock.tryLock 或 synchronized 防止多个线程同时回源。拿到锁的线程负责加载数据,没拿到锁的线程可以短暂等待或返回旧值。这个场景无需分布式锁,因为每台机器维护自己的本地缓存,跨机器重复加载虽然浪费一些资源,但不影响业务一致性。
在月底结算批次调度场景中,多台机器可能同时扫描待处理任务。这里可以用数据库抢占:任务表有 status、owner、version 字段,扫描到待处理任务后通过条件更新抢占。也可以用 Redis 分布式锁按 settlementMonth + orgId 加锁,避免同一机构同月重复跑。但即使用了分布式锁,结算明细表也要有唯一键,防止锁过期或任务重试导致重复写。
关键伪代码或流程
synchronized 保护本地缓存构建:
public ProductRule getRule(String productId) {
ProductRule cached = localCache.get(productId);
if (cached != null) {
return cached;
}
synchronized (getLock(productId)) {
ProductRule again = localCache.get(productId);
if (again != null) {
return again;
}
ProductRule loaded = ruleRepository.load(productId);
localCache.put(productId, loaded);
return loaded;
}
}
ReentrantLock.tryLock 避免线程长时间等待:
boolean locked = lock.tryLock(100, TimeUnit.MILLISECONDS);
if (!locked) {
return OldValueFallback.useLastSnapshot();
}
try {
return rebuildSnapshot();
} finally {
lock.unlock();
}
条件更新实现商机认领:
update opportunity
set owner_id = ?, status = 'CLAIMED', claim_time = now()
where id = ?
and status = 'WAIT_CLAIM';
Redis 分布式锁释放必须校验持有者:
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
月底结算推荐流程:调度器扫描待处理批次;通过条件更新或分布式锁抢占批次;执行分片计算;写入结算明细时使用唯一键;批次完成后更新状态;失败批次记录错误原因并允许重跑;补偿任务扫描长时间运行未完成的批次并释放或重新派发。
常见坑与排查
第一个坑是锁对象选错。比如在方法里 new Object() 再 synchronized,每次请求都是新锁,完全没有互斥效果。或者锁粒度过大,把所有产品都锁在同一把锁上,导致无关业务互相阻塞。
第二个坑是 ReentrantLock 没有在 finally 中释放。异常分支一旦漏释放,后续线程会一直等待。排查时看线程栈,如果大量线程停在 AQS 获取锁,再看锁持有线程是否异常退出或卡在下游调用。
第三个坑是读写锁升级。线程持有读锁时再尝试获取写锁,容易死锁。常见写法是需要写入时先释放读锁,再获取写锁,或者直接用更简单的互斥锁。
第四个坑是分布式锁过期时间设置不合理。过短会导致业务还没执行完锁就过期,其他机器进入临界区;过长会导致持锁机器宕机后恢复慢。对于执行时间不稳定的任务,要有续期机制或改用数据库任务状态。
第五个坑是释放了别人的 Redis 锁。如果加锁时只 SETNX,释放时直接 DEL,可能出现线程 A 锁过期后线程 B 获得锁,线程 A 执行完又删除了 B 的锁。必须用唯一 token 加 Lua 脚本校验释放。
第六个坑是把锁当成幂等。锁只能控制某段时间的并发,不能保证消息重试、任务补偿、系统重启后的业务唯一性。保险订单、权益、结算明细必须有业务幂等键和唯一约束。
面试追问
追问一:synchronized 和 ReentrantLock 你怎么选?
追问二:公平锁一定更好吗?
追问三:读写锁适合什么场景?为什么写多时反而可能更差?
追问四:Redis 分布式锁如何避免误删?
追问五:分布式锁和数据库唯一索引是什么关系?
追问六:月底结算任务重复执行,靠锁能完全解决吗?
推荐回答
我会先按资源边界选择锁。如果只是单 JVM 内的短临界区,优先用 synchronized,简单且自动释放。如果需要超时获取、可中断等待、多个条件队列,就用 ReentrantLock。读多写少且共享对象在 JVM 内,可以考虑读写锁,但要评估写锁饥饿和升级风险。跨机器协调才考虑分布式锁。
但我不会把分布式锁作为最终一致性的唯一保证。比如商机认领和结算明细生成,我更信任数据库条件更新、唯一索引、幂等流水和状态机。分布式锁可以减少重复计算和并发冲突,但锁过期、网络抖动、任务重试都可能让临界区被重复进入,所以最终写入必须可幂等。
如果线上出现锁相关问题,我会先看线程栈确认是本地锁等待还是下游慢调用;再看锁等待时间、持锁时间、Redis 锁 key、业务单号和数据库冲突日志。很多“锁慢”本质不是锁本身慢,而是临界区里做了 RPC、数据库大查询或批量写入,导致持锁时间过长。
延伸学习路线
第一阶段,掌握 Java 内置锁语义:互斥、可见性、可重入、锁对象、同步方法和同步块。能解释临界区和共享变量的关系。
第二阶段,学习 AQS 和 ReentrantLock,理解同步队列、条件队列、公平非公平、可中断获取和超时获取。
第三阶段,学习读写锁、StampedLock、乐观读和锁降级,重点理解适用场景而不是盲目替换互斥锁。
第四阶段,学习 Redis 和 ZooKeeper 分布式锁实现,重点关注 token、TTL、续期、释放校验和故障场景。
第五阶段,把锁和业务一致性结合:数据库唯一索引、条件更新、乐观锁版本号、幂等表、状态机、消息去重和补偿任务。资深面试里,能讲清楚“不靠锁也能保证最终正确”往往更有说服力。