【Java项目技术亮点】可重入锁与读写锁实现
写在前面
说实话,锁这块的知识点我之前一直觉得"会用就行",直到有一次线上出了死锁事故,排查了整整半天才发现是锁不可重入导致的。从那以后我就告诉自己,锁的原理不能只停留在"会用"层面。这篇文章我把可重入锁和读写锁的底层逻辑、Redisson的实现、还有面试常考的场景题都整理了一遍,希望对你有帮助。

文章目录
一、为什么需要可重入锁?
1.1 一个真实的死锁场景
先看一段代码,这段代码演示了不可重入锁会出什么问题:
public class NonReentrantLockDemo {
private Lock lock = new NonReentrantLock(); // 假设的自定义不可重入锁
// 外层方法:获取锁
public void outerMethod() {
lock.lock();
try {
System.out.println("外层方法获取了锁");
// 调用内层方法,内层也需要同一把锁
innerMethod();
} finally {
lock.unlock();
}
}
// 内层方法:也需要同一把锁
public void innerMethod() {
lock.lock(); // 💥 这里会死锁!同一线程再次获取锁会被阻塞
try {
System.out.println("内层方法获取了锁");
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
NonReentrantLockDemo demo = new NonReentrantLockDemo();
demo.outerMethod(); // 程序会卡死在这里
}
}
运行结果:程序会卡死在 innerMethod() 的 lock.lock() 处,因为外层方法已经持有了锁,同一线程再次请求同一把锁时被阻塞,形成了自己等自己的死锁局面。
1.2 生活类比:办公室钥匙
想象一下你拿着门禁卡刷进了公司大门,然后你想去楼上的会议室。如果每次进一个房间都要重新刷门禁,那岂不是要疯了?
可重入锁的逻辑就是:你已经验证过身份了(拿到了锁),在同一个"事务"里再去其他需要验证的地方,不需要重新验证。
类比总结:
- 不可重入锁 = 每进一个房间都要重新刷门禁,哪怕你已经在楼里了
- 可重入锁 = 刷一次门禁,楼里所有房间随便进,出去的时候按进入次数反向关门
二、可重入锁原理
2.1 重入次数计数器
可重入锁的核心就是一个计数器,记录同一个线程对同一把锁的重入次数。在分布式场景下,通常用 Redis 的 Hash 结构来存储:
lockName(锁的名称) → {
"线程ID-1": 3, // 线程1重入了3次
"线程ID-2": 1 // 线程2重入了1次
}
2.2 加锁流程
加锁时判断逻辑如下:
- 检查 Redis 中是否存在该锁的 Key
- 如果不存在:直接创建,设置当前线程ID,重入次数设为 1,设置过期时间
- 如果已存在,且是同一线程:重入次数 +1,刷新过期时间
- 如果已存在,但是其他线程:返回加锁失败,进入等待(自旋/订阅)
2.3 解锁流程
解锁时判断逻辑如下:
- 检查当前线程是否持有该锁
- 如果不是自己的锁:抛出异常(非法解锁)
- 如果是自己的锁:重入次数 -1
- 如果重入次数减到 0:真正删除 Redis 中的 Key,释放锁
- 如果重入次数还没到 0:什么都不做,只是计数减一
2.4 Redisson 可重入锁的 Lua 脚本解析
Redisson 用 Lua 脚本保证加锁和解锁操作的原子性。下面是核心脚本:
加锁脚本:
-- KEYS[1] = 锁的名称,如 "myLock"
-- ARGV[1] = 锁的过期时间(毫秒)
-- ARGV[2] = 线程唯一标识,如 "UUID:threadId"
-- 如果锁不存在
if (redis.call('exists', KEYS[1]) == 0) then
-- 创建Hash,设置线程标识和重入次数=1
redis.call('hset', KEYS[1], ARGV[2], 1);
-- 设置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
-- 如果锁已存在,且是同一线程(可重入)
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 重入次数+1
redis.call('hincrby', KEYS[1], ARGV[2], 1);
-- 刷新过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
-- 锁被其他线程持有,返回剩余过期时间(用于等待)
return redis.call('pttl', KEYS[1]);
解锁脚本:
-- KEYS[1] = 锁的名称
-- KEYS[2] = 发布订阅的channel名称
-- ARGV[1] = 解锁消息
-- ARGV[2] = 过期时间
-- ARGV[3] = 线程唯一标识
-- 如果锁不存在,说明已经释放了
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;
-- 重入次数-1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
-- 如果重入次数>0,说明还需要继续持有锁
if (counter > 0) then
-- 刷新过期时间
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
-- 重入次数=0,真正删除锁
redis.call('del', KEYS[1]);
-- 发布解锁消息,通知等待的线程
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
return nil;
踩坑提醒
Redisson 的锁默认有 30 秒的看门狗(Watchdog)自动续期机制。但如果你的业务执行时间特别长(比如批量导出几百万数据),建议手动设置更长的 leaseTime,避免看门狗线程和业务线程竞争资源。
三、读写锁原理
3.1 读锁共享,写锁互斥
读写锁的核心思想就一句话:读读不互斥,读写互斥,写写互斥。
| 操作组合 | 是否互斥 | 说明 |
|---|---|---|
| 读锁 vs 读锁 | 不互斥 | 多个线程可以同时读 |
| 读锁 vs 写锁 | 互斥 | 有人读的时候不能写,有人写的时候不能读 |
| 写锁 vs 写锁 | 互斥 | 同一时刻只能有一个写操作 |
3.2 适用场景:读多写少
读写锁特别适合读多写少的场景,比如:
- 配置中心:配置被读取的频率远高于修改频率
- 缓存更新:缓存数据被频繁读取,偶尔更新
- 商品详情页:大量用户浏览,偶尔有运营修改商品信息
3.3 生活类比:图书馆
把读写锁想象成图书馆的规则:
- 读锁 = 多个读者可以同时在阅览室看书,互不干扰
- 写锁 = 管理员要整理书架(修改数据),这时候所有读者都得出去,等管理员整理完才能继续看
- 写写互斥 = 两个管理员不能同时整理同一个书架,得一个一个来
四、Redisson 读写锁实现
4.1 RedissonReadWriteLock API
// 获取读写锁对象
RLock readLock = readWriteLock.readLock();
RLock writeLock = readWriteLock.writeLock();
// 读锁操作
readLock.lock();
try {
// 读取共享数据
} finally {
readLock.unlock();
}
// 写锁操作
writeLock.lock();
try {
// 修改共享数据
} finally {
writeLock.unlock();
}
4.2 Redis 数据结构
Redisson 读写锁在 Redis 中的存储结构如下:
Key: "myReadWriteLock"
├── Hash Field: "UUID:threadId:write" → Value: 1 (写锁,String类型)
├── Hash Field: "UUID:threadId:read" → Value: 1 (读锁,String类型)
└── Hash Field: "mode" → Value: "read" 或 "write"
实际上 Redisson 用了更复杂的结构:
- 写锁:存储在 Hash 中,Key 是锁名称,Field 是
UUID:threadId,Value 是重入次数 - 读锁:存储在 Hash 中,Key 是锁名称,Field 是
UUID:threadId+ 读锁模式标识,Value 是重入次数 - 额外使用一个 Set 来记录所有持有读锁的线程
4.3 公平锁 vs 非公平锁
| 特性 | 公平锁 | 非公平锁 |
|---|---|---|
| 获取顺序 | 按请求顺序排队 | 不保证顺序,插队可能 |
| 吞吐量 | 较低(需要排队) | 较高(减少线程切换) |
| 饥饿问题 | 不会饥饿 | 可能饥饿 |
| Redisson 支持 | ReadWriteLock 默认非公平 |
可通过 FairLock 使用公平锁 |
4.4 完整代码示例:商品库存读写
下面是一个模拟商品库存读写的完整示例:
import org.redisson.Redisson;
import org.redisson.api.RReadWriteLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* 商品库存读写锁示例
* 场景:多个线程同时读取商品库存,偶尔有线程修改库存
*/
public class ProductStockRWLockDemo {
private static final String LOCK_NAME = "product:stock:lock";
private static final String PRODUCT_KEY = "product:1001:stock";
public static void main(String[] args) throws InterruptedException {
// 创建 Redisson 客户端
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setPassword(null);
RedissonClient redisson = Redisson.create(config);
// 获取读写锁
RReadWriteLock rwLock = redisson.getReadWriteLock(LOCK_NAME);
// 创建线程池模拟并发
ExecutorService executor = Executors.newFixedThreadPool(10);
// 模拟5个读线程
for (int i = 0; i < 5; i++) {
final int readerId = i;
executor.submit(() -> {
// 获取读锁
rwLock.readLock().lock();
try {
System.out.println("读者-" + readerId + " 开始读取库存...");
// 模拟读取操作耗时
Thread.sleep(1000);
// 从Redis读取库存
int stock = getStockFromRedis(redisson);
System.out.println("读者-" + readerId + " 读取到库存: " + stock);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
rwLock.readLock().unlock();
System.out.println("读者-" + readerId + " 释放读锁");
}
});
}
// 模拟2个写线程
for (int i = 0; i < 2; i++) {
final int writerId = i;
executor.submit(() -> {
// 获取写锁
rwLock.writeLock().lock();
try {
System.out.println("写者-" + writerId + " 获取写锁,开始修改库存...");
// 模拟写操作耗时
Thread.sleep(2000);
// 修改库存
int newStock = 1000 - writerId * 100;
updateStockToRedis(redisson, newStock);
System.out.println("写者-" + writerId + " 修改库存为: " + newStock);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
rwLock.writeLock().unlock();
System.out.println("写者-" + writerId + " 释放写锁");
}
});
}
// 等待所有任务完成
executor.shutdown();
executor.awaitTermination(30, TimeUnit.SECONDS);
redisson.shutdown();
}
/**
* 模拟从Redis读取库存
*/
private static int getStockFromRedis(RedissonClient redisson) {
// 实际项目中用 redisson.getBucket(PRODUCT_KEY).get()
return 1000;
}
/**
* 模拟更新库存到Redis
*/
private static void updateStockToRedis(RedissonClient redisson, int stock) {
// 实际项目中用 redisson.getBucket(PRODUCT_KEY).set(stock)
System.out.println("库存已更新到Redis: " + stock);
}
}
运行效果分析:
- 5 个读线程可以同时持有读锁,并行读取库存
- 写线程获取写锁时,会等待所有读锁释放
- 写锁持有期间,所有读线程和写线程都会被阻塞
五、可重入锁 vs 读写锁 vs 普通锁对比
| 对比维度 | 普通锁(互斥锁) | 可重入锁 | 读写锁 |
|---|---|---|---|
| 可重入 | 不支持 | 支持 | 支持 |
| 读写分离 | 不支持 | 不支持 | 支持 |
| 读读并发 | 不支持(互斥) | 不支持(互斥) | 支持 |
| 并发性能 | 低 | 中 | 高(读多写少场景) |
| 实现复杂度 | 简单 | 中等 | 较高 |
| 适用场景 | 简单互斥控制 | 方法嵌套调用 | 缓存、配置中心、读多写少 |
| Redis实现 | SETNX |
Hash + 计数器 | Hash + Set + 发布订阅 |
| 死锁风险 | 高(嵌套调用) | 低 | 低 |
| 写饥饿风险 | 无 | 无 | 有(大量读时写可能饥饿) |
六、问题与解答
Q1: 读写锁会不会产生写饥饿?
会。 当读操作非常频繁时,写线程可能长时间获取不到写锁,这就是"写饥饿"。
Redisson 读写锁默认是非公平的,读线程可以插队。解决方案:
- 使用公平锁模式,让写线程按顺序排队
- 设置读锁的最大持有时间,超时自动释放
- 在业务层面控制并发读的数量
// Redisson 提供了带超时的读锁
rwLock.readLock().lock(10, TimeUnit.SECONDS);
Q2: 可重入锁在集群环境下有什么问题?
这个问题我见过太多人踩坑了。主要有两个问题:
-
主从切换导致锁丢失:客户端A在 Master 上加锁成功,Master 还没来得及同步到 Slave 就挂了,Slave 提升为 Master 后锁信息丢失,客户端B也能加锁成功,导致两个客户端同时持有锁
-
网络分区(脑裂):客户端A持有锁期间与 Redis 集群断开连接,Redisson 的看门狗无法续期,锁过期后其他客户端获取到锁
解决方案:使用 Redisson 的 MultiLock(联锁)或 RedLock 算法,在多个独立的 Redis 节点上同时加锁。
Q3: 读写锁的读锁能不能升级为写锁?
不能直接升级。 这叫"锁升级",会导致死锁。
原因很简单:线程A持有读锁,想升级为写锁,需要等待所有其他读锁释放。但如果其他线程也持有读锁并且在等待写锁释放(因为它们也想升级),就形成了死锁。
正确做法是"锁降级":先获取写锁,再获取读锁,然后释放写锁。这样写操作完成后,读锁仍然持有,保证数据一致性。
// 锁降级的正确姿势
rwLock.writeLock().lock(); // 1. 先获取写锁
try {
// 2. 修改数据
updateData();
// 3. 获取读锁(在写锁还没释放时)
rwLock.readLock().lock();
} finally {
rwLock.writeLock().unlock(); // 4. 释放写锁
}
try {
// 5. 用读锁读取刚修改的数据
readData();
} finally {
rwLock.readLock().unlock(); // 6. 最后释放读锁
}
七、面试高频考点汇总
考点1:可重入锁的底层原理是什么?
参考答案:可重入锁的核心是重入次数计数器。在 Java 的 ReentrantLock 中,通过 AQS 的 state 字段记录重入次数;在 Redisson 中,通过 Redis Hash 结构存储 {线程ID: 重入次数}。加锁时如果同一线程已持有锁,计数器 +1;解锁时计数器 -1,减到 0 才真正释放锁。
考点2:Redisson 的看门狗机制是怎么工作的?
参考答案:如果不指定 leaseTime,Redisson 会启动一个看门狗线程(Watchdog),每隔 10 秒(lockWatchdogTimeout / 3)检查当前线程是否还持有锁,如果持有就自动续期到 30 秒。如果客户端宕机,看门狗停止续期,锁会在 30 秒后自动释放。注意:如果手动指定了 leaseTime,看门狗不会启动。
考点3:读写锁适合什么场景?有什么缺点?
参考答案:读写锁适合读多写少的场景(如缓存、配置中心),因为读读不互斥,可以大幅提升并发读的性能。缺点是:1)实现复杂度高;2)可能产生写饥饿;3)读写锁的内存开销比普通锁大;4)在分布式环境下,锁的获取和释放延迟更高。
考点4:什么是锁降级?为什么支持锁降级但不支持锁升级?
参考答案:锁降级是指持有写锁时获取读锁,然后释放写锁的过程。目的是在写完数据后,立刻以读锁的方式读取数据,保证读到的是自己刚写的值。锁升级(持有读锁时获取写锁)会导致死锁,因为写锁需要等待所有读锁释放,而持有读锁的线程又在等待写锁,形成循环等待。
考点5:分布式锁在主从切换时如何保证安全?
参考答案:可以使用 RedLock 算法:在多个独立的 Redis 节点上同时加锁,超过半数(N/2 + 1)加锁成功才算最终成功。这样即使一个节点发生主从切换丢失锁,其他节点上仍然有锁保护。Redisson 提供了 RedissonRedLock 来实现这个算法。
八、模拟面试官提问
场景题1:设计一个商品详情页缓存更新方案
面试官:我们有一个商品详情页,QPS 大概 5000,商品信息缓存在 Redis 中。运营偶尔会修改商品信息,你怎么保证缓存一致性?
参考答案:
我会用读写锁 + 缓存的方案:
- 读请求:获取读锁,从缓存读取商品信息,读完释放读锁
- 写请求(运营修改):获取写锁,更新数据库,删除缓存,释放写锁
- 如果缓存不存在,获取读锁后回源查数据库并写入缓存
public Product getProductDetail(Long productId) {
String cacheKey = "product:detail:" + productId;
RReadWriteLock rwLock = redisson.getReadWriteLock("product:lock:" + productId);
// 尝试从缓存获取
Product product = redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
// 缓存未命中,获取读锁(防止缓存击穿)
rwLock.readLock().lock();
try {
// 双重检查
product = redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
// 回源查数据库
product = productMapper.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
}
return product;
} finally {
rwLock.readLock().unlock();
}
}
public void updateProduct(Product product) {
RReadWriteLock rwLock = redisson.getReadWriteLock("product:lock:" + product.getId());
// 获取写锁
rwLock.writeLock().lock();
try {
// 1. 更新数据库
productMapper.updateById(product);
// 2. 删除缓存
redisTemplate.delete("product:detail:" + product.getId());
} finally {
rwLock.writeLock().unlock();
}
}
场景题2:分布式环境下可重入锁的安全性
面试官:你们的分布式锁用的 Redisson 可重入锁,如果 Redis 主节点挂了,锁的安全性怎么保证?
参考答案:
单节点 Redis 确实存在主从切换导致锁丢失的问题。我们的做法是:
- 核心业务使用 RedLock:在 3 个以上的独立 Redis 实例上加锁,超过半数成功才算加锁成功
- 非核心业务接受极小概率的不一致:用单节点 Redisson 锁,配合数据库的乐观锁做兜底
- 监控告警:对锁的持有时间做监控,如果锁持有时间异常长,触发告警
// RedLock 使用示例
RLock lock1 = redissonClient1.getLock("myLock");
RLock lock2 = redissonClient2.getLock("myLock");
RLock lock3 = redissonClient3.getLock("myLock");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
redLock.lock();
try {
// 业务逻辑
} finally {
redLock.unlock();
}
场景题3:读写锁在配置中心的应用
面试官:你们的配置中心是怎么保证配置一致性的?
参考答案:
配置中心的典型特征是读多写少,非常适合读写锁:
- 所有服务启动时获取配置:使用读锁,多个服务可以同时读取
- 运营修改配置:使用写锁,修改时阻塞所有读取
- 配置修改完成后通过发布/订阅通知所有服务重新拉取
public String getConfig(String configKey) {
RReadWriteLock rwLock = redisson.getReadWriteLock("config:lock:" + configKey);
rwLock.readLock().lock();
try {
return redisTemplate.opsForValue().get("config:" + configKey);
} finally {
rwLock.readLock().unlock();
}
}
public void updateConfig(String configKey, String value) {
RReadWriteLock rwLock = redisson.getReadWriteLock("config:lock:" + configKey);
rwLock.writeLock().lock();
try {
redisTemplate.opsForValue().set("config:" + configKey, value);
// 发布配置变更事件
redisTemplate.convertAndSend("config:change:" + configKey, value);
} finally {
rwLock.writeLock().unlock();
}
}
场景题4:锁降级策略设计
面试官:什么场景下需要用到锁降级?怎么实现?
参考答案:
锁降级的典型场景是保证数据一致性:写完数据后立刻读取验证,确保读到的是自己写的值。
比如在数据迁移场景中:
public void migrateData(String key) {
RReadWriteLock rwLock = redisson.getReadWriteLock("migrate:lock:" + key);
// 第一步:获取写锁
rwLock.writeLock().lock();
try {
// 执行数据迁移
doMigrate(key);
// 第二步:在写锁内获取读锁(锁降级)
rwLock.readLock().lock();
} finally {
// 第三步:释放写锁,保留读锁
rwLock.writeLock().unlock();
}
try {
// 第四步:用读锁验证数据
verifyData(key);
} finally {
// 第五步:释放读锁
rwLock.readLock().unlock();
}
}
为什么不能反过来(锁升级):如果先拿读锁再拿写锁,其他线程可能也持有读锁,写锁会一直等待,形成死锁。
场景题5:读写锁 vs 悲观锁/乐观锁选型
面试官:你们项目里什么时候用读写锁,什么时候用乐观锁?
参考答案:
我的选型原则如下:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 读多写少(缓存、配置) | 读写锁 | 读读不互斥,性能好 |
| 写多读少(库存扣减) | 乐观锁(CAS) | 冲突少,无锁开销 |
| 读写都频繁 | 分段锁 + 乐观锁 | 减少锁粒度 |
| 强一致性要求 | 写锁(悲观锁) | 保证数据绝对一致 |
| 高并发短操作 | 乐观锁 | 无阻塞,性能好 |
实际项目中,我们的商品库存扣减用的是数据库乐观锁(version 字段),而商品详情页缓存用的是Redisson 读写锁,根据场景选择最合适的方案。
九、互动话题
你在实际项目中遇到过锁相关的线上问题吗?是死锁、锁超时还是主从切换导致的问题?当时是怎么排查和解决的?欢迎在评论区分享你的经历,我会在评论区和大家一起讨论。
参考资料
更多推荐
所有评论(0)