Java锁实现大全
首先,我们解释一下,什么是Java中的锁:
“一段synchronized(同步块)的代码被一个线程执行之前,他要先拿到执行这段代码的权限,在Java里边就是拿到某个同步对象的锁(一个对象只有一把锁);
如果这个时候同步对象的锁被其他线程拿走了,他(这个线程)就只能等了(线程阻塞在锁池等待队列中)。 取到锁后,他就开始执行同步代码(被synchronized修饰的代码);线程执行完同步代码后马上就把锁还给同步对象,其他在锁池中等待的某个线程就可以拿到锁执行同步代码了。这样就保证了同步代码在统一时刻只有一个线程在执行"
Java的锁实现,如下图所示(通过数据库做分布式锁开销太大,因此下图打叉):
针对分布式锁,就需要用外部的方式生成分布式锁。
分布式环境下,锁定全局唯一资源,作用是:
业务资源锁定
消费去重
分布式事务中,常见的分布式锁有:
我们在选择锁的时候,需要结合场景。如果CP类业务,就应该选择上表CP的zookeeper或redis。AP类的业务(例如社交类),就可以选择redis做分布式锁。
etcd实现分布式锁详解
但是,如果业务是CP模型,就需要使用强一致的分布式锁,如etcd。
实际上,在容器云时代,etcd作为服务注册中心被广泛使用。
etcd有如下特点:
简单KV
强一致
多活
提供数据持久化
etcd提供的分布式锁整体方案是:分布式celient+etcd,是client TTL模式。
etcdv3默认提供分布式锁的功能。我们不用显性书写锁续租,只需要关注申请锁、释放锁即可。续租会自动进行。
etcd提供了独有的集群管理模式,方便进行极端case下的测试,以三个节点的etcd集群为例:
1.单节点停机,不影响持续写入,不影响读,结果有一致性。
2.当只有一个节点时,读会停机,写入正常。
3.理论上只要不是多节点同时停机,线上服务不会受影响。
etcd流程图如下所示:
在上图中,etcd client用于管理etcd的链接,节点监控、节点复杂均衡(etcd集群自己的多活不需要etcd client来保证。etcd client是为了保证连接到的etcd实例是可用的)。
客户端在请求锁的时候,会先判断etcd client是否存在,如果存在,则根据负载均衡算法建立与etcd链接,连上以后,从etcd竞争锁,成功的话,后面锁的租期自动续租,当锁使用结束后,释放锁。
客户端在请求锁的时候,如果没有etcd client,那么会加载etcd集群建立etcdclient。而这个etcd client的目的,本质上是判断etcd集群哪个实例可用,然后按照算法为分配一个etcd的实例连接。
我们查看etcd client的部分代码(EtcdClient.java):
第一个圈是加载etcd节点的列表(node ip);
第一个方框是把节点列表都加入到内存中;
第二个方框是对etcd 实例做心跳检查(探活、自动恢复);
下面代码段是etcd client对外提供的etcd的操作接口,如竞争锁、删除锁等。
代码中对锁的操作采用curl。下面代码段用于组装curl的命令:
接下里,我们查看etcd心跳检查方法(EtcdHeartbeatTask.java):
代码通过尝试连接来确认etcd实例是否是好的:
通过建立socket通信来检验etcd是否可以被连接接:
接下来,我们看etcd锁的实现。它支持可重入锁、自动续租、竞争锁、释放锁。( EtcdLock.java)。
我们查看代码中声明锁的代码段,我们可以看到用etcd实现分布式锁的本质利用它的Key-value:
在前文中我我们提到了,Java锁三种本地事务锁都都支持重入锁。支持重入性,表示能够对共享资源能够重复加锁,即当前线程获取该锁再次获取不会被阻塞。
要想支持重入性,就要解决两个问题:1. 在线程获取锁的时候,如果已经获取锁的线程是当前线程的话则直接再次获取成功;2. 由于锁会被获取n次,那么只有锁在被释放同样的n次之后,该锁才算是完全释放成功。
接下来我们继续看etcd实现分布式锁的源码。
在BaseLock.java中,查看如下代码段:
如果没有获取到etcd中的key,表示是空锁,返回false;
如果是可重入锁并且当线程是否已经持有key,持有的话则返回true。这就实现了可重入锁直接拿到锁,不用竞争和等待的非阻塞模式。
如果没抢到锁,则需要重新抢锁。
我们看一下EtcdLock.java中锁的核心代码:
上图第一个方框抢的锁,是EtcdClient.java中定义的casVal
方法名。如果没有抢到锁,就重复尝试抢锁,直到超时退出。此外,如果抢锁error,也需要重试抢锁。
那么,抢的锁在哪里定义的呢?EtcdClient.java中。
这样,我们就对上号了。EtcdClient.java定义访问监控和访问etcd集群,定义锁;BaseLock.java定义空锁和可重入锁的判断;EtcdLock.java定义抢锁的方法。
截止到目前,etcd做分布式锁我们已经介绍完。
redis实现分布式锁详解
接下来,我们看通过redis实现分布式锁。
Redis虽然本身支持多线程,但只是I/O支持多线程,但本质上命令处理还是只唯一线程串行处理。
在通过redis实现分布式锁的时候,也需要设置RedisClient.java.用于配置java连接、监控redi集群。
Redis自身的锁可以利用 Redis 的 setnx 命令(需要注意的是,分布式锁这样不成-)。
加锁命令:SETNX key value,当键不存在时,对键进行设置操作并返回成功,否则返回失败。KEY 是锁的唯一标识,一般按业务来决定命名。
解锁命令:DEL key,通过删除键值对释放锁,以便其他线程可以通过 SETNX 命令来获取锁。
锁超时:EXPIRE key timeout, 设置 key 的超时时间,以保证即使锁没有被显式释放,锁也可以在一定时间后自动释放,避免资源被永远锁住。
Redis加锁解锁伪代码如下:
if (setnx(key, 1) == 1){
expire(key, 30)
try {
//TODO 业务逻辑
} finally {
del(key)
}
}
上述锁实现方式存在一些问题:如果 SETNX 成功,在设置锁超时时间后,服务器挂掉、重启或网络问题等,导致 EXPIRE 命令没有执行,锁没有设置超时时间变成死锁。
有很多开源代码来解决这个问题,比如使用 lua 脚本。也就是说,虽然redis自己有加锁的命令,但我们在实际应用中不会这样用,因为会出现一些问题。
我们看两段通过redis实现分布式锁加锁和解锁的代码片段。
加锁代码:
public class RedisTool {
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
/**
* 尝试获取分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @param expireTime 超期时间
* @return 是否获取成功
*/
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
可以看到,我们加锁就一行代码:jedis.set(String key, String value, String nxxx, String expx, int time),这个set()方法一共有五个形参:
第一个为key,我们使用key来当锁,因为key是唯一的。
第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是我们在上面讲到可靠性时,分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。
第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
第五个为time,与第四个参数相呼应,代表key的过期时间。
解锁代码:
public class RedisTool {
private static final Long RELEASE_SUCCESS = 1L;
/**
* 释放分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @return 是否释放成功
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。那么为什么要使用Lua语言来实现呢?因为要确保上述操作是原子性的。eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。
参考文献:
https://mp.weixin.qq.com/s/qJK61ew0kCExvXrqb7-RSg