如果有兴趣了解更多相关内容的话,可以来我的个人网站看看:耶瞳空间
一:前言
首先看一个案例:我们开发一个网站,需要对访问量进行统计,用户每发送一次请求,访问量+1,如何实现?我们模拟有100个人同时访问,并且每个人对网站发起10次请求,最后总访问次数应该是1000次。
模拟代码如下:
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public class Demo {
// 总访问量
static int count = 0;
// 模拟访问的方法
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 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实现100个线程结束之后再执行以下代码
countDownLatch.await();
long endTime = System.currentTimeMillis();
System.out.println(Thread.currentThread().getName() + ", 耗时:" + (endTime - startTime) + ", count = " + count);
}
}
很明显count不是1000,那么问题出在哪呢?
对于代码中的count++
,其实是分这样几个步骤执行的:
- 获取count的值,记作A:A=count
- 将A值+1,得到B:B=A+1
- 将B值赋值给count
如果有A、B这两个线程同时执行count++,他们通知执行到上面步骤的第一步,得到的count是一样的,那么3步操作结束后,count只加了1,导致count结果不正确
那么怎么解决这个问题呢?可以在对count++
操作的时候,我们让多个线程排队处理,多个线程同时到达request()
方法的时候,只能允许一个线程进去操作,其他的线程在外面等待,等里面的处理完毕出来之后,外面等着的再进去一个,这样操作的count++
就是排队进行的,结果一定是正确的
怎么实现排队效果呢?java中synchronized
关键字和ReentrantLock
都可以实现对资源加锁,保证并发正确性,多线程的情况下可以保证被锁住的资源被串行访问
在代码中就是在request()
方法里加synchronized
修饰,具体可见我的另一篇博客:并发编程——共享模型之管程
可见上锁之后,访问量就正常了,但也注意到,耗时从63ms变成了5335ms,那是因为在方法上加锁,导致TimeUnit.MILLISECONDS.sleep(5);
也被串行执行了,因此仅仅等待就花费了5000ms,而结果执行错误只是因为count++
被并发访问,因此我们可以只给count++
加锁:
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public class Demo {
// 总访问量
volatile static int count = 0;
/**
* @param expectCount 期望值count
* @param newCount 需要给count赋值的新值
* @return boolean
*/
public static synchronized boolean compareAndSwap(int expectCount, int newCount) {
// 判断count当前值是否和期望值expectCount一致,如果一致,则将newCount赋值给count
if(getCount() == expectCount) {
count = newCount;
return true;
}
return false;
}
public static int getCount() {
return count;
}
// 模拟访问的方法
public static void request() throws InterruptedException {
// 模拟耗时5毫秒
TimeUnit.MILLISECONDS.sleep(5);
int expectCount; // 表示期望值
while(!compareAndSwap(expectCount = getCount(), expectCount + 1)) { }
}
public static void main(String[] args) throws InterruptedException {
// 开始时间
long startTime = System.currentTimeMillis();
int threadSize = 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实现100个线程结束之后再执行以下代码
countDownLatch.await();
long endTime = System.currentTimeMillis();
System.out.println(Thread.currentThread().getName() + ", 耗时:" + (endTime - startTime) + ", count = " + count);
}
}
很明显这样程序的效率得到了很大的提升
二:CAS介绍
那现在回到正题,CAS全称“Compare And Swap”,即“比较并替换”。CAS操作包含三个操作数:
- V:要更新的变量(var)
- E:预期值(expected)
- N:新值(new)
比较并交换的过程如下:判断V是否等于E,如果等于,将V的值设置为N;如果不等,说明已经有其它线程更新了V,则当前线程放弃更新,什么都不做。所以这里的预期值E本质上指的是“旧值”。
我们以一个简单的例子来解释这个过程:
- 如果有一个多线程共享的变量i原本等于5,我现在在线程A中,想把它设置为新的值6,我们使用CAS来做这个事情。
- 首先我们用i去与5对比,发现它等于5,说明没有被其它线程改过,那我就把它设置为新的值6,此次CAS成功,i的值被设置成了6;
- 如果不等于5,说明i被其它线程改过了(比如现在i的值为2),那么我就什么也不做,此次CAS失败,i的值仍为2。
在这个例子中, i就是V,5就是E,6就是N。
那有没有可能我在判断了i为5之后,正准备更新它的新值的时候,被其它线程更改了i的值呢?不会的。因为CAS是一种原子操作,它是一种系统原语,是一条CPU的原子指令,从CPU层面保证它的原子性。当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,这个重试的过程也叫做自旋。当然也允许失败的线程放弃操作。正是基于这样的原理,CAS即时没有使用锁,也能发现其他线程对当前线程的干扰,从而进行及时的处理。
CAS的缺点:
- CPU开销较大:在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。
- 不能保证代码块的原子性:CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用synchronized了。
三:基本使用
java提供了对CAS操作的支持,现在用CAS修改上面的例题:
public class Demo {
// 总访问量
static AtomicInteger count = new AtomicInteger(0);
// 模拟访问的方法
public static void request() throws InterruptedException {
// 模拟耗时5毫秒
TimeUnit.MILLISECONDS.sleep(5);
// 修改访问量
while (true) {
int oldCount = count.get();
int newCount = oldCount + 1;
if (count.compareAndSet(oldCount, newCount)) {
break;
}
}
}
public static void main(String[] args) throws InterruptedException {
long startTime = System.currentTimeMillis();
int threadSize = 100;
CountDownLatch countDownLatch = new CountDownLatch(threadSize);
for (int i = 0; i < threadSize; i++) {
Thread thread = new Thread(() -> {
// 模拟用户行为,每个用户访问10次网站
try {
for (int j = 0; j < 10; j++) {
request();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
});
thread.start();
}
// 利用countDownLatch实现100个线程结束之后再执行以下代码
countDownLatch.await();
long endTime = System.currentTimeMillis();
System.out.println(Thread.currentThread().getName() + ", 耗时:" + (endTime - startTime) + ", count = " + count);
}
}
不过需要注意的是,在获取共享变量时,为了保证该变量的可见性,需要使用volatile修饰。volatile可以用来修饰成员变量和静态成员变量,它可以避免线程从自己的工作缓存中查找变量的值,而是到主存中获取它的值,线程操作volatile变量都是直接操作主存。即一个线程对volatile变量的修改,对另一个线程可见。CAS必须借助volatile才能读取到共享变量的最新值来实现比较并交换的效果。关于volatile,详细介绍可以看我的这篇博客:并发编程——可见性与有序性