线程安全与CAS策略
1. 线程安全问题
概述
线程安全问题发生在多线程环境中 , 并且存在数据共享 (即多个线程操作同一个数据)。
当多个线程访问某个类(数据)时,不管运行时环境采用何种调度方式或者这些进程将如何交替执行,并且在主调代码中**不需要任何额外的同步或协同,这个类(数据)都能表现出正确的行为,**那么就称这个类(数据)是线程安全的。
反之,当多个线程共享数据,数据产生与线程逻辑运行结果不一致的情况,(例如100张票被多个线程同时卖出一张,结果还剩99张)就是线程不安全。
线程安全问题发生的原因
在 Java 程序中,存储数据的内存空间分为共享内存和本地内存。线程在读写主存的共享变量时,会先将该变量拷贝一份副本到自己的本地内存,然后在自己的本地内存中对该变量进行操作,完成操作之后再将结果同步至主内存。主内存数据和本地内存的不同步,导致多个线程同时操作主内存里的同一个变量时,变量数据可能会遭到破坏
要想清楚理解线程不安全现象内在的本质,则需要对线程在内存中的存储过程进行了解,而这涉及到下面提及的java内存模型。
JMM模型(java 内存模型)
首先,JMM模型是一个抽象概念,与JVM有关系,但不是具体实际的物理内存划分。
JMM 可以看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,这样就可以屏蔽各个操作系统的差异,简化多线程编程。
(因为并发编程下,像CPU多级缓存
和指令重排序
这类设计可能会导致程序运行出现一些问题。)
线程与主内存
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:
- 比较 A 与 B 是否相等。(比较)
- 如果结果相等,则将 B 写入 A。(交换)
返回操作是否成功。
实现原理
针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来说:
Java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;
unsafe 的 CAS 依赖的是 JVM 针对不同的操作系统实现的 Atomic::cmpxchg(比较并交换);
Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 CPU 硬件提供的 lock 机制保证其原子性。
总而言之,CAS的实现原理是通过硬件和软件层面的配合来实现的。硬件提供了原子指令和锁机制,而软件层面的JVM使用了底层的CAS操作实现,依赖于处理器和操作系统提供的特性来保证CAS操作的原子性。
CAS应用
- 实现原子类
标准库中提供了 java.util.concurrent.atomic 包,里面的类都是基于CAS这种方式来实现的。
典型的就是 AtomicInteger 类,其中:
getAndIncrement 相当于 i++ 操作;
incrementAndGet 相当于 ++i 操作;
getAndDecrement 相当于 i-- 操作;
decrementAndGet 相当于 --i 操作。
缺点:原子变量使用在存在多线程和有共享变量的程序中,他的弊端就体现在操作共享变量的线程不能太多,太多之后就会出现卡顿,性能下降,倒还不如使用synchronized关键字了。
- 实现自旋锁
自旋锁是基于 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问题
- 什么是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比较的值是相等的,就进行了交换。
- 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。
- 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不相等,于是扣款失败。