一、锁的认知
什么是锁
日常视角 : 锁是一种常见的安全设备,用于保护贵重物品、房屋、车辆等不被盗窃或未经授权的进入。
计算机视角: 锁是一种同步机制,用于控制对共享资源的访问。锁可用于防止多个线程或进程同时访问共享资源,从而避免竞争条件和数据不一致性等问题。
使用锁存在的利弊
可以为我所用的一些特点
- 线程安全:保证同一时间只有一个线程访问共享资源
- 避免死锁:当多个线程访问共享资源时,锁可以确保每个线程在访问共享资源之前获取锁,并在使用完之后释放锁,从而避免死锁的发生。
- 提高并发性能:通过合理使用锁,可以提高并发性能。例如,使用读写锁可以允许多个线程同时读取共享资源,从而提高读取性能。
使用时应该考虑的问题
- 竞争条件:多个线程同时尝试获取同一个锁,可能会导致线程等待和性能下降。
- 上下文切换:使用锁可能会导致线程频繁从用户态切换到内核态
- 死锁:跟优点相对应,如果使用不当可能会导致死锁的发生,影响程序的正确性和性能。比如没有合理的释放锁。
锁在各技术和场景下使用对比
技术 | 场景 | 用途 |
---|---|---|
Redisson | 分布式锁 | Redisson 是一个支持多种分布式锁类型的 Java 库。使用 Redisson 可以很方便地实现分布式锁,以避免多个节点同时修改数据导致的数据冲突问题。 |
Java | 多线程并发/单机锁 | Java 内置的锁机制可用于控制多个线程对共享资源的访问。通过使用 synchronized 关键字或者 ReentrantLock 类,以及包括 JUC 包众多类,可以确保在任何给定时间只有一个线程可以访问共享资源和线程之间的通信问题。 |
MySQL | 行级锁 | MySQL 支持多种类型的锁,其中行级锁是最常见的类型。使用行级锁可以确保在修改数据时只有一个线程可以访问每个行。比如 Inoodb 引擎中在 RR 级别用到间隙锁、next-key 锁等。都是为了解决竞争资源时带来的脏读、幻读等问题。 |
CPU 使用锁 | 多线程同步 | 在多线程应用中,CPU 使用锁可用于同步多个线程的执行。主要解决的是多个核心并发访问/修改同一块内存的问题。所以有锁总线和 MESI 协议来做 |
二、聊聊 Redisson
Redission 简介
Redisson 是依托于 redis 的一款开源组件,在 Redis 原有的分布式锁机制的基础上封装了很多给开发者开箱即用的方法。在实际开发中,我们可用这些优秀稳定的轮子为我所用。当然他一些优秀的设计思想比如他如何利用 redis 来高度抽象一些方法、如何用时间轮算法,来提高锁的效率和可靠性,这些都是值得我们学习和借鉴。
关于 Redission 的锁使用
工程中引入依赖
xml
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.20.0</version>
</dependency>
Redisson 的单 key 锁
api 的使用方式
java
public void testLock() {
// 获取锁对象
RLock lock = redissonClient.getLock("mylock");
try {
// 尝试获取锁,最多等待 10 秒,锁的过期时间为 30 秒
boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (locked) {
// 获取锁成功,执行业务逻辑
// ...
} else {
// 获取锁失败,执行其他逻辑
// ...
}
} catch (InterruptedException e) {
// 获取锁过程中被中断
} finally {
// 释放锁
lock.unlock();
}
}
自定义切面使用例子
java
@Component
public class LockDemoAnnotationService {
@MyAspetRedissonLock(name = "mylock", leaseTime = 10)
public void myMethod() {
// 获取锁成功,执行业务逻辑
// ...
}
}
单 key 锁整体实现原理图
使用起来的确毫无压力,但是里面也封装了很多细节。下面我们看看上述 api 接口实现原理:
图中的名词解释
名词 | 解释 |
---|---|
ttl | 代表 redis 中该 key 的剩余过期时间(同时也代表该 key 已经被其他线程加锁占用),反之如果返回 null 代表线程 A 加锁成功。关于 Redisson 具体的逻辑实现其实是通过包裹了 RFuture(一种 Futrue),Futrue 里面的任务本质上执行的是 Lua 脚本。ttl 对应的是 null 还是具体的过期时间其实也就是 lua 脚本执行的结果。 |
waitTime | 等待时间,代表线程 A 可再次尝试加锁的剩余时间 |
leaseTime | 代表过期时间,上图并没有说明,但实际逻辑藏匿在获取 ttl 的逻辑中。这个参数其实也是用户端传进来。如果用户端没有传该值,那么在获取 ttl 的逻辑中会有续期的方法。 |
相关实现类介绍
实现类 | 介绍 |
---|---|
org.redisson.RedissonLock | Redisson 的可重入锁实现类,实现了 RLock 接口。 |
org.redisson.command.CommandAsyncExecutor | Redisson 的命令执行器,在实现可重入锁的过程中用于执行 Redis 命令。 |
org.redisson.command.CommandExecutor | Redisson 的命令执行器(接口),实现了 CommandAsyncExecutor 接口,执行 Lua 脚本。 |
org.redisson.pubsub.LockPubSub | Redisson 的锁发布/订阅(Pub/Sub)对象,用于处理锁的释放事件。 |
具体功能实现介绍
我们将从使用功能为目标,自上而下来剖析它: 例: tryLock(long waitTime, long leaseTime, TimeUnit unit); **waitTime:**尝试加锁时间 **leaseTime:**锁过期时间 **unit:**时间单位
waitTime 逻辑实现
自旋锁方式来做处理,原代码简化实现方式:
java
......代码省略
long time = unit.toMillis(waitTime);
do {
......代码省略
time -= System.currentTimeMillis() - currentTime;
} while(time > 0L);
过期时间和可重入锁实现
本质是通过 lua 脚本实现,如下:
lua
if ((redis.call('exists', KEYS[1]) == 0) or (redis.call('hexists', KEYS[1], ARGV[2]) == 1))
then redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
return redis.call('pttl', KEYS[1]);
解释:第二行就是实现可重入的实现。利用 redis 的 hash 结构,让对应的线程的进入次数加 1,代码可以翻译为 redis.call('hincrby', key, threadId, 1); redis 存入的过期时间也是利用 redis 的本身能力实现,可看第三行的 lua 脚本。代码可以翻译为 redis.call('pexpire', key, leaseTime);
聊聊 redission 续期
怎么使用 redission 的库使用续期功能,来保证自己存放的锁永远不过期呢,其实它给我们封装了两个方法可供我们开发者调用,使用起来也非常的简单,利用三板斧,引入依赖、写配置、方法调用。依赖最前面已经引入了,下面是配置和方法使用 配置:
properties
watchdog:
checkInterval: 5000 # 监控间隔,默认为 5000 毫秒
failCount: 3 # 失败次数,超过该次数后对象被认为已经超时
timeout: 10000 # 超时时间,单位为毫秒
方法调用: 无参形式:tryLock() 有参形式:tryLock(waitTime,TimeUnit) 解释:使用也很好理解,居然没有过期时间的参数,要知道我们本质是利用 redis 中的能力,它对任何的数据肯定是有过期策略。现在 tryLock 没有这个参数代表着他能无限续期,同时也说明 Redisson 库底层帮我们封装了这一套逻辑。
续期原理介绍
问题引入 官网对于 Redisson 续期的介绍是:启动一个后台线程(看门狗线程),定期检查锁的过期时间,如果发现锁的过期时间即将到达,则尝试进行续期操作。
当然这句是比较简单的描述,深入到代码层面我们考虑应该要怎么实现。
比如用什么启动后台线程(看门狗),同一时间段有多个 key 需要续期该怎么解决。
代码原理实现
看门狗线程实现代码位置:org.redisson.RedissonBaseLock#renewExpiration
可以看到这里创建了这个定时任务,这个定时任务其实就是"看门狗",相当于创建了一个后台线程去续期。 多个 key 同一时间段需要续期实现: 直接说结论的是,这里它是采用的 Netty 这个包中提供的时间轮算法来添加到上面说的看门狗的任务里来实现。说到这里其实也是挺有意思的,我们用了 Redisson 的库,Redisson 的作者用了 Netty 的包里的算法。
时间轮算法介绍
时间轮算法其实也不难理解,原理就是将任务按照其到期时间放入对应的槽位,每个槽位的时间间隔为固定值,当时间轮转动时,到期的任务将会被执行。netty 中这个算法的底层是一个数组,任务通过双向链表构成。源码位置:
io.netty.util.HashedWheelTimer
聊聊 Redisson 联锁
Redisson 提供了联锁(MultiLock)功能,可以将多个锁绑定在一起,实现原子性地获取和释放多个锁,以便更好地保护多个资源。
使用方式
java
public class MultiLockDemoService {
@Resource
private RedissonClient redissonClient;
public void testMultiLock() throws InterruptedException {
RLock lock1 = redissonClient.getLock("lock1");
RLock lock2 = redissonClient.getLock("lock2");
RLock multiLock = redissonClient.getMultiLock(lock1, lock2);
multiLock.tryLock(1L, TimeUnit.SECONDS);
try {
// do something
} finally {
multiLock.unlock();
}
}
}
原理介绍
原理总结
联锁的实现原理其实依赖单个加锁的实现,相对于单个锁它封装了多个 Redisson 锁(RLock)对象。当然由于是集合,我们要考虑的是一起成功或者一起失败的问题。
更多功能支持
具体来说,RedissonMultiLock 类是 Redisson 联锁的主要实现类,它继承自 RedissonMultiLockBase 类,提供了联锁的创建、获取、释放等操作方法,并且支持联锁的自动续期和异步执行。 RedissonMultiLock 类中的主要方法包括:
- lock():获取联锁;
- unlock():释放联锁;
- isLocked():判断联锁是否被获取;
- getLocks():获取所有锁的列表;
- getHoldCount():获取当前线程持有联锁的数量;
- tryLock():尝试获取联锁,并设置超时时间;
- tryLock(long waitTime, long leaseTime, TimeUnit unit):尝试获取联锁,并设置等待时间和锁的超时时间;
- forceUnlock():强制释放所有联锁。
使用注意事项
联锁只有在所有锁都获取成功后才算获取成功,只要有一个锁获取失败,就会立即释放已经获取的锁,并抛出异常。因此,在使用 Redisson 联锁时,需要特别注意各个锁的获取顺序和释放顺序,避免死锁等问题。
个人不推荐使用联锁,最好在业务层面上逻辑处理好后使用单个锁。
三、总结
在计算机中不管是 CPU 密集型任务亦或是 IO 密集型任务,我们都希望通过多线程同时(并发)执行来充分"榨干"计算机的性能以此来提高程序的执行效率。当然随之带来的就是资源竞争导致的数据一致性问题,因此锁这个概念不管在硬件中还是在软件中都会有它的用武之地。
本文只是从 java 中 Redisson 库的使用来简述这个问题,以此让大家更好的认识它。
个人认为 Redisson 是一个非常好用的 java 库,对于我们常用的加锁功能他替我们考虑的很周全,包括锁等待、可重入、续期等问题,可让开发者更全身心投入到业务开发中。同时阅读他们的源码可学习到如何合理的运用进行设计模式编排代码和利用好 JUC 包进行异步编程。