【JUC】CAS策略

1. 线程安全问题

概述

​ 线程安全问题发生在多线程环境中 , 并且存在数据共享 (即多个线程操作同一个数据)。

​ 当多个线程访问某个类(数据)时,不管运行时环境采用何种调度方式或者这些进程将如何交替执行,并且在主调代码中**不需要任何额外的同步或协同,这个类(数据)都能表现出正确的行为,**那么就称这个类(数据)是线程安全的。

​ 反之,当多个线程共享数据,数据产生与线程逻辑运行结果不一致的情况,(例如100张票被多个线程同时卖出一张,结果还剩99张)就是线程不安全。

线程安全问题发生的原因

在 Java 程序中,存储数据的内存空间分为共享内存和本地内存。线程在读写主存的共享变量时,会先将该变量拷贝一份副本到自己的本地内存,然后在自己的本地内存中对该变量进行操作,完成操作之后再将结果同步至主内存。主内存数据和本地内存的不同步,导致多个线程同时操作主内存里的同一个变量时,变量数据可能会遭到破坏
image-20231024200216863

​ 要想清楚理解线程不安全现象内在的本质,则需要对线程在内存中的存储过程进行了解,而这涉及到下面提及的java内存模型。

JMM模型(java 内存模型)

首先,JMM模型是一个抽象概念,与JVM有关系,但不是具体实际的物理内存划分。

JMM 可以看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,这样就可以屏蔽各个操作系统的差异,简化多线程编程。

(因为并发编程下,像CPU多级缓存指令重排序这类设计可能会导致程序运行出现一些问题。)

image-20231024201741811

线程与主内存

JMM规定了线程和主内存之间的抽象关系。线程之间的共享变量存储在主内存中,每个线程都有自己的本地内存,本地内存中存储了该线程以读/写共享变量的副本。

主内存:

所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量。

本地内存:

每个线程都有一个私有的本地内存来存储共享变量的副本,并且,每个线程只能访问自己的本地内存,无法访问其他线程的本地内存。本地内存是 JMM 抽象出来的一个概念,存储了主内存中的共享变量副本。

关于主内存与工作内存直接的具体交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义来以下八种原子操作:

  • lock(锁定): 作用于主内存中的变量,将他标记为一个线程独享变量。
  • unlock(解锁): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
  • load(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。
  • use(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
  • write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

除了这 8 种同步操作之外,还规定了下面这些同步规则来保证这些同步操作的正确执行:

  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或
    assign)的变量,换句话说就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
  • 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock
    后,只有执行相同次数的 unlock 操作,变量才会被解锁。
  • 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign
    操作初始化变量的值。
  • 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量

2. 线程安全(并发编程)的三大特性

1. 原子性

即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

  • 原子操作:即不会被线程调度机制打断的操作,没有上下文切换。

2. 可见性

当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

3. 有序性

如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。
由于java会发生指令重排,因而在多线程中需要有序性避免出现数据异常。

3. 线程安全的解决方案

1. 同步锁机制-Synchronized 关键字

​ 对共享数据的代码块、或者方法添加synchronized关键字

2. 共享数据使用原子包装类

实现机制——CAS策略
CAS策略

CAS(Compare and Swap)是一种并发控制机制,用于实现多线程环境下的原子操作。它是通过比较内存中的值与期望的值,如果相等则交换,否则不做任何操作CAS是一种乐观锁技术,不需要使用传统的锁机制来保证线程安全。

例如,假设内存中的原始数据为 A,旧的预期值为 B,需要修改的新值为 C:

  1. 比较 A 与 B 是否相等。(比较)
  2. 如果结果相等,则将 B 写入 A。(交换)

返回操作是否成功。

实现原理

针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来说:

Java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;
unsafe 的 CAS 依赖的是 JVM 针对不同的操作系统实现的 Atomic::cmpxchg(比较并交换);
Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 CPU 硬件提供的 lock 机制保证其原子性。
总而言之,CAS的实现原理是通过硬件和软件层面的配合来实现的。硬件提供了原子指令和锁机制,而软件层面的JVM使用了底层的CAS操作实现,依赖于处理器和操作系统提供的特性来保证CAS操作的原子性。

CAS应用
  1. 实现原子类
    标准库中提供了 java.util.concurrent.atomic 包,里面的类都是基于CAS这种方式来实现的。

典型的就是 AtomicInteger 类,其中:

getAndIncrement 相当于 i++ 操作;
incrementAndGet 相当于 ++i 操作;
getAndDecrement 相当于 i-- 操作;
decrementAndGet 相当于 --i 操作。

缺点:原子变量使用在存在多线程和有共享变量的程序中,他的弊端就体现在操作共享变量的线程不能太多,太多之后就会出现卡顿,性能下降,倒还不如使用synchronized关键字了。

  1. 实现自旋锁

自旋锁是基于 CAS 实现的更灵活的锁,其伪代码如下:

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;
	}
}
ABA问题
  1. 什么是ABA问题

​ ABA问题是CAS操作的一个潜在问题。ABA问题指的是,在CAS操作中,如果一个值原来是A,后来变成了B,然后又变回了A,那么CAS操作就可能会误判为成功。这是因为CAS只比较了值,并没有考虑过程中的变化。例如下面的情况:

​ 假设存在两个线程 t1 和 t2,有一个共享变量 num,初始值为 A。

​ 接下来,线程 t1 想使用 CAS 把 num 值改成 Z,那么就需要:

​ 先读取 num 的值,记录到 oldNum 变量中;
​ 然后使用 CAS 判定当前 num 的值是否为 A,如果为 A,就修改成 Z。
​ 但是,在 t1 执行这两个操作之间,t2 线程把 num 的值从 A 改成了 B,又从 B 改成了 A。

​ 到这一步,t1 线程无法区分当前这个变量始终是 A,还是经历了一个变化过程,但与oldNum比较的值是相等的,就进行了交换。

  1. ABA问题引发的BUG

大部分的情况下,t2线程这样的一个反复横跳改动,对于 t1 是否修改 num 是没有影响的,但是不排除一些特殊情况:

​ 假如张三有100元存款,想从ATM机中取出50元,当按下取钱按钮的时候因为网络延迟,导致张三以为没有按成功,因此又按了一次按钮,此时就创建了两个线程,来并发的来执行取50元这个操作。

​ 在正常情况下,我们所期望的就是一个线程执行 -50 成功;而另一个线程执行 -50 失败。
​ 如果使用 CAS 的方式来执行这个扣款过程就有可能出现问题。

正常过程:

存款为100元,线程1 获取到当前存款的值为100,期望更新为50;线程2 也获取到当前存款的值为100,期望更新为50。
线程1 使用CAS先执行扣款成功,存款被改成了50。
线程2 也使用CAS尝试扣款,发现此时的存款50与获取的旧值100不相等,因此执行失败。

异常过程:

存款为100元,线程1 获取到当前存款的值为100,期望更新为50;线程2 也获取到当前存款的值为100,期望更新为50。
线程1 使用CAS先执行扣款成功,存款被改成了50。
在 线程2 执行扣款操作之前,张三的朋友还钱给张三,向他的账户转了50元,此时张三的余额就又变成100元了。
线程2 尝试执行扣款操作,发现此时余额与刚才获取的旧值100相等,于是扣款50元,余额也变成50了。
在这个异常过程中,两次都扣款成功了,但是张三却只拿到了50元,另外50缺丢失了,这就是ABA问题所引发的BUG。

  1. ABA问题的解决方式

​ 给要修改的值,引入版本号。在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期。

​ CAS 操作在读取旧值的同时,也要读取版本号;
​ 真正修改的时候:
​ 如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
​ 如果当前版本号高于读到的版本号,就操作失败(认为数据已经被修改过了)。
例如针对上面的场景:

假如张三有100元存款,想从ATM机中取出50元,当按下取钱按钮的时候因为网络延迟,导致张三以为没有按成功,因此又按了一次按钮,此时就创建了两个线程,来并发的来执行取50元这个操作。

存款为100元,线程1 获取到当前存款的值为100,版本号为1,期望更新为50;线程2 也获取到当前存款的值为100,版本号为1,期望更新为50。
线程1 使用CAS先执行扣款成功,存款被改成了50,版本号修改为2。
在 线程2 执行扣款操作之前,张三的朋友还钱给张三,向他的账户转了50元,此时张三的余额就又变成100元了,版本号更新为3。
线程2 尝试执行扣款操作,发现此时余额与刚才获取的旧值100相等,但是旧版本号1与当前版本号3不相等,于是扣款失败。

文章引用

【JMM详解-CSDN博主「千月落」】https://blog.csdn.net/yuanchengmm/article/details/131490495

他的账户转了50元,此时张三的余额就又变成100元了,版本号更新为3。

线程2 尝试执行扣款操作,发现此时余额与刚才获取的旧值100相等,但是旧版本号1与当前版本号3不相等,于是扣款失败。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值