大数跨境
0
0

乐观锁和无锁

乐观锁和无锁 二进制跳动
2021-02-03
2
导读:乐观锁和无锁


点击上方蓝字关注我们



CAS

CAS 是 Compare And Swap 的缩写,意思是比较并替换。

如果本次修改不成功,怎么办?很多情况下,它将一直重试,直到修改为期望的值。


立春

LiChun


拿 AtomicInteger 类来说,相关的代码如下:

public final boolean compareAndSet(int expectedValue, int newValue) {
       return U.compareAndSetInt(this, VALUE, expectedValue, newValue);
}

比较和替换是两个动作,CAS 是如何保证这两个操作的原子性呢?

我们继续向下追踪,发现是 jdk.internal.misc.Unsafe 类实现的,循环重试就是在这里发生的:

@HotSpotIntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
   int v;
   do {
       v = getIntVolatile(o, offset);
  } while (!weakCompareAndSetInt(o, offset, v, v + delta));
    return v;}

JVM 内部,在 linux 机器上参照 os_cpu/linux_x86/atomic_linux_x86.hpp。可以看到,最底层的调用,是汇编语言,而最重要的,就是cmpxchgl指令。到这里没法再往下找代码了,因为 CAS 的原子性实际上是硬件 CPU 直接保证的


关于 Atomic 类,还有一个小细节,那就是它的主要变量,使用了 volatile 关键字进行修饰。代码如下,你知道它是用来干什么的吗?

private volatile int value;

答案:使用了 volatile 关键字的变量,每当变量的值有变动的时候,都会将更改立即同步到主内存中;而如果某个线程想要使用这个变量,就先要从主存中刷新到工作内存,这样就确保了变量的可见性。有了这个关键字的修饰,就能保证每次比较的时候,拿到的值总是最新的。


立春,为二十四节气之首,又名正月节、岁节、改岁、岁旦等。立,是“开始”之意。春,代表着温暖、生长。


乐观锁

为什么读多写少的情况,就适合使用乐观锁呢?悲观锁在读多写少的情况下,不也是有很少的冲突吗?

其实,问题不在于冲突的频繁性,而在于加锁这个动作上。

  • 悲观锁需要遵循下面三种模式:一锁、二读、三更新,即使在没有冲突的情况下,执行也会非常慢;

  • 如之前所说,乐观锁本质上不是锁,它只是一个判断逻辑,资源冲突少的情况下,它不会产生任何开销。

Redis 分布式锁

我们先来看一段代码:

public void lock(String key, int timeOutSecond) {    for (; ; ) {        boolean exist = redisTemplate.opsForValue().setIfAbsent(key, "", timeOutSecond, TimeUnit.SECONDS);        if (exist) {            break;        }    }}public void unlock(String key) {    redisTemplate.delete(key);}

这段代码中的问题很多,我们只指出其中一个最严重的问题。在多线程中,执行 unlock方法的,只能是当前的线程,但在上面的实现中,由于超时存在的原因,锁被提前释放了。考虑下面 3 个请求的时序:

请求A:获取了资源 x 的锁,锁的超时时间为 5 秒

请求A:由于业务执行时间比较长,业务阻塞等待,超过 5 秒

请求B:第 6 秒发起请求,结果发现锁 x 已经失效,于是顺利获得锁

请求A:第 7 秒,请求 A 执行完毕,然后执行锁释放动作

请求C:请求 C 在锁刚释放的时候发起了请求,结果顺利拿到了锁资源

此时,请求 B 和请求 C 都成功地获取了锁 x,我们的分布式锁失效了,在执行业务逻辑的时候,就容易发生问题。

所以,在删除锁的时候,需要判断它的请求方是否正确。首先,获取锁中的当前标识,然后,在删除的时候,判断这个标识是否和解锁请求中的相同。


可以看到,读取和判断是两个不同的操作,在这两个操作之间同样会有间隙,高并发下会出现执行错乱问题,而稳妥的方案,是使用 lua 脚本把它们封装成原子操作。


改造后的代码如下:

public String lock(String key, int timeOutSecond) {    for (; ; ) {        String stamp = String.valueOf(System.nanoTime());        boolean exist = redisTemplate.opsForValue().setIfAbsent(key, stamp, timeOutSecond, TimeUnit.SECONDS);        if (exist) {            return stamp;        }    }}public void unlock(String key, String stamp) {    redisTemplate.execute(script, Arrays.asList(key), stamp);}

相应的 lua 脚本如下

local stamp = ARGV[1]local key = KEYS[1]local current = redis.call("GET",key)if stamp == current then    redis.call("DEL",key)    return "OK"end

可以看到,reids 实现分布式锁,还是有一定难度的。推荐使用 redlock 的 Java 客户端实现 redisson



我们可以看下 redisson 分布式锁的典型使用代码。

String resourceKey = "goodgirl";RLock lock = redisson.getLock(resourceKey);try {    lock.lock(5, TimeUnit.SECONDS);    //真正的业务    Thread.sleep(100);} catch (Exception ex) {    ex.printStackTrace();} finally {    if (lock.isLocked()) {        lock.unlock();    }}

使用redis monitor可以看到具体的操作步骤。

无锁(Lock-Free)

这里就不展开了,大家可以google看下,类似的应用有ConcurrentLinkedQueue

Disruptor 是一个无锁、有界的队列框架,它的性能非常高。它使用 RingBuffer、无锁和缓存行填充等技术,追求性能的极致,在极高并发的场景,可以使用它替换传统的 BlockingQueue。

小结

乐观锁严格来说,并不是一种锁。它提供了一种检测冲突的机制,并在有冲突的时候,采取重试的方法完成某项操作。假如没有重试操作,乐观锁就仅仅是一个判断逻辑而已。

悲观锁每次操作数据的时候,都会认为别人会修改,所以每次在操作数据的时候,都会加锁,除非别人释放掉锁。

乐观锁在读多写少的情况下,之所以比悲观锁快,是因为悲观锁需要进行很多额外的操作,并且乐观锁在没有冲突的情况下,也根本不耗费资源。但乐观锁在冲突比较严重的情况下,由于不断地重试,其性能在大多数情况下,是不如悲观锁的。

由于乐观锁的这个特性,乐观锁在读多写少的互联网环境中被广泛应用。

我们主要看了在数据库层面的一个乐观锁实现,以及Redis 分布式锁的实现,后者在实现的时候,还是有很多细节需要注意的,建议使用 redisson 的 RLock。

当然,乐观锁有它的使用场景。当冲突非常严重的情况下,会进行大量的无效计算;它也只能保护单一的资源,处理多个资源的情况下就捉襟见肘;它还会有 ABA 问题,使用带版本号的乐观锁变种可以解决这个问题。

【声明】内容源于网络
0
0
二进制跳动
15 年 + 技术老兵 架构师|技术总监|科技创业技术合伙人 曾任职苏宁科技、电讯盈科、联想云 专注架构设计与技术落地
内容 739
粉丝 0
二进制跳动 15 年 + 技术老兵 架构师|技术总监|科技创业技术合伙人 曾任职苏宁科技、电讯盈科、联想云 专注架构设计与技术落地
总阅读62
粉丝0
内容739