CAS机制
1.多线程执行下引发线程安全问题
1.1什么是线程安全问题
通过一个问题,一段代码进入本篇博客讲解:
问题:得到一个数,数值为:1000000。
单线程实现:一个循环解决问题,这个不多讲了。
多线程实现:两个线程实现对一个变量操作,每个线程操作变量500000次,得到1000000。
public class Demo2 {
// 我们需要获取的值
public static long number = 0;
public static void main(String[] args) throws InterruptedException {
// 线程一
Thread t1 = new Thread(() -> {
// 循环加 500000 次
for (int i = 0; i < 500000; i++) {
number++;
}
});
Thread t2 = new Thread(() -> {
// 循环加 500000 次
for (int i = 0; i < 500000; i++) {
number++;
}
});
// 启动线程,如果不调用start 方法,线程是不能启动的
t1.start();
t2.start();
// 等待两个线程执行完毕后输出想要的值,调用 join 方法等待
// 调用 join 方法需要抛出或解决一个 InterruptedException 异常,这里我们尝试抛出异常
t1.join();
t2.join();
// 线程 t1,t2 执行结束完毕后输出值
System.out.println(number);
}
}
==============
预期结果:1000000
==============
实际结果:
通过上述执行结果我们发现我们的预期结果与代码执行实际结果不吻合,这就是多线程情况下引发的线程安全问题。
1.1.1产生线程安全问题的原因:
① 线程在系统调度中顺序无序,抢占式执行,谁先抢到谁执行.
② 多线程修改同一个变量
③ 修改操作不是原子(不可分割的最小单位)的,如++操作就涉及到CPU的三个指令:
load 加载,add 增加,save 保存.如果两个线程对同一个变量进行操作时,会有很多排列顺序,就会造成变量值计算的最终结果不是预期结果.
④ 内存可见性:在多线程环境下,编译器对于代码的优化,产生误判(某个变量明明在其它线程执行过程中更改了,但是当前线程不知道),所以会引起bug.比如假设线程A的中断条件为某个变量flag=1才可以中断(默认flag = 0 不满足线程A中断条件),线程B在执行过程中将flag=1(满足线程A中断条件),但是线程A没有休眠,执行速度极快,编译器就会对代码进行优化(只加载一次flag的值),当线程B将flag=1时,线程A并不会重新加载flag的值,所以就会造成线程A无法中断,从而bug出现.
⑤ 指令重排序:完成某个任务的结果一样,但是过程顺序不一致.在多线程情况下,可能会使结果不可预知,从而产生bug.(很难调试出来,完全靠自己去想去理解)这里给大家举个例子:我们实例化某个对象时,new操作主要分为三步:
- 创建出对象(建好房子)
- 构造对象(装修房子)
- 将生成的地址赋值给对象引用(拿到钥匙).
在多线程情况下,线程调度无序,那么某个线程可能会拿到一个没有构造好的对象(啥也没有,属性都是默认的),那么我们去使用该对象成员变量或方法时,可能就会发生一系列的错误.
cpu调度问题:操作系统内核将CPU时间片分随机调度给线程使用就会出现下面的操作:(推荐阅读:并发编程)
下面我们模拟一下上面的多线程数字增加问题的场景:
首先T1得到CPU时间片执行+1操作,此时我们从主内存将数据拷贝到T1的工作内存中再由T1进行操作,当T1拿到number时,准备进行数据+1操作,但是由于T1的cup时间片用完了就阻塞在+1操作地方,cpu后又把时间片分给了T2进行操作。此时T2先从主内存将数据拷贝到T2的工作内存中,再进行操作。T2执行完++操作并把新的值写回主内存中(此时主内存的值为:1),现在cup又将时间片分给了T1,T1继续执行未完成的步骤,但是T1的工作内存中的副本值依旧是之前拿到的0,所有T1线程执行++操作结果也为1,并将1返回给了主内存(此时主内存的number依旧等于1)。在这样一个操作过程中,我们本来是执行了两次++操作,但实际上主内存的number只增加了1。以此类推两个线程都有可能发生一个完整的操作未执行完毕,CPU的时间片就用完后阻塞再获取的情况,这样就出现了【1.1】中代码执行的结果。
小结:由于线程不能及时感知到主内存的数据是否发生变化(内存可见性)就产生了上述问题
1.2如何解决线程安全问题
1.2.1加锁
加锁解决线程安全问题原因:通过加锁操作将一个过程变为了原子操作(同mysql中的原子性类似),从而保证线程安全。
代码如下:
public class Demo2 {
// 我们需要获取的值
public static long number = 0;
public static void main(String[] args) throws InterruptedException {
// 线程一
Thread t1 = new Thread(() -> {
synchronized (Demo2.class) {
// 循环加 500000 次
for (int i = 0; i < 500000; i++) {
number++;
}
}
});
Thread t2 = new Thread(() -> {
synchronized (Demo2.class) {
// 循环加 500000 次
for (int i = 0; i < 500000; i++) {
number++;
}
}
});
// 启动线程,如果不调用start 方法,线程是不能启动的
t1.start();
t2.start();
// 等待两个线程执行完毕后输出想要的值,调用 join 方法等待
// 调用 join 方法需要抛出或解决一个 InterruptedException 异常,这里我们尝试抛出异常
t1.join();
t2.join();
// 线程 t1,t2 执行结束完毕后输出值
System.out.println(number);
}
}
============
预期结果:1000000
============
实际结果:
原因:
以上就是加锁保证线程安全的过程。(参考:synchronized的底层实现)
1.2.2原子操作类
所谓原子操作类,指的是java.util.concurrent.atomic包下,一系列以Atomic开头的包装类。如AtomicBoolean,AtomicUInteger,AtomicLong。它们分别用于Boolean,Integer,Long类型的原子性操作。
我们尝试使用原子操作类:
public class Demo3 {
// 实例化一个整形原子类
public static AtomicInteger number = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
// 线程一
Thread t1 = new Thread(() -> {
// 循环加 500000 次
for (int i = 0; i < 500000; i++) {
// 同 number++ 效果相同
// 底层使用了CAS机制保证了线程安全问题
number.incrementAndGet();
}
});
Thread t2 = new Thread(() -> {
// 循环加 500000 次
for (int i = 0; i < 500000; i++) {
// 同 number++ 效果相同,
// 底层使用了CAS机制保证了线程安全问题
number.incrementAndGet();
}
});
// 启动线程,如果不调用start 方法,线程是不能启动的
t1.start();
t2.start();
// 等待两个线程执行完毕后输出想要的值,调用 join 方法等待
// 调用 join 方法需要抛出或解决一个 InterruptedException 异常,这里我们尝试抛出异常
t1.join();
t2.join();
// 线程 t1,t2 执行结束完毕后输出值
System.out.println(number);
}
}
==============
预期结果:1000000
==============
实际结果:
结果:同样保证了线程安全问题。下面就来介绍原子类是如何来实现操作的。
2.原子操作类底层实现(CAS机制)
CAS 本质上提供的是一种无锁方案,而 synchronized 和 Lock 是互斥方案;Java 原子类本质上使用的是 CAS,而 CAS 底层通过 unsafe 类实现的。
而CAS的操作本质就是一个判断逻辑。
3.为什么要使用CAS
3.1CAS解决并发情况下产生的线程安全问题
上图通过多次CAS(对比替换)就解决了内存可见性问题。从而保证线程安全问题,但是由于整个操作过程不是原子操作,故又出现了新的问题ABA,此处先不讲解,第4.CAS引发的问题与解决办法 在讲解。
3.2CAS多线程并发性能可能优于synchronized
有小伙伴就会问,既然加锁就能够解决线程安全问题,而使用CAS又存在ABA问题,那么为什么还要使用CAS,而不是直接就只用synchronized加锁呢!
原因:CAS相比于synchronized加锁,有场景能够提高系统的性能(参考:CAS与synchronized的性能对比)。
4.CAS引发的问题与解决办法
4.1 ABA问题
例:你到银行取钱,你有100块钱,你要取50块,你妈妈在你取钱的时候又给你打了50块。
预期结果:你拿到50块,你的账户余额剩余100块。
有可能的结果:你拿到50块,你的账户余额剩余50块。
这岂不是亏懵了?兄弟们。
在我们用户看来结果是正确的,但是实际上等会你妈妈打电话给你,才发现亏了。
这就是CAS给我们带来的ABA问题:T1和T2线程获取时间片都执行到将主内存的数据拷贝到工作内存中,但是T2此时发生了阻塞,而T1继续执行并将结果写回主内存,如果此时T2被唤醒,那么进行对比发现工作内存的值与主内存数据不一致就应该停止运行,但是在T2线程唤醒前T3线程又往内存中存了50,内存数据又变成了100,此时T2被唤醒,发现与主内存数据一致以为操作过程没有问题,就继续执行了,这就是ABA问题。那要如何来解决ABA问题呢,大佬们就引入了版本号来解决。
4.2 引入版本号解决ABA问题
4.2.1版本号是什么
版本号可以简单的理解为就是一个类似于日期的东西,总是朝着一个方向变化。
如:只能变大或者只能变小。
4.2.2引入版本号是如何解决ABA问题的
在主内存中添加一个变量,主内存在拷贝数据时将版本号一同拷贝给工作内存。
解决ABA问题的过程:T1和T2线程获取时间片都执行到将主内存的数据拷贝到工作内存中(版本号都为0),但是T2此时发生了阻塞,而T1继续执行并将结果写回主内存(主内存版本号++ => 1),如果此时T2被唤醒(此时T2的版本号还是0),那么进行对比发现工作内存的版本号与主内存版本号不一致就停止运行,如果在T2线程唤醒前T3线程又往内存中存了50,主内存数据又变成了100(版本号++ => 2),此时T2(版本号还是0)被唤醒,发现与主内存版本号不一致,就不执行后序流程了。这样ABA问题就得到了解决。
五,总结
CAS流程:
- 将需要修改的数据从主内存中读入本地线程缓存(工作内存);
执行 CAS 操作,将本地线程缓存中的旧的版本号与主内存中的版本号进行比较;- 如果本地线程缓存中旧的版本号与主内存中的版本号相等,则将本地线程缓存中需要修改的数据进行修改;
- 如果缓存的数据修改成功,将修改后的数据写入主内存,并返回修改结果;
- 如果失败,则返回当前主内存中的数据;
- 在多线程并发执行的情况下,如果多个线程同时执行 CAS 操作,只有一个线程的 CAS 操作会成功,其他线程的 CAS 操作都会失败,这也是 CAS 的原子性保证。