目录
前言
- 并发编程的重要性:
从当下的计算机使用环境来看,单线程程序难以满足高性能和高吞吐量的需求,而并发编程允许多个线程执行任务,有效减少等待和资源闲置。
- 传统锁机制的局限性:
传统锁机制中,上锁和解锁的开销本身比较大,并且可能存在死锁问题。
CAS的概念
CAS(Compare And Swap,比较并交换)是一种用于多线程编程中的原子操作,他能实现无锁同步。在某些场景下,使用CAS可以在不加锁情况下,就能保证线程安全,避免了加锁操作,因此执行效率比较高。
写一个程序给大家体会一下CAS的作用,代码中使用到的是底层依靠CAS实现的原子类,具体使用方式不用深究。
初始count=0,分别用thread1和thread2对count++五千次:
public class Test1 {
//创建一个关于AtomicInteger的对象,这个对象中的getAndIncreament()方法可以对变量进行后置++,并且不会产生线程安全
private static AtomicInteger count =new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
//分别创建两个线程,判断是否会产生线程安全问题
Thread thread1=new Thread(()->{
for (int i = 0; i <5000 ; i++) {
//count++
count.getAndIncrement();
}
});
Thread thread2=new Thread(()->{
for (int i = 0; i <5000 ; i++) {
//count++
count.getAndIncrement();
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
//获取最终加加的值 10000?
System.out.println("count ="+count.get());
}
}
执行结果:
(两个线程在没有加锁的情况下,并发执行,count仍然是一万)
CAS的实现原理
1、CPU支持CAS的相关机制
CAS(Compare-And-Swap)之所以能够通过比较和交换实现原子操作,依赖的是CPU的支持。CPU在在修改变量的时候能够保证线程安全,又是因为它具有以下机制:
-
硬件支持:现代CPU提供了专门的指令来实现CAS操作,例如x86架构的CMPXCHG指令。这些指令由CPU内部硬件保证在执行过程中不会被中断,从而实现了原子性。CPU确保在执行CAS指令时,其他处理器或内核无法访问被操作的内存地址,直到操作完成。
-
总线锁定:在多处理器系统中,CPU使用总线锁定(Bus Locking)机制来确保内存操作的原子性。总线锁定是一种硬件机制,它可以在执行关键指令时锁定CPU与内存之间的总线,确保在操作期间没有其他处理器可以访问内存。通过总线锁定,CPU可以确保在读取、比较和写入这三个步骤之间不会发生任何干扰,从而实现原子操作。
-
缓存一致性协议:在多核系统中,CPU使用缓存一致性协议(如MESI协议)来维护多个处理器缓存之间的一致性。CAS操作依赖于这些协议来确保在不同核上运行的线程对同一内存地址的操作是可见且一致的。例如,当一个处理器执行CAS操作时,它会确保所有其他处理器都看到最新的内存值,并且在操作完成之前,其他处理器对该地址的操作会被延迟或阻塞。
-
内存屏障:为了防止编译器或CPU重排指令顺序,CAS操作通常与内存屏障(Memory Barriers)结合使用。内存屏障是指令序列中的特殊指令,它们确保在屏障之前的所有内存操作都完成后,才开始执行屏障之后的内存操作。这种机制防止了指令重排,进一步保证了CAS操作的原子性。
总之,CAS依靠的就是CPU的指令级别的操作实现的原子,而CPU的这些原子性指令,来自于上面的这四种机制。
2、CAS使用CPU指令实现原子操作的步骤
CPU中有多个核心,每个核心的寄存器的数量和种类非常多。
还内置了存储器,也就是高速缓存(cache),为了更好的描述操作,假定CPU只有一个核心:
CAS操作其实很简单,就是对数据进行比较、赋值。
执行一次CAS操作的流程(伪代码,只是用于理解):
第一次看上面这个伪代码可能觉得比较抽象,所以下面以实现一个支持多线程环境下count++的伪代码为例进行展开讲解:
class Test{
/*寄存器2是要交换的值*/
/**
* 注意:参数只要是可比较的类型就可以,不一定是Object*/
boolean CAS(Object Cache,Object register1,Object register2){
/**
* 注意,多个线程之间Cache是内存可见的,也就是说被的线程更改了Cache,当前线程是可以知道的。
* */
/* 寄存器1(旧的值)要和Cache(可能被其他线程修改过的值)进行比较,判断Cache是否被修改过*/
if(Cache==register1){
Cache=register2;
/*Cache被修改该成寄存器2,返回成功*/
return true;
}
/*Cache没有被修改,返回失败*/
return false;
}
//下面这个类,可以在多线程环境下保证安全
class MyAtomicInteger{
//value初始值为0
private int value =0;
//count++的方法
public int getAndIncreament(){
//把缓存中的值(value)读取到寄存器1(oldValue)
int oldValue=value;
//如果oldValue被别的线程修改过,那么重新从内存中读取新的oldValue
while(!CAS(value,oldValue,oldValue+1)){
//一直更新oldValue只要value和oldValue不相等。
oldValue=value;
}
return oldValue;
}
}
}
首先寄存器1会先去高速缓存(Cache)读取value的值,同时把value+1的运算放到寄存器2:
接下来,CPU不会立刻把寄存器2的值赋值给Cache,而是先对寄存器1的值和Cache中的值进行比较,如果相等,那么才修改Cache也就是高速缓存中的值。
这一步非常关键,倘若别的线程++的操作比较快,Cache中的value已经被他加成了3,那么就可以检测到:
重新写入新的value后,在把值赋值给Cache中的内存数据,从而保证每一次的++操作都时线程安全的:
简单来讲,只要Cache中的数据被修改,都能被CAS感知到(在多个线程中,Cache中的数据时可见的),重新加载新的数据、计算新的值、修改内存从而保证了线程安全。
CAS的应用
1、实现原子类
Java标准库中提供了java.util.concurrent.atomic 包,这里面都是基于CAS实现线程安全的类。
在文章开头使用的AtomicInteger就是最典型的类。
2、实现自旋锁
自旋锁的底层使用的也是CAS,这也是为什么说自旋锁会比悬挂等待锁的开销小很多的原因。
自旋锁伪代码:
class spinLock{
//这个引用用来指向正在上锁的线程
private Thread usingThread=null;
private boolean CAS(Thread usingThread,Thread Null,Thread curThread){
//如果当前没有线程在占用锁
if(usingThread==null){
//把要使用锁的线程的引用赋值给usingThread
usingThread=curThread;
//返回真
return true;
}
return false;
}
public void lock(){
//不为真,就一直检测,直到锁被释放(这是个空循环,执行的非常快,如果线程占用锁时间太长,效率就会降低,因为这个过程,CPU不会被释放)
while(!CAS(usingThread,null,Thread.currentThread())){
//空循环
}
}
public void unlock(){
usingThread=null;
}
}
ABA问题及其解决方法
1、什么是ABA问题?
ABA问题是CAS的一个小小瑕疵。因为在特定场合才会出现这种问题,并且这种问题出现的概率非常的小。具体为什么,来看下面的解释。
刚才我们在讲CAS实现原子操作的步骤中强调过,必须先判断寄存器1的值和高速缓存中的值是否相同,相同才能重新对高速缓存中的值进行修改。
ABA问题实际上就出现在上面这步上。缓存中的value可能会出现这种情况:
缓存中value刚开始等于3,这也是线程一中,oldValue所记录的值。(A)
随后另一个线程把缓存中的value修改成了2。(B)
然后又紧接着,另一个线程又把缓存中的value改回成了原来的3(A)
最后,回到线程一,value=oldValue=3,CAS会正常执行操作,感知不到A->B->A,它只能感知到前后两个结果是否匹配(A->A)。
试问这种从原来A改来改去,又改回A,最终对计算结果会有影响吗?
从运算数字的角度来讲,这的确没有任何问题,因为前A和后A在大小上没有任何区别,但是在某些逻辑上,这会造成严重的错误!
举个栗子:
在银行账户系统中,假如说A要还B 50元,而C要还A 50元:
如果A在转账给B的时候,C也呈现在给A转账,就可能出现ABA问题,A将会对B还款两次:
2、如何解决ABA问题?
如何避免A连续还B100元呢?
引入版本号:
版本号只会加,不会减(一般用的就是时间戳)。这种只加不减的方式可以避免出现A->B->A,只会出现A->B->C->...
在以后的CAS中不仅要判断实际内存中的value是否和oldValue一致,还要同时判断版本号是否一致。如果value和oldValue一致(可加可减),但是版本号(只能加不能减)不一致,那么说明出现了ABA问题,取消对内存修改的操作。