【JavaEE】常见的锁策略与CAS的ABA问题


1 常见的锁策略

1.1 乐观锁与悲观锁

乐观锁
 假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式的对数据是否产生并发冲突进行检测,如果发现了并发冲突,则返回错误信息,让用户来决定如何去做。
在这里插入图片描述

悲观锁
 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。

乐观和悲观的区别主要是在于对锁冲突的预估:如果说,预估锁冲突的概率是比较高的,就比较悲观~ 而如果锁冲突的概率是比较低的,就很乐观了~

像 Java 中 synchronizedReentrantLock 等独占锁就是悲观锁思想的实现。

public void performSynchronisedTask() {
    
    
    synchronized (this) {
    
    
        // 需要同步的操作
    }
}

private Lock lock = new ReentrantLock();
lock.lock();
try {
    
    
   // 需要同步的操作
} finally {
    
    
    lock.unlock();
}

而像 Java 中 java.util.concurrent.atomic 包下面的原子变量类(比如AtomicInteger、LongAdder)就是使用了乐观锁的一种实现方式 CAS 实现的。关于 CAS 我们往后再谈~

那么悲观锁和乐观锁分别适用于什么样的场景呢?

在这里插入图片描述
想必大家一定有和辅导员请假的经历,我们可以把乐观锁和悲观锁看成两类人:A 和 B。假设 A 同学 和 B 同学都想要给辅导员请假。

乐观 A 同学:

乐观 A 同学认为,辅导员是比较 空闲 的。于是,想要请假的时候就直接去办公室找辅导员。如果辅导员比较忙,则请假失败,等辅导员空闲的时候再次尝试请假;而如果辅导员真的比较闲,就能直接请假了。(在本次流程中,没有进行加锁操作,而是直接访问资源。就算没有请假成功,也进行了数据冲突的识别~)

悲观 B 同学:

悲观 B 同学认为,辅导员是比较 忙碌 的。因此,B 同学会先给辅导员打电话,询问其是否有时间(相当于加锁操作)。得到辅导员的肯定答复后,才会去辅导员办公室进行请假流程。如果得到了否定回答,则会等待一定时间,下次再和辅导员确定。

 两种方式乍一眼看区别不太大,但是实际上,适合的场景是不同的:如果辅导员是真的忙,那么使用悲观锁比较合适~ 如果使用乐观锁,就会像 A 同学那样,每次都跑去办公室确认是否空闲,无形中耗费了很多资源;如果辅导员是比较空闲的,那么使用乐观锁比较合适,如果使用悲观锁,则会让效率更低~
在这里插入图片描述

1.2 轻量级锁与重量级锁

轻量和重量则单纯是 从时间消耗 来看的,对于轻量级锁来说,其获取锁的速度会更快;对于重量级锁来说,获取锁的速度会更慢~

对于锁的原子性,追根溯源是 CPU 提供的:

  • CPU 提供了原子操作指令;
  • 操作系统基于原子操作指令,实现了 mutex 互斥锁;
  • JVM 又基于操作系统提供的互斥锁实现了 synchronizedReentrantLock 等关键字和类。

重量级锁 : 加锁机制重度依赖了操作系统提供的 mutex

  • 大量的内核态和用户态的切换
  • 容易引发线程的调度

轻量级锁 :与重量级锁不同,其加锁机制尽可能不使用操作系统提供的互斥锁,而是尽量在 用户态 完成,操作系统提供的 mutex 是下下策~

  • 少量的内核态和用户态的切换
  • 不太容易引发线程的调度

1.3 自旋锁与挂起等待锁

自旋锁是轻量级锁的典型实现,挂起等待锁是重量级锁的典型实现。

自旋锁
 一般情况下,线程在尝试获取锁失败后就会进入阻塞状态,而放弃CPU,等待被调度。而自旋锁则不同,其策略是:如果获取锁失败,则立即尝试获取锁,直到获取锁为止!一旦锁被其他线程释放,就能在第一时间获得锁。

伪代码如下:

while(抢锁(lock) == 失败) {
    
    
}

那么该如何区别理解自旋锁和挂起等待锁呢?

这就不得不谈一谈“舔狗”的心路历程了!

在这里插入图片描述
我们可以把自旋锁看作一个标准的舔狗!怎么舔呢?死皮赖脸!死缠烂打!坚持不懈的追求女神~ 当女神和前任分手后就能抓住一切机会上位!

而挂起等待锁就比较摆烂了,它追求女神的方式比较特别,仅仅是通知妹子: “那啥,我喜欢你嗷~” 于是就摆烂了。一直等到女神回过神儿来,看也没什么意思,要不就和你试试吧:“单着没?要不试试?” 可是,在你“上岸”前,女神到底中途又谈了多少任你是不清楚的。

自旋锁相较挂起等待锁来说,由于没有放弃CPU,不涉及线程的调度与阻塞,一旦锁被释放就能第一时间获取~ 但是,如此一来,也有很大的弊端!舔狗虽好,但是累啊!假设,舔狗在追求妹子的时候,妹子和现任谈的天长地久,你就 需要长时间的自旋,而这会消耗 CPU资源,是需要付出巨大成本的!而挂起等待锁,在挂起等待的时候是不需要消耗 CPU 的~

1.4 互斥锁与读写锁

互斥锁是一种很形象的说法:就像一个房间只能住一个人,任何人进去之后就把门锁上了,其他人都不可以进去,直到进去的人重新解锁,既是释放了这个锁资源为止。

而在多线程中,数据的读取方之间不会产生线程安全问题,但是数据的写入方之间以及和读取方之间都需要进行互斥。 而如果在此两种情景下都使用互斥锁,则会产生很大的性能损耗,而读写锁就是为了解决这一问题~
在这里插入图片描述

读写锁就是把读操作和写操作区分对待

  • 读加锁和读加锁之间, 不互斥.
  • 写加锁和写加锁之间, 互斥.
  • 读加锁和写加锁之间, 互斥.

在 Java 标准库,实现了 ReentrantReadWriteLock 类, 实现了读写
锁:

  • ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
  • ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.

注意,上述内容涉及到互斥,而互斥就涉及到了挂起等待了。而线程一旦被挂起,再次被唤醒就是未知的了。所以,尽可能减少互斥是提高效率的有效途径~

1.5 可重入锁与不可重入锁

可重入锁顾名思义就是可以重新进入的锁,允许同一个线程多次获取同一把锁。 连续的两次加锁并不会导致死锁。

Java 中的 Reentrant 开头命名的锁都是可重入锁,synchronized关键字锁也是可重入的。

不可重入锁该如何理解呢? - > 将自己锁死
在这里插入图片描述
伪代码如下(synchronized是可重入锁,这里只是用伪代码举例,并不会真的阻塞):
即一个线程没有释放锁,又尝试再次加锁

// 加锁!
synchronized(locker) {
    
    
    // 第二次加锁,锁已占用,阻塞等待
	synchronized(locker) {
    
    
	}
}

可重入锁的实现方式
实现的方式是在锁中记录该锁持有的线程身份,以及一个计数器(记录加锁次数)。如果发现当前加锁的线程就是持有锁的线程,则直接计数自增。

1.6 公平锁与非公平锁

假设有三个线程 A B C,A 获取锁成功, B 获取锁失败,阻塞等待, C 获取锁失败,同样阻塞等待,此时 A 释放锁,B C 会发生什么?

公平锁: 遵循 “先来后到”。当 A 释放锁后,B 能先于 C 获取锁。

非公平锁: 不遵循 “先来后到”,当 A 释放锁后,B 和 C 都有可能获取到锁。


2 CAS 操作

2.1 CAS 简介

Compare and swap,字面意思:”比较并交换“,相当于通过一个原子的操作, 同时完成 “读取内存, 比较是否相等, 修改内存” 这三个步骤。本质上需要 CPU 指令的支撑。一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。

  1. 比较 A 与 V 是否相等。(比较)
  2. 如果比较相等,将 B 写入 V。(交换)
  3. 返回操作是否成功。

一句话概括,就是将寄存器A的值和内存V的值进行比对,如果值相同,就把寄存器B的值和V的值进行交换。

伪代码如下:

boolean CAS(address, expectValue, swqpValue) {
    
    
	if (&address == expectValue) {
    
    
		&address = swqpValue;
		return true;
	}
	return false;
}

address -> 内存地址, expectValue -> 寄存器A,swapValue -> 寄存器B
CAS操作是一条 CPU 指令,具有原子性,伪代码只是用于方便理解。

CAS 可以视为一个乐观锁,当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。

2.2 CAS 的应用

2.2.1 实现原子类

标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的。
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作。

操作示例代码

AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();

原子类通过CAS操作是如何实现的呢?

伪代码如下:

class AtomicInteger {
    
    
    private int value;
    public int getAndIncrement() {
    
    
        int oldValue = value;
        while ( CAS(value, oldValue, oldValue+1) != true) {
    
    
            oldValue = value;
       }
        return oldValue;
   }
}

由于 Java 没法表示在寄存器中的值,oldValue 可以视为一个寄存器。
如果发现 value 和 oldValue 值相同,就把 oldValue + 1 设置到 value 中,相当于进行了 ++ 操作。

2.2.2 实现自旋锁

即通过 CAS 查看当前锁是否被某个线程所持有,如果已经被持有了则进行自旋等待;如果没有被持有,就把 owner 设置为当前加锁的线程~

public class SpinLock {
    
    
    private Thread owner = null;
    public void lock(){
    
    
        // 通过 CAS 看当前锁是否被某个线程持有. 
        // 如果这个锁已经被别的线程持有, 那么就自旋等待. 
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. 
        while(!CAS(this.owner, null, Thread.currentThread())){
    
    
       }
   }
    public void unlock (){
    
    
        this.owner = null;
   }
}

3 CAS 的 ABA 问题

什么是ABA问题呢?

假设有两个线程 t1 和 t2,有一个共享变量 num,其初始值为 A。接下来,线程 t1 想要使用 CAS 把值改成 B 就需要进行下面的步骤:

  • 读取 num 的值,记录到 oldNum 变量中
  • 使用 CAS 判定当前 num 是否为 A,如果为 A 则修改为 B

但是,在 t1 执行这两个操作之间,线程 t2 可能进行了某种骚操作!t2 可能将 num 的值修改成了 B 又修改成了 A。
在这里插入图片描述
需要明确的是,t1 线程使用 CAS 的初衷是期望 num 不变则进行修改,然并卵,t1 线程并不知道 num 是否被 t2 线程更改过并进行了复原~ 虽然,单单对修改 num 值来说,并没什么大问题。

ABA 问题可能引发的 BUG

在大部分情况下,t2 这样反复横跳的骚操作并不会引发什么问题,但是总有些特殊情况~

举个例子~

假设小黄有100块钱,想从 ATM 取款机上取 50 块钱。假设 ATM 创建了两个线程 t1 和 t2 并发执行 -50 的扣款过程~ 我们期望 t1 线程扣款成功,t2 线程扣款失败,使用 CAS 处理扣款过程。

一般情况下:

  1. t1 和 t2 线程都获取当前存款为 100 块,期望更新为 50,假设 t1 先执行,t2 阻塞等待
  2. t1 线程执行,扣款成功,当前存款剩余 50 块
  3. t2 线程执行,发现当前的存款是 50 块,与一开始的 100 块不同,所以扣款失败~

在这里插入图片描述

异常情况:

  1. t1 和 t2 线程都获取当前存款为 100 块,期望更新为 50,假设 t1 先执行,t2 阻塞等待
  2. t1 线程执行,扣款成功,当前存款剩余 50 块
  3. 在 t2 线程执行前,七七给小黄进行了转账 50 块的操作
  4. t2 线程执行,发现当前的存款是 100 块,与一开始的 100 块相同,所以扣款成功,当前存款剩余 50 块~

天呐~ 发现没有!因为 ABA 问题,导致了扣款两次!!!
在这里插入图片描述

如果解决 ABA 问题引发的 BUG?

想要解决上述所述问题,其实也很简单,只需要在原来的基础上 引入一个版本号~

具体操作如下:

  • CAS 在读取旧值的时候,也需要读取一个版本号。
  • 如果需要进行修改数据,则进行版本号的判断。如果当前版本号与之前读到的版本号旧值相同,则进行修改,并且版本号 + 1;如果当前版本号比之前的版本号旧值还要高,则认为已经修改过,不作处理。

对于上述的转账过程中,由于 ABA 问题引发的异常情况,引入版本号后也可以得到解决,具体如下:

  1. t1 和 t2 线程都获取当前存款为 100 块,期望更新为 50,假设 t1 先执行,t2 阻塞等待。默认读取的 版本号初始为 1。
  2. t1 线程执行,扣款成功,当前存款剩余 50 块,并进行版本号 + 1 的操作,此时 版本号为 2。
  3. 在 t2 线程执行前,七七给小黄进行了转账 50 块的操作, 版本号 + 1,此时 版本号为 3。
  4. t2 线程执行,发现当前的存款是 100 块,与一开始的 100 块相同,但是由于当前版本号为 3,而之前读取的版本号为 1,高于旧值,所以不进行修改操作,余额依然为 100。

在这里插入图片描述


写在最后

 以上便是本文的全部内容啦!创作不易,如果你有任何问题,欢迎私信,感谢您的支持!
本文被 JavaEE编程之路 收录点击订阅专栏 , 持续更新中。
 创作不易,如果你有任何问题,欢迎私信,感谢您的支持!

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/m0_60353039/article/details/130138032