写在前面
说实话,锁这块的知识点我之前一直觉得"会用就行",直到有一次线上出了死锁事故,排查了整整半天才发现是锁不可重入导致的。从那以后我就告诉自己,锁的原理不能只停留在"会用"层面。这篇文章我把可重入锁和读写锁的底层逻辑、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 加锁流程

加锁时判断逻辑如下:

  1. 检查 Redis 中是否存在该锁的 Key
  2. 如果不存在:直接创建,设置当前线程ID,重入次数设为 1,设置过期时间
  3. 如果已存在,且是同一线程:重入次数 +1,刷新过期时间
  4. 如果已存在,但是其他线程:返回加锁失败,进入等待(自旋/订阅)

2.3 解锁流程

解锁时判断逻辑如下:

  1. 检查当前线程是否持有该锁
  2. 如果不是自己的锁:抛出异常(非法解锁)
  3. 如果是自己的锁:重入次数 -1
  4. 如果重入次数减到 0:真正删除 Redis 中的 Key,释放锁
  5. 如果重入次数还没到 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: 可重入锁在集群环境下有什么问题?

这个问题我见过太多人踩坑了。主要有两个问题:

  1. 主从切换导致锁丢失:客户端A在 Master 上加锁成功,Master 还没来得及同步到 Slave 就挂了,Slave 提升为 Master 后锁信息丢失,客户端B也能加锁成功,导致两个客户端同时持有锁

  2. 网络分区(脑裂):客户端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 中。运营偶尔会修改商品信息,你怎么保证缓存一致性?

参考答案

我会用读写锁 + 缓存的方案:

  1. 读请求:获取读锁,从缓存读取商品信息,读完释放读锁
  2. 写请求(运营修改):获取写锁,更新数据库,删除缓存,释放写锁
  3. 如果缓存不存在,获取读锁后回源查数据库并写入缓存
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 确实存在主从切换导致锁丢失的问题。我们的做法是:

  1. 核心业务使用 RedLock:在 3 个以上的独立 Redis 实例上加锁,超过半数成功才算加锁成功
  2. 非核心业务接受极小概率的不一致:用单节点 Redisson 锁,配合数据库的乐观锁做兜底
  3. 监控告警:对锁的持有时间做监控,如果锁持有时间异常长,触发告警
// 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:读写锁在配置中心的应用

面试官:你们的配置中心是怎么保证配置一致性的?

参考答案

配置中心的典型特征是读多写少,非常适合读写锁:

  1. 所有服务启动时获取配置:使用读锁,多个服务可以同时读取
  2. 运营修改配置:使用写锁,修改时阻塞所有读取
  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 读写锁,根据场景选择最合适的方案。


九、互动话题

你在实际项目中遇到过锁相关的线上问题吗?是死锁、锁超时还是主从切换导致的问题?当时是怎么排查和解决的?欢迎在评论区分享你的经历,我会在评论区和大家一起讨论。


参考资料

更多推荐