今天学习redisson锁,在学习之前,先做好准备工作。先掌握如何使用,再来学习原理。redisson的红锁已经过时了,废弃的原因我就不多说了。redisson的锁分单锁和多锁,多锁是将多个锁捆绑在一起统一管理,红锁就是多锁的一种实现。而今天我要学习的可重入锁是个单锁。
实验步骤
启动redis
我是在微软的WSL上安装了一个redis服务器,所以我启动就比较容易了。
console
hope@hope:~$ sudo service redis-server status
[sudo] password for hope:
* redis-server is not running
hope@hope:~$ sudo service redis-server start
Starting redis-server: redis-server.
hope@hope:~$ sudo service redis-server status
* redis-server is running
接下来就是写java程序了。我使用maven来搭建这个实验程序。
配置依赖
xml
<dependencies>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.24.2</version>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.2.6</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
<version>6.1.8</version> <!-- 例如 5.3.21 -->
</dependency>
</dependencies>
控制器代码
java
@RestController
public class LockController {
@Autowired
private RedissonClient redissonClient;
@GetMapping("/lock")
public String lock() {
RLock lock= redissonClient.getLock("register-lock");
try {
boolean isLocked = lock.tryLock(0, 30, TimeUnit.SECONDS);
if (!isLocked) {
// 获取锁失败,快速失败,不要阻塞
return "获取锁失败,请稍后重试";
}
// 业务代码
System.out.println(System.currentTimeMillis()+","+Thread.currentThread()+"正在操作资源");
Thread.currentThread().join(10000); // 模拟业务耗时
System.out.println(System.currentTimeMillis()+","+ Thread.currentThread() +"操作资源结束");
return "got lock";
} catch (InterruptedException e) {
return "执行中断";
} finally {
if (lock != null && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
配置文件我没加,所以使用的是默认的配置localhost:6379,并且是没有密码的单机模式。
需要注意的是,测试加锁是要用两个不同的浏览器,在同一个浏览器开多个窗口,浏览器会顺序请求。
加锁原理
redisson先执行一个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]);
该代码位于RedissonLock#tryLockInnerAsync方法中。其中参数如下:
- KEYS[1]是锁的名称
- ARGV[1]是锁释放时间
- ARGV[2]是当前线程名称
这说明redisson的锁是用redis的hash来实现的。使用hincrby来自增以统计锁重入次数,使用pexpire来设置过期时间。
等待原理
在tryAcquire方法里waitTime参数直接被忽略。上层调用是tryLock,在tryLock里有一段无限循环代码:
java
while (true) {
long currentTime = System.currentTimeMillis();
ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return true;
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
// waiting for message
currentTime = System.currentTimeMillis();
if (ttl >= 0 && ttl < time) {
commandExecutor.getNow(subscribeFuture).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
commandExecutor.getNow(subscribeFuture).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
}
学过编程的都知道,如果在无限循环里不时线程进入waiting或block状态的话,CPU使用率会非常高。这段无限循环代码不至于CPU飙升的核心就在于getLatch()返回了一个信号量Semaphore对象,并且使用信号量的tryAcquire方法来避免CPU使用率飙升。使用无限循环是因为抢占锁的极可能不止一个线程,如果还失败了就继续等待。从信号量的阻塞时间来看,直接是阻塞了剩余时间,那么肯定要有一个唤醒机制,否则一直沉睡,等待就没有意义。如果缺少一个唤醒机制,那么等待就永远只是等待,拿不到锁。
所以redisson的锁订阅了一个redis的频道,在收到释放锁的订阅消息时,把信号量释放掉,这样阻塞的线程就得到了释放,执行无限循环的下一个动作。在LockPubSub的订阅回调里有这么一段代码:
java
@Override
protected void onMessage(RedissonLockEntry value, Long message) {
if (message.equals(UNLOCK_MESSAGE)) {
Runnable runnableToExecute = value.getListeners().poll();
if (runnableToExecute != null) {
runnableToExecute.run();
}
value.getLatch().release();
} else if (message.equals(READ_UNLOCK_MESSAGE)) {
while (true) {
Runnable runnableToExecute = value.getListeners().poll();
if (runnableToExecute == null) {
break;
}
runnableToExecute.run();
}
value.getLatch().release(value.getLatch().getQueueLength());
}
}
释放原理
redisson锁释放有两种方式:
- 异步释放
- 同步释放
redisson为了复用代码,同步释放只是把异步释放过程同步执行,也就是直接调用get方法去阻塞等待异步方法完成。redisson释放锁要做至少两件事情:
- 修改redis服务器上锁对应的hash值
- 发布锁释放消息
现在就是仔细分析这两个功能在源码哪里实现的。首先在RedissonLock的unlockInnerAsync里有一段超级长的lua代码:
lua
local val = redis.call('get', KEYS[3]);
if val ~= false then
return tonumber(val);
end;
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
redis.call('pexpire', KEYS[1], ARGV[2]);
redis.call('set', KEYS[3], 0, 'px', ARGV[5]);
return 0;
else
redis.call('del', KEYS[1]);
redis.call(ARGV[4], KEYS[2], ARGV[1]);
redis.call('set', KEYS[3], 1, 'px', ARGV[5]);
return 1;
end;
这里我详细解释下参数的含义:
- KEYS[1],是锁的名称;
- KEYS[2],是订阅频道的名称;
- KEYS[3],是解锁的Latch名称;
- ARGV[1],恒为0,表示释放;
- ARGV[2],为锁释放时间;
- ARGV[3],当前现场ID;
- ARGV[4],发布消息命令;
- ARGV[5],超时时间。
从这段代码中可以看到如果因为递归,让锁的重入次数增加,那么需要逐步解锁,直到计数器为0时才会完全释放锁。同时删除锁和发布消息时在一起的,因为处于同一个脚本中,所以是原子性的。