前言
CAS【Compare And Swap,乐观锁】是JUC的基础,学习CAS的原理以及使用。一、案例
多线程环境下的失败案例:模拟100个用户同时访问网站10次:
public class Demo1 {
static int count = 0;
/**
* 模拟用户访问服务器
*/
public static void request() {
try {
//每个用户花费2ms访问本网站
TimeUnit.MILLISECONDS.sleep(2);
//网站访问加一
count++;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws InterruptedException {
final int threadNumber = 100;
//使得主线程只能在所有用户都执行完毕后才能执行
CountDownLatch cdl = new CountDownLatch(threadNumber);
long start = System.currentTimeMillis();
// 一百个用户同时请求
for (int i = 0; i < threadNumber; i++) {
new Thread(() -> {
try {
//每个用户请求十次网站
for (int j = 0; j < 10; j++) {
request();
}
} catch (Exception e) {
System.out.println(e.getCause());
}finally {
cdl.countDown();
}
}).start();
}
//等待其余线程执行完毕
cdl.await();
System.out.println("访问量:" + count);
System.out.println("耗时:" + (System.currentTimeMillis() - start));
}
}
虽然执行速度很快,但是试了很多次,访问量都到不了1000,说明达不到要求,这样的代码就是失败的
分析原因:
执行引擎在计算count++时,并不是一个原子操作【即一个一气呵成,不被其他线程中断的操作】
实际上,count的执行分为三个步骤:
①从堆区获取count;【静态变量存放在堆区该类对象的位置】
②将count + 1,复制给一个临时变量t;
③将t赋值给count,写回。
由于三步不能一次执行完毕,在某个线程的执行过程中,可能出现其他线程也同样执行了一二两步,此时两者得到的t都是等值的,因此将t赋值给count的操作就重复进行了,浪费了一次增加访问量的机会【也可以说某一次修改被覆盖了】
解决办法:
通过上锁的方式,让执行count++的操作不被其他线程打断,只有彻底完成才会被其他线程继续执行,代码见下节
二、正常的同步
java提供了synchronized与ReentrantLock两种方式来实现加锁。
public synchronized static void request() {
try {
//每个用户花费2ms访问本网站
TimeUnit.MILLISECONDS.sleep(2);
//网站访问加一
count++;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
利用synchronized锁住request(),耗时如下:
可见,耗时翻了将近12倍。加锁的消耗巨大。
为什么耗时如此之高?
①加锁本身需要耗时
②中间的很多操作本来就无需加锁,强行加锁使得这些原本可以并发的行为也只能排队执行了。
既然有些操作不需要加锁,我们试试看只在其中的某个地方加锁看看:
public static void request() {
try {
//每个用户花费2ms访问本网站
TimeUnit.MILLISECONDS.sleep(2);
//网站访问加一
ReentrantLock lock = new ReentrantLock();
lock.lock();
count++;
lock.unlock();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
只比不加锁的情况慢一点点,果然效率提升了很多。
但是就前面分析的,就单从i++来说,只需要将最后赋值的操作加锁就行,可不可以进一步优化呢?
使用自定义的简单cas来实现。
三、模拟cas
这些操作中,只有count++的第三步赋值时需要加锁,因此使用比较交换思想,我们可以这么做:
- 提供一个获取当前count的方法getCount(),由于不加锁,这个方法很快【①不加锁】
- 定义一个cas方法,比较传递的exceptCount与当前实际的count是否相等,若相等就赋值【第③步,通过比较实现同步】,若赋值成功返回true
- request()中,将count+1和自己当前获取到的count作为exceptCount传递给cms(),若返回false,说明不相等,再次重复获取count,执行赋值。
这样通过比较、赋值的行为本身就比加锁的行为快很多,代码如下:
注意:cas()方法必须要加锁,保证在执行过程中count值不会被修改。
static int count = 0;
/**
* 模拟用户访问服务器
*/
public static void request() {
try {
//每个用户花费2ms访问本网站
TimeUnit.MILLISECONDS.sleep(2);
//网站访问加一
// 定义期待值,表示当前该线程取得的count值
int excpectCount;
//比较为false,说明取得值已经变化,重新再取一次,直到成功
while (!compareAndSwap(excpectCount = getCount(), excpectCount + 1)){}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private static synchronized boolean compareAndSwap(int exceptCount, int co) {
int c;
if ((c = getCount()) == exceptCount) {
count = co;
return true;
}
return false;
}
private static int getCount() {
return count;
}
也可以说非常快了。
四、JDK的cas api
jdk的cas是通过Unsafe类来实现的。Unsafe类可以操作本地内存,类似以前说过的allocateDirect()类型的方法。
Unsafe提供一些操作本地内存的本地方法,通过c语言实现,其中就宝座实现cas的几个方法。
cas实际上是一个操作系统的原子命令,我们平时说的cas实际上是前面的准备工作+实际的cas操作,狭义的cas特指将数据存放到对应的内存位置。
cas的原理是:当一个线程比较成功时,就会将总线上锁,使得其他线程无法通过总线去修改变量,这样当修改完毕之后,其他线程的比较就会失败,就去重新获取这个值。由于没有真实的加锁操作,效率很高。
我曾经在这篇关于ConcurrentHashMap的博客写了关于cms的相关api,以及通过反射的获取Unsafe对象的方式。
- CAS存在的问题:
高并发情况下,会有多个线程都会成功的获取exceptCount与对等值的本地变量count进行操作,然而就算都能完整的做完,也只有一个线程可以进入执行cas替换掉内存的值,这样别的线程所做的事情就被浪费了 - ABA问题
A在进行了获取except的时候没有cpu时间片了,会暂停执行;
令一个线程修改了变量值,又给改回来了,当A重新执行时,比较会成功。
相当于A遗失了两次更新的版本。
通过程序演示ABA:
public class Demo4 {
public static AtomicInteger ai = new AtomicInteger(1);
public static void main(String[] args) {
new Thread(()->{
try {
int expect = ai.get();
int aim = expect + 1;
//等1s,让干扰线程执行修改
Thread.sleep(1000);
boolean isSuccess = ai.compareAndSet(expect, aim);
System.out.println("cas成功了吗?" + isSuccess);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "main").start();
new Thread(() -> {
try {
//若干扰线程先执行,让主线程执行到取得值的位置
Thread.sleep(29);
ai.set(2);
System.out.println("干扰了执行");
ai.set(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
},"干扰").start();
}
程序中使用了AtomicInteger类,AtomicXXX类都在内部聚合Unsafe类,这是jdk提供给我们间接使用Unsafe类的一种方式。
main忽略了干扰线程的中间的ABA修改。
如何预防ABA问题?
一般情况下无需预防,在敏感的场合需要预防,可以使用synchronized代替Unsafe的cas操作,或者使用AtomicStapedReference类来解决。
这个类会在每次修改时同步修改版本号,若版本号不一致,即使值一致比较也会失败。