简介
- 关于多线程
首先,先复习一下Java多线程。我们都知道,启动一个Java程序,操作系统会为其创建一个进程,而一个进程中可以创建多个线程,线程之间能够访问共享的内存变量,通过操作系统处理器的调度,可以让我们的程序变得更加高效。
Java线程在运行的生命周期中有6种不同的状态。
状态名称 | 说明 |
---|---|
NEW | 初始状态,没有调用start()方法 |
RUNABLE | 运行状态 |
BLOCKED | 阻塞状态,表示线程阻塞于锁 |
WAITING | 等待状态 |
TIME_WAITING | 超时等待状态 |
TERMINATED | 终止状态 |
实际项目中,我们经常会遇到类似这样的并发场景:
优惠券的抢购:多个线程抢购一定数量的优惠券,最后剩余的优惠券数量为负数
产生的原因是:当多个线程对同一个变量进行操作时,会出现一个线程的业务逻辑没有结束,另一个线程就取获取变量进行操作,这时变量还处于之前的值。
解决这种问题的方式有很多,比如,可以用volatile修饰成员变量,这样对该变量的访问必须从共享内存中获取,同时它的改变必须同步刷新到共享内存中,保证所有线程的可见性;还有最常见的方法,使用关键字synchronized实现对同步块同步方法的上锁。
当然,在一个JVM中这样的方法是可行的,当出现分布式,多个节点,即在WEB项目中,多个客户端对一个数据进行请求时,则需要使用分布式锁。
分布式锁 Java主要有两种实现,redis和zookeeper。本文主要是介绍redislock的实现
-关于redis
redis英文全称[Remote Dictionary Service],(WIKI解释:Redis是一个使用ANSI C编写的开源、支持网络、基于内存、可选持久性的键值对存储数据库),在互联网技术领域,Redis是使用最为广泛的存储中间件,在实际项目开发中,redis常被用来做一些数据的缓存,以及本文所讲的分布式锁,不论应用于哪方面其效率都是很高的。
实现
redis分布式锁的实现原理是线程执行业务逻辑前,必须先获取锁,获取锁的方法其实是不断尝试在redis中set一条记录,set成功才返回true,随后执行业务逻辑,执行结束后,释放锁,即从redis中将这条记录删除,以便其他线程可以获取锁。
- 获取锁
redis的官方文档给出了解决思路,如下图为获取锁——set记录的方法:
image.png这条指令是setnx和expire组合在一起的原子指令,30000对应的是过期时间,单位是毫秒。
在以前的redis版本,这个指令需要分两部分执行。一般执行一个并发业务时,定义一个唯一的key,通过setnx(set if not exists)指令,当只有不存在此key值的记录时才能set,返回true。这时会出现一个问题,当业务逻辑没执行完,锁没有释放的情况下,出现服务宕机,那这时redis中锁就会一直存在,别的客户端就获取不到锁,造成死锁现象。这时需要给锁添加过期时间,即进行expire指令,超出过期时间锁自动删除。即如下:
> setnx lock:codehole true
OK
> expire lock:codehole 5
这个时候又会出现一个问题,在setnx后锁成功插入,执行expire之前,服务出现宕机,进程挂掉了,那么expire还是得不到执行,一样会造成死锁。
为解决这种情况,出现了现在的原子指令。
所谓原子操作,即保证操作一直运行到结束。在这里,把setnx与expire结合成一条语句执行,保持了操作的原子性,要么都成功,要么都不成功。保证操作原子性在分布式锁是非常非常非常关键的。
下面是Java中的实现:
/**
* 上锁成功后返回值
*/
private static final String LOCK_SUCCESS = "OK";
/**
* SetNX方法中NX的含义
*/
private static final String SET_IF_NOT_EXIST = "NX";
/**
* SetNX方法中PX的含义
*/
private static final String SET_WITH_EXPIRE_TIME = "PX";
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 使用Jedis客户端执行原子指令
*
* @param key
* @param value
* @param expiry
* @return
*/
public boolean setNX(String key, String value, long expiry) {
//指令执行成功的话返回"OK"
String result = stringRedisTemplate.execute((RedisCallback<String>) connection -> {
JedisCommands commands = (JedisCommands) connection.getNativeConnection();
return commands.set(key, value, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expiry);
});
return LOCK_SUCCESS.equals(result);
}
这个demo基于springboot1.5,对redis进行了整合,通过@Resource注释,注入了StringRedisTemplate,并结合Jedis对redis进行操作。
有了这个方法,就可以对获取锁的方法进行进一步的处理。可以在一定的时间内不断的重试获取锁,这里必须设置一个重试超时时间和最多重试次数,防止线程饥饿,一直在重试获取锁。
- 解锁
成功获取锁了,接下来的问题就是如何安全的解锁——将锁删除。先来看看官方文档的实现:
image.png解锁需要注意一个问题,如果一个线程由于某些原因,执行任务的时间超过了锁的过期时间,那么redis将会释放锁,其他线程将会获取锁,这时,原来的超时的线程执行完自己的业务逻辑以后,会执行解锁操作,把其他线程业务逻辑还没执行完的锁就被误删,别的线程也会再次取得锁。如此反复。
为了解决这个问题,官方给出的方法是,redis执行Lua脚本,删除锁前,比较value值是否相等,相等才能进行删除操作。Lua脚本可以保证连续多个指令原子性的完成。因为value的比较和key的删除不是一个原子操作。
下面是Java中执行Lua脚本的方法:
/**
* lua脚本:key相等时判断value值是否相等,相等的话则删除
*/
private static final String LUA_UNLOCK_SCRIPT = "if redis.call(\"get\",KEYS[1]) == ARGV[1] " +
"then " +
"return redis.call(\"del\",KEYS[1]) " +
"else " +
"return 0 " +
"end";
/**
* 调用Lua脚本删除key
*
* @param keys
* @param args
* @return
*/
public boolean delate(List<String> keys, List<String> args) {
Object result = stringRedisTemplate.execute((RedisCallback<Object>) connection -> {
Object nativeConnection = connection.getNativeConnection();
if (nativeConnection instanceof Jedis) {
return ((Jedis) nativeConnection).eval(LUA_UNLOCK_SCRIPT, keys, args);
}
//如果时redis集群
else if (nativeConnection instanceof JedisCluster) {
return ((JedisCluster) nativeConnection).eval(LUA_UNLOCK_SCRIPT, keys, args);
}
return 0L;
});
return result != null && Long.parseLong(result.toString()) > 0;
}
可以使用如下的方式设置value值,尽可能保证每个线程的value值唯一,并可以使用ThreadLocal线程变量,存储当前线程的value。ThreadLocal 是一个以ThreadLocal对象为键、任意对象为值得存储结构。
String value = UUID.randomUUID().toString();
思考
这种方案也不是那么的完美,如果出现线程业务超时完成的话,那么一样会有别的线程可以获取到锁执行自己的业务,这样虽然不会误删当前线程的锁,但是,这个线程获取到的数据或者变量是上一个锁执行完成之后的,一样可能会造成数据异常。
如果项目要求高的话,可以尝试使用zookeeper来做分布式锁,或者别的解决方案。
网友评论