1、CAS的引出
在理解什么是CAS之前让我们来看一段代码:
public class Demo01 {
/**
* 记录访问次数
*/
static int count;
public static void request() throws InterruptedException {
//调用一次耗时5毫秒
TimeUnit.MILLISECONDS.sleep(5);
count++;
}
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
int threadSize = 100;
/**
* 为了保证100线程全部执行,所以使用CountDownLatch栅栏类
*/
CountDownLatch countDownLatch = new CountDownLatch(threadSize);
for(int i = 0 ; i< threadSize ; i++){
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
try {
for (int i = 0 ; i< 10 ; i++){
request();
}
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
countDownLatch.countDown();
}
}
});
thread.start();
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
long endTime = System.currentTimeMillis();
System.out.println("当前线程:"+Thread.currentThread().getName() + "耗时:"+(endTime-startTime)+"ms,count:"+count);
}
}
上面的代码的说明:在多线程情况下,访问一个方法,访问成功一次则count++,共有开启100条线程,每个线程访问10次,count的最终结果预期为1000.
最终程序运行结果为:
我们通过结果发现,访问数并你没有达到我们预期的1000,运行多次后count的结果依旧是<1000的。
那这是为什么呢?
我们不难发现,count++ 不是原子性操作,它实际上是由三步来完成的!
- 获取count的值 ,记做A : A = count
- 将A的值+1 ,记做B : B = A + 1
- 将B的值赋值给count
当有多个线程同时执行count++,他们同时执行到上面步骤的第一步,得到count是一样的,3步操作结束后。count只加1,导致count结果不正确
基于这个问题,我们可以对count++操作的时候,我们让多个线程排队处理,多个线程同时到达request()方法的时候,只能允许一个线程可以进去操作,其他线程在外面等待,等里面的线程处理完毕后,外面等待的线程再进去一个,这样操作的count++就是排队进行的,结果一定是正确的。
我们只需要在当前代码上稍微做点修改即可,其他代码不变,在request()方法上添加synchronized 关键字,锁对象为当前类的Class对象。
public synchronized static void request() throws InterruptedException {
TimeUnit.MILLISECONDS.sleep(5);
count++;
}
我们再次执行代码,结果是:
通过结果我们不难发现,count确实达到了我们预期的结果,但耗时太长了,因为线程中的request方法使用synchronized关键字修饰,保证了并发情况下,request方法同一时刻只允许一个线程进入,request加锁相当于串行执行了,所以才会出现耗时长。
怎么解决这耗时长的问题呢?
在之前我们提到过,count++实际上是由三部分组成的,
- 获取count的值 ,记做A : A = count
- 将A的值+1 ,记做B : B = A + 1
- 将B的值赋值给count
接下来,我们将升级第三步,将第三步分为四个步骤去完成,分别是
- [1] 获取锁
- [2] 获取当下count的最新值,记做expectCount
- [3] 判断expectCount的值是否等于A,如果相等,则将B的值赋值给count,并返回true,否则返回false,然后再次执行count++操作,直到返回true为止。
- [4] 释放锁
代码如下:
/**
* 记录访问次数
* volatile : 表示多线程之间,count值可见
*/
volatile static int count;
public static int getCount() {
return count;
}
public static void request() throws InterruptedException {
TimeUnit.MILLISECONDS.sleep(5);
// count++;
int expectCount;
while(!CompareAndSwap(expectCount = getCount(), expectCount+1)){}
}
/**
* 只给count++的第三步加锁
* @param expectCount 期望值
* @param newCount 需要给count赋值的新值
* @return 成功返回true 失败返回false
*/
public static synchronized boolean CompareAndSwap(int expectCount , int newCount){
if(expectCount == getCount()){
count = newCount;
return true;
}
return false;
}
public static void main(String[] args) {
//main方法不做改变
}
执行代码,结果是:
从结果中,我们可以很直接的发现,耗时变短了,count的值也是符合我们预期的值,这就是CAS。
2、介绍CAS机制
CAS(Compare-And-Swap):比较再替换。简单来说,比较和替换是使用一个期望值和一个变量的当前值进行比较,如果当前变量的值与我们期望的值相等,就使用一个新值替换当前变量的值。CAS包含三个操作数 (V(内存位置)、A(期望值)、B(新值)),如果内存位置V的值与期望值A相同,那么处理器判断内存某个位置的值是是否为预期值,如果是则更改为B的值,如果不是,处理器不会做任何操作,并且这个过程是原子的。
3、JDK中是如何实现CAS呢?
以比较简单的AtomicInteger为例(一些AtomicInteger的方法):
我们发现,AtimicInteger的方法都是通过unsafe对象来调用的。
Unsafe是CAS的核心类,由于Java是无法直接访问底层系统的,需要通过本地(native)方法来防访问,Unsafe相当于一个后门,基于该类可以直接操作特定内存的数据,Unsafe类存在于sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存。在Java中CAS操作的执行依赖于Unsafe类的方法。
Unsafe的方法都是由native修饰的,功力还比较浅薄,等高深一点再进去看原理吧!
总结:JDK实现CAS机制是因为底层调用了Unsafe的CAS方法
4、CAS机制的优缺点
优点:CAS是一种乐观锁,而且是一种非阻塞的轻量级的乐观锁,什么是非阻塞式的呢?其实就是一个线程想要获得锁,对方会给一个回应表示这个锁能不能获得。在资源竞争不激烈的情况下性能高,相比synchronized重量锁,synchronized会进行比较复杂的加锁,解锁和唤醒操作。
缺点:
1.CPU开销较大:在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。
2.不能保证代码块的原子性:CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。
3.ABA问题:CAS最大的问题。CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,在CAS方法执行之前,被其它线程修改为了B、然后又修改回了A,那么CAS方法执行检查的时候会发现它的值没有发生变化,但是实际却变化了。这就是CAS的ABA问题。
5、代码模拟ABA问题
public class CASABATest {
/**
* AtomicInteger: 可以用原子方式更新的 int 值
*
* 准备数据 , 初始值为1
*
*/
public static AtomicInteger a = new AtomicInteger(1);
public static void main(String[] args) {
/**
* 创建两个线程,模拟CAS的ABA问题
* 主线程执行+1操作
* 其他线程在主线程替换旧值时执行+1,-1操作
*/
Thread main = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("操作线程:"+Thread.currentThread().getName()+",初始值:"+a.get());
int expectNum = a.get();
int newNum = expectNum + 1;
try {
Thread.sleep(1000L); // main线程休息一秒,让出cpu的资源
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean isCASSuccess = a.compareAndSet(expectNum, newNum);
System.out.println("操作线程:"+Thread.currentThread().getName()+",[incread],CAS:"+isCASSuccess);
}
},"主线程");
Thread other = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10L);//休息10毫秒,保证主线程运行到了sleep
int incNum = a.incrementAndGet(); // +1 操作
System.out.println("操作线程:"+Thread.currentThread().getName()+",【increment】值为:"+a.get());
int decNum = a.decrementAndGet(); // -1 操作
System.out.println("操作线程:"+Thread.currentThread().getName()+",【decrement】值为:"+a.get());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"其他线程");
main.start();
other.start();
}
}
代码分析:创建两个线程,主线程执行+1操作,干扰线程执行+1,-1操作,然后通过AtomicInteger的compareAndSet() ->替换成功则返回true,否则返回false。
运行代码,结果如下:
我们看到CAS的结果为true,但我们已经在干扰线程中对当前值进行了操作,这就意味着CAS并没有察觉到ABA问题,这就是CAS的ABA问题。
6、JDK自带的ABA问题解决方法
AtomicStampedReference主要包含一个对象引用及一个可以自动更新的整数“stamp”的Pair对象来解决ABA问题。
修改数据需要提供原来数据的引用以及版本号,只有版本号一致了,你就能去修改数据。我们去看一下他的compareAndSet()方法。
当你的期望引用以及你的期望版本号与Pair对象中的引用数据以及版本号一致,才可进行替换操作。
casPair()的底层源码:
ABA问题的解决,代码如下:
public class CASABATest02 {
/**
* AtomicInteger: 可以用原子方式更新的 int 值
*
* 准备数据 , 初始值为1
*
*/
public static AtomicStampedReference<Integer> a = new AtomicStampedReference<>(1,1);
public static void main(String[] args) {
/**
* 创建两个线程,模拟CAS的ABA问题
* 主线程执行+1操作
* 其他线程在主线程替换旧值时执行+1,-1操作
*/
Thread main = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("操作线程:"+Thread.currentThread().getName()+",初始值:"+a.getReference());
Integer expectReference = a.getReference(); //期望引用
Integer newReference = expectReference + 1; //新的引用
Integer expectStamp = a.getStamp();//期望版本
Integer newStamp = expectStamp + 1;
try {
Thread.sleep(1000L); // main线程休息一秒,让出cpu的资源
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean isCASSuccess = a.compareAndSet(expectReference, newReference,expectStamp,newStamp);
System.out.println("操作线程:"+Thread.currentThread().getName()+",[incread],CAS:"+isCASSuccess);
}
},"主线程");
Thread other = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10L);//休息10毫秒,保证主线程运行到了sleep
a.compareAndSet(a.getReference(),a.getReference()+1,a.getStamp(),a.getStamp()+1);//加1
System.out.println("操作线程:"+Thread.currentThread().getName()+",【increment】值为:"+a.getReference()+"版本:"+a.getStamp());
a.compareAndSet(a.getReference(),a.getReference()-1,a.getStamp(),a.getStamp()+1);//-1
System.out.println("操作线程:"+Thread.currentThread().getName()+",【decrement】值为:"+a.getReference()+"版本:"+a.getStamp());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"其他线程");
main.start();
other.start();
}
}
代码分析:我们将AtomicInteger 对象替换成了AtomicStampedReference对象,给期望引用添加了期望版本号,在执行CAS操作时,如果期望引用和新引用或者期望版本和新版本不一致,compareAndSet()方法就会返回false,很显然,我们在干扰线程中对版本进行了两次+1操作,而期望版本是一次+1操作,所以一定返回的是false。
结果如下: