这篇文章主要通过一个高并发场景,来引出CAS算法的使用以及深入浅出探讨一下CAS底层实现原理,最后会讲解CAS算法在Java中的应用。
首先我们模拟一个网站的高并发访问,假设有100个用户,同时请求服务器十次。
public class Demo {
static int count;//记录用户访问次数
public static void request() throws InterruptedException {
//模拟请求耗时5毫秒
TimeUnit.MILLISECONDS.sleep(5);
count++;
}
public static void main(String[] args) throws InterruptedException {
//开始时间
long startTime = System.currentTimeMillis();
int threadSize = 100;
//CountDownLatch类就是要保证完成100个用户请求之后再执行后续的代码
CountDownLatch countDownLatch = new CountDownLatch(threadSize);
for (int i = 0; i < threadSize; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
//模拟用户行为,访问10次网站
try{
for (int j = 0; j < 10; j++)
request();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
}
});
thread.start();
}
countDownLatch.await();
long endTime = System.currentTimeMillis();
System.out.println(Thread.currentThread().getName()+"耗时:"+(endTime-startTime)+",count="+count);
}
}
按照正产的逻辑来说,100个用户,每个用户访问网站十次,那么网站的用户访问量count
应该等于1000才对。
通过javap -c -v Demo.class
命令对class文件进行反编译,我们可以看到红色框中就是request()
方法中的count++
语句对应的字节码指令,主要包括三个步骤:
1.
getstatic
:将常量值count放到方法的操作数栈上。
2.iconst_1&iadd
将常量1放到方法的操作数栈上,然后弹出栈顶的前两个元素,执行相加操作,然后将结果压入操作数栈中。
3.putstatic
:将操作数栈栈顶的结果赋值给常量count。
当有两个线程同时将常量值count放到各自方法的操作数栈上时,然后同时执行相加操作,最后都赋值给常量count,这个时候发现两个线程相等于只对count变量做了一个相加操作,导致结果不够1000。
答案很简单,当我们在执行count++操作的时候,让
线程去排队执行
,每次只允许一个线程执行count++,其他线程必须等待前一个线程完成之后,才可以执行,这样就能保证count的结果是1000。
相信我们都能想到使用
synchronized
关键字对request()
方法进行加锁,当线程要访问同步方法的时候,必须获取锁才可以执行,不然就阻塞等待直到其他线程释放锁。那么我们尝试一下在request()方法上加锁。
public static synchronized void request() throws InterruptedException {
//模拟请求耗时5毫秒
TimeUnit.MILLISECONDS.sleep(5);
count++;
}
加了synchronized
关键字修饰之后,结果能保证是1000了,但是还有一个更重要的问题要考虑,就是性能,加了synchronized
关键字之后,程序大概需要执行5到6秒钟,在高并发场景下,响应时间这么慢是不被允许的,那是因为synchronized
加锁的开销很大。
首先之所以耗时长是因为,
synchronized
的锁粒度很大,那么我们来降低锁粒度,比如TimeUnit.MILLISECONDS.sleep(5);
是不需要加锁的,然后count++
中其实只有在执行第三步的时候会引发高并发中的可见性问题(一个线程在更新count值的时候,并不知道其他线程对count值的改变,导致结果不一致),所以我们对第三步进行一个改良:
- 获取count的值,记为A
- 执行加一操作得到结果B
- 更新count的值
a) 获取锁;
b) 获取count的最新值,记为LV;
c) 判断LV是否等于A,如果是,则将最新值B赋值给A,返回true,否则返回false;
d) 释放锁;
首先为了获取到内存中count的最新值,我们使用volatile
关键字修饰变量count
static volatile int count;
然后对于第三步增加一个compareAndSwap()
方法实现,如下
public static synchronized boolean compareAndSwap(int expectedValue,int newValue) {
if (getCount() == expectedValue) {
count = newValue;
return true;
}
return false;
}
然后request()
方法中会循环判断,直到满足条件
public static void request() throws InterruptedException {
//模拟请求耗时5毫秒
TimeUnit.MILLISECONDS.sleep(5);
int expectedValue;
while(!compareAndSwap(expectedValue = getCount(),expectedValue + 1)){}
}
最终的运行结果如下,在保证了结果的正确性的情况下,性能提高显著。
在上面的高并发场景中,其实我们已经初步实现了一个CAS算法,CAS算法,全称是
Compare And Swap
,意思就是比较并且交换,准确来说应该是比较如果相同就交换
。在CAS算法中,有三个操作数,分别是内存位置值
,期望值
,新值
,如果内存位置值等于期望值,则处理器自动会将内存位置的值更新为新值。我们也可以把CAS算法看成一种无锁或者乐观锁算法,他跟悲观锁的区别就是,悲观锁只要有竞争就会阻塞线程,而CAS不会阻塞线程,也不需要向操作系统申请锁对象,在应用层通过自旋判断的方式解决了线程同步的问题
。
在这里我直接给出结论:CAS算法在操作系统底层是对应一条
cmpxchg
字节码指令,指令的作用就是比较并且交换操作数,并且在多处理器的情况下,会在cmpxchg
字节码指令前面加上lock
前缀,确保了对内存的读写操作的原子性。
1 带有lock前缀的指令在早期的处理器中,是会向总线发送一个LOCK#信号,
锁住总线
,将CPU和内存之间的通信锁定了,使得锁定期间,其他处理器也不能操作其他内存地址的数据了,所以总线锁开销比较大。
2 后期的处理器使用了”缓存锁定“
的方法来保证了对共享内存的原子操作,带有lock前缀的指令在执行写回内存操作的时候,不会直接在总线上加锁,而是会直接修改内存地址
,这个时候其他处理器会嗅探总线上的数据来检测自己的缓存行是否过期
,如果发现缓存行对应的内存地址改变了,就会使得缓存行失效,这就是缓存一致性协议
,保证了对共享变量的原子操作。
JDK提供了多个原子类(Atomic)来实现CAS算法,对于上面的场景代码,我们试着使用JDK提供的
AtomicInteger
类来实现。
public class Demo {
static AtomicInteger count = new AtomicInteger();
public static void request() throws InterruptedException {
//模拟请求耗时5毫秒
TimeUnit.MILLISECONDS.sleep(5);
count.getAndIncrement();
}
public static void main(String[] args) throws InterruptedException {
//开始时间
long startTime = System.currentTimeMillis();
int threadSize = 100;
//CountDownLatch类就是要保证完成100个用户请求之后再执行后续的代码
CountDownLatch countDownLatch = new CountDownLatch(threadSize);
for (int i = 0; i < threadSize; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
//模拟用户行为,访问10次网站
try{
for (int j = 0; j < 10; j++)
request();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
}
});
thread.start();
}
countDownLatch.await();
long endTime = System.currentTimeMillis();
System.out.println(Thread.currentThread().getName()+"耗时:"+(endTime-startTime)+",count="+count.get());
}
}
其实JDK中真正封装了CAS操作算法的是在
sun.misc.unsafe
类中,主要是以下三个方法实现了CAS操作,分别是public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5); public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5); public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
其中参数的含义是
var1——要操作对象的内存地址。 var2——要操作对象中属性地址的偏移量。 var4——表示需要修改数据的期望值。 var5\var6——表示需要修改数据的新值。
CAS虽然很高效地解决了原子操作问题,但是CAS仍然存在三大问题。
1 ABA问题
2 循环时间长开销大
3 只能保证一个共享变量的原子操作
ABA问题指的是,有一个线程将共享变量A的值更改成为B,然后又更改成为A,对于另一个线程来说,共享变量A的值是没有改变的,但实际上共享变量A的值是发送了变化了,这就是
ABA问题
。因项目实际需要,如果不允许出现ABA问题,可以增加一个版本号的方式来解决ABA问题。在JDK中提供了一个AtomicStampedReference
类来解决ABA问题。
虽然CAS并没有向操作系统申请锁对象,节省了内核态和用户态之间切换的开销,但是CAS在自旋的过程中,消耗的是CPU的资源,如果在高并发的情况下,多个线程在自旋,那么CPU负荷很快就会满了,所以从这一点可以看出CAS主要适用的场景是,并发量不高,并且同步代码逻辑的执行时间短的场景下。
CAS只能保证对一个共享变量的原子操作,不能保证对多个共享变量的原子操作,所以一般只能加锁。