Java多线程之CAS机制详解
作者:一只爱打拳的程序猿
1. 什么是CAS?
CAS 全名 compare and swap (比较并交换)是一种基于 Java 实现的 计算机代数系统,用于多线程并发编程时数据在无锁的情况下保证线程安全安全运行。
CAS机制 主要用于对一个变量(操作)进行原子性的操作,它包含三个参数值:需要进行操作的变量A、变量的旧值B、即将要更改的新值C。
CAS机制 会对当前内存中的 A 进行判断看是否等同于 B ,如果相等则把 A 值更改为 C ,否则不进行操作。以下为 CAS 操作的一段伪代码:
boolean CAS(A,B,C) { if (&A == B) { &A = C; return true; } return false; }
当然,以上代码不具有原子性只是简单理解 CAS 的判定以及返回机制。真正的 CAS 只是一条 CPU 指令,相比于上述代码具有原子性 。
在了解 CAS 的基本判定后下面我们来看如何通过 Java 标准库来运用 CAS 。
2. CAS的应用
2.1 实现原子类
CAS 可以不加锁保证操作的原子性,Java 标准库提供了 Atomic + 包装类,相关的组合类来实现原子操作,这些类都是在 java.util.concurrent.atomic 包底下的。
以常用的 AtomicInteger 类来举例,AtomicInteger 类底下的 getAndIncrement 方法达到的效果就是自增类似于 i++ 操作,getAndDecrement 方法就是自减类似于 i-- 操作。
因此 AtomicInteger 类常见的方法有:
- getAndIncrement 方法,自增操作,类似于 i++。
- getAndDecrement 方法,自减操作,类似于 i--。
- get 方法,获取当前 AtomicInteger 类引用的值。
当然,Atomic + 其他“数值”包装类也能使用以上方法!
代码案例,不使用 synchronized 的情况下保证一个线程自增5000,另一个线程也自增5000,最后返回两线程之和10000:
public static void main(String[] args) throws InterruptedException { //初始化number为0 AtomicInteger number = new AtomicInteger(0); //线程1使number自增5000次 Thread thread1 = new Thread(()->{ for (int i = 0; i < 5000; i++) { number.getAndIncrement(); } }); //线程2也使number自增5000次(在线程1执行后) Thread thread2 = new Thread(()->{ for (int i = 0; i < 5000; i++) { number.getAndIncrement(); } }); thread1.start();//启动线程1 thread2.start();//启动线程2 thread1.join();//等待线程1执行完毕 thread2.join();//等下线程2执行完毕 System.out.println(number.get());//输出number的值 }
运行后打印:
以上代码,在不使用锁(synchronized)的情况下保证了线程的安全性。其底层运用的就是 CAS 机制,getAndIncrement 方法的具体实现,我们可以参考以下 伪代码 来理解:
class MyAtomicInteger { private int value; public int getAndIncrement() { int oldValue = value; while (CAS(value,oldValue,oldValue + 1) != true) { oldValue = value; } return oldValue; } }
假设 getAndIncrement 方法被两个线程同时调用,线程1 和 线程2 的 oldValue 值都为 0,内存中的 value 值为0。
1)线程1 进入了 getAndIncrement 方法,此时线程1进行 CAS 判定,发现线程1的 oldValue = value,就把 value 进行自增。
2) 线程2 进入了 getAndIncrement 方法,此时 线程2 进行 CAS 判定,发现 oldValue != value,进入 while 循环,把 value 赋值给 old Value。
3)经过以上判断后,线程2 再次进行 CAS 判断时,发现 oldValue = value 了,此时的 value 值又会自增。
以上的 伪代码 就能实现一个原子类,里面的 getAndIncrement 方法也是具备原子性的。通过上述图例就能很好的理解。
2.2 实现自旋锁
CAS的自旋锁指的是在使用CAS操作时,当CAS操作失败后,线程不直接阻塞等待,而是继续尝试执行CAS操作,即对前一次CAS操作的失败进行重试,直到CAS操作成功为止。
自旋锁的意思是程序使用循环来等待特定条件的实现方式,相较于传统的阻塞锁,自旋锁不会使线程进入阻塞状态,因此避免了线程上下文切换带来的开销。通常,当线程竞争的资源空闲等待的时间不长,自旋锁是一种比较高效的同步机制。
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; } }
Thread.currentThread() 为当前对象的引用,以上代码进行 CAS 判定时:
如果判断 this.owner 为空,则把当前对象的引用赋值给 this.owner。此时 CAS 方法返回 true,并取反,while 循环退出。判断 this.owner 不为空,则不做任何操作,CAS 方法返回 false,并取反,while 循环继续执行。由于 while 循环体内没有任何内容,while 条件判断会执行很快,直到 this.owner 加锁成功为止。
这就是自旋锁的体现,关于锁的策略在本专栏中有详细讲解。大家可以前去查找。
3. CAS的ABA问题
ABA 问题是:当线程1首先读取到共享变量值A。然后线程2先把这个共享变量值修改为B,再修改回A。
此时其他线程再进行 CAS 操作时误以为共享变量值没有被修改过,从而成功的将共享变量更改为新值。
但实际过程中共享变量经历了 由 A 变为 B,再由 B 变为 A,这样就可能会导致一些问题。
类似于,网上购买一部二手机。买的时候,卖家说是零件完好,到手后才发现是一部翻新机。这样就会导致手机用不了几天就出问题。至于到手之前,卖家不说是识别不出这部手机的好坏的。
3.1 ABA问题可能引起的BUG
ABA 问题,就是 CAS 机制导致的数据反复横跳。
假设,张三要去 ATM 取钱,张三余额有 1000 元,他要取 500 元。他安排两个线程,线程1 和 线程2 来并发执行取钱操作。
预期效果:线程1 执行取钱操作判断余额为 1000,执行余额 -500 操作,此时余额 500,线程2 处于阻塞等待状态。当 线程2 执行取钱操作判断余额不是 1000 不执行 -500 操作。
ABA问题出现:线程 1 执行取钱操作判断余额为 1000,执行余额 -500 操作,此时余额 500,线程2 阻塞等待状态。突然,张三的朋友给他转账了 500 ,此时 余额又变回了 1000。
线程2 进入取钱操作时,判断余额为 1000 元,执行余额 -500 操作,此时余额剩余 500。这就是 ABA 问题造成的后果,张三回家后打开手机查看余额剩余 500,实际张三被 ABA 问题坑了 500元。
3.2 解决ABA问题
CAS 操作,是将需要改变的值 A 与旧值 B 进行比较,相等则把新值 C 赋值给 A ,否则不做改变。解决 CAS 出现 ABA 问题,我们可以引入一个版本号,比较版本号是否符合预期。
比如在网上购买一部二手机,卖家会将手机的翻新程度进行一个版本号标记,翻新1次记版本号1,翻新2次的记版本号2,以此类推。这时候,客户会根据版本号来选择翻新程度相应的手机。
- 当版本号和读到的版本号相等,则修改数据,并把版本号 + 1。
- 当版本号高于读到的版本号,就操作失败(认为数据已经被修改过了)
根据以下 伪代码 来理解:
num = 0; version = 1; old = version; CAS(version,old,old+1,num); public void CAS(version,oldVersion,oldVersion+1,num){ if(version == oldVersion) { version = oldVersion + 1; num++; } }
对以上代码进行一个讲解, version 作为版本号,当 version 版本号等于读到的 oldVersion 版本号,则把 oldVersion +1 赋值给 version,并且 num ++ 。这样就能避免 ABA 问题的出现。
当然,Java 中 提供了一个 AtomicStampedReference<>类,这个类可以对某个类进行保证,这样就能提供上述的版本号管理功能。
public class TestDemo { private static final AtomicStampedReference<Integer> sharedValue = new AtomicStampedReference<>(10, 0); public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(() -> { int expectedStamp = sharedValue.getStamp(); int newValue = 20; sharedValue.compareAndSet(10, newValue, expectedStamp, expectedStamp + 1); System.out.println(Thread.currentThread().getName() + " updated sharedValue to " + newValue); }, "Thread-1"); Thread thread2 = new Thread(() -> { int expectedStamp = sharedValue.getStamp(); int oldValue = sharedValue.getReference(); int newValue = 30; sharedValue.compareAndSet(oldValue, newValue, expectedStamp, expectedStamp + 1); System.out.println(Thread.currentThread().getName() + " updated sharedValue to " + newValue); }, "Thread-2"); thread1.start(); thread1.join(); thread2.start(); thread2.join(); System.out.println("final value: " + sharedValue.getReference()); } }
运行后打印:
以上代码,共享变量的初始值为10,然后线程1将共享变量的值修改为20,线程2将共享变量的值修改为30。由于AtomicStampedReference类包含版本号信息,因此即使共享变量的值在这个过程中发生了ABA的变化,CAS操作也可以正常进行,不会出现误判现象。
谈谈你对 CAS 机制的理解?
CAS 全称 compare and swap 即比较并交换,它通过一个原子的操作完成“读取内存,比较是否相等,修改内存”这三个步骤,本质上需要 CPU 指令的支持。
ABA 问题如何解决?
我们可以给修改的数据加上一个版本号,初始化当前版本号与旧的版本号相等。判断当前版本号如果等于旧版本号则对数据进行修改,并使版本号自增。判断当前版本号大于旧版本号,则不进行任何操作。
到此这篇关于Java多线程之CAS机制详解的文章就介绍到这了,更多相关CAS机制详解内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!