前言:
在并发编程的学习中,并发三大特性是我们肯定会遇到的知识点,在面试中,也会常常被问到,那么并发的三大特性到底包括哪些呢?我们如何能够快速理解它,今天的博客将会带大家有一个彻底的了解!
1.1 串行、并行和并发的区别
在了解并发三大特性之前,我们需要先了解一下串行、并行和并发之间的区别,它们之间的区别到底是什么呢?快来看看吧!
串行:在时间上不可能发生重叠,前一个任务没搞定,下一个任务只能等着
并行:在时间上是重叠的,两个任务在同一时刻互不干扰的同时执行 (多个CPU同时执行)
并发:允许两个任务彼此干扰,同一时间点,只有一个任务运行,交替执行 (时间片轮转)
如果你觉得文字解释不够直观,没关系,画个简图来辅助理解:
串行:1 --> 2 --> 3
并行:1
2
并发:1--> 2 --> 1 --> 2
1.2 并发的三大特性
并发的三大特性:原子性、可见性和有序性
注意:需要保证三大特性才能够保证线程安全
1.原子性
1-1 原子性概念
-
原子性是指在一个操作中,CPU不可以在中途暂停,然后再调度 (即不被中断操作,要么全部执行完成,要么都不执行)
就好比转账的过程,从账户A向账户B转1000元,那么必然包括两个操作: 首先从账户A减去1000元,然后往账户B加上1000元,两个操作必须全部完成
1-2 案例解释
例如在单核CPU中有T1和T2两个线程,它们分别来执行add方法,T1线程在执行该方法时,CPU进行调度,切换T2线程来执行该方法,这就是CPU进行调度来处理并发
private int i = 0; //i变量初始值为0
//一个add自增方法
public void add() {
//i执行加1运算
i++;
}
如果要保证该方法是原子性的,即要么都成功,要么都失败,因此不能让T1线程在执行时被CPU进行切换,而是让它直到执行完该方法或者事务时才能退出
虽然只是执行了一个简单add方法,但其实在CPU中经历了四步操作:
- 将i从主存读取到工作内存中的副本中
- i变量执行加1的运算
- 将结果写入工作内存
- 将工作内存的值刷回主存 (什时候刷入由操作系统决定,不确定的)
1-3 具体执行步骤
比如说有一个i变量,现在它在主存中,并且i的初值为0
- 如果有两个线程T1和T2,它们会分别从主存中把i变量值读取到工作内存中 (这里其实是对i变量值的一个复制,T1和T2都会有一个自己的工作内存)
- 然后分别开始进行加1的操作(即i值变为1),将i=1的结果写入到T1和T2的工作内存中(前三步操作都是确定的,而最后一步操作是不确定的)
- 最后将i=1的值再写回到主存中去(由操作系统来决定,不确定时间)
总结:程序中的原子性是指最小的操作单元,比如自增操作,它本身其实不是原子性操作,分了三步,包括读取变量的原始值,进行加1操作,写入工作内存
1-4 常规并发操作
如果是常规的并发操作,其具体执行步骤如下:
- T1线程先从主存中将i=0的复制值读取到工作内存中,但这时CPU进行调度,切换到T2线程来执行;
- T2线程首先将i=0的复制从主存中读取到工作内存中,然后执行加1操作,即i值变为1,将i=1存入到T2的工作内存;
- 再切换到T1线程,T1还没有进行加1操作,这时T1的i=0的值被写回到主存,这样肯定就导致其结果错误了
1-5 保证add方法原子性
如果保证add方法是原子性的,具体执行步骤如下:
- T1线程先从主存中将count=0的复制值读取到工作内存中,然后执行加1操作,count值变为1,将count=1存入到T1的工作内存;
- 然后再T2线程再执行此方法,重复与T1相同的操作,T2线程在其工作内存中的count值也为1
让前三步一块执行,这样就保证了原子性; - 但还是由于最后一步何时count值从工作内存中读入主存不确定,因此仅凭原子性,还是无法保证线程安全
总结:
- 因此在多线程中,有可能一个线程还没自增完,可能才执行到第二步,另一个线程就已经读取了值,导致结果错误;
- 那如果保证自增操作是一个原子性操作,那么就能保证其他线程读取到的一定是自增后的数据
- 使用synchronized同步锁可以同时保证并发的三大特性:即原子性,可见性和有序性
2.可见性
2-1 可见性概念
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改值
2-2 案例解释
-
案例解释1:
例如前面的i自增问题,但两个线程在不同的CPU,那么线程T1改变了了i的值还没有刷新到主存,线程T2又使用了i变量,那么这个值肯定还是之前的,线程T1对变量的修改线程T2没有被看到,这就存在可见性问题
-
案例解释2:
//线程T1
//设置标志位stop初始值为false
private boolean stop = false;
while(!stop) {
doSomething();
}
//线程T2
//T2修改标志位值为true
stop = true;
请思考一个问题:如果线程T2改变了stop中的值,那么线程T1一定会停止吗?
答案是不一定,当线程T2更改了stop变量的值后,但是还没来得及写入主存中,线程T2就转去做其他事情了,那么线程T1由于不知道线程T2对stop变量的更改,因此还会一直循环下去
2-3 怎样才能保证可见性呢?
要保证可见性,就涉及操作系统中的两个协议:总线Lock (锁定) 协议 和 MESI (缓存一致性) 协议
我们还是结合前面的i自增案例对可见性进行解释:
-
假设有T1和T2两个线程,我们保证其可见性,但不保证原子性;那么还是前三步还是会存在CPU调度和线程切换,T1线程从主存中读取到i=0值的副本;这时CPU进行调度,切换到T2线程,T2也从主存读取到的i=0的复制值 (i=0的值是在T2的工作内存中的);
-
再次调度切换到T1线程,T1执行加1操作,这时CPU进行调度,又切换到T2线程,但是此时i=0的值会失效(只要T1做加1操作时T2中的i=0就会失效,这是由于可见性在起作用),而T2线程可能还是会执行加1的操作
-
当再次切换到T1线程时,T1中的i=1变量值被读取到其工作内存中,然后工作内存(缓存)中的i=1又马上被刷新主存中去,但是这两步是连续进行的,即符合原子性原则 (而这是总线锁定和缓存一致性保证的)
总结:
- 因此,如果要想保证线程安全,就需要同时保证原子性和可见性
即在上述的T1和T2线程并发执行的过程中,我们通过原子性将前三步进行绑定,而通过可见性又将第三步和第四步进行绑定,实际上相当于将这四步操作进行绑定,整体变成了一个原子操作
-
在Java中,为了保证 i++的线程安全问题,可以使用AtomicInteger来定义i变量
什么是AtomicInteger?
AtomicInteger是一个支持原子操作的int封装类,提供了原子性的访问和更新操作
为什么使用AtomicInteger可以保证线程安全?
AtomicInteger之所以可以保证线程安全性,是因为它底层是通过volatile和CAS(Compare
And swap,即比较并交换)实现的;volatile可以保证内存可见性和有序性,但保证不了原子性,而CAS可以保证原子性 -
使用volatile、synchronized和final关键字 (volatile除了保证有序性外,还可以保证可见性,final可以保证可见性)
3. 有序性
3-1 有序性概念
- 有序性是指虚拟机在进行代编译时,对于那些改变顺序之后不会对最终结果造成影响的代码,虚拟机不一定会按照我们写的代码顺序来执行,有可能将它们重新排序;
- 实际上,对于有些代码重新排序后,虽然对变量的值没有造成影响,但又是可能会出现线程安全问题
3-2 案例解释
比如本来代码执行的顺序是1234,但是经过编译重排后变成了2134,无论执行顺序是1234还是2134,在单线程下结果不会发生变化,才会进行重排,但如果发生重排后结果会发生变化,那它不会进行重排
//变量a初始值为0
static int a = 0;
//标志位初始值为false
static boolean flag = false;
//写入方法
public void write() {
//将变量a赋值为2
a = 2; // 第一步
//将标志位置为true
flag = true; //第二步
}
//乘值方法
public void multiply() {
//判断标志位是否为真
if(flag) { //第三步
//如果标志位为真,进行平方运算
int result = a * a; //第四步
}
}
在单线程情况下:
在单线程下,如果将第一步和第二步顺序进行交换,也就是将标志位flag先置为true,再将变量a赋值为2,与原顺序相比,其执行结果并不会受到影响,因此不会造成线程不安全问题
在多线程情况下:
-
如果是多线程情况下,有一个T1线程和T2线程,还是将第一步和第二步的顺序进行交换,如果T1线程先执行第二步,将标志位flag置为true;
-
如果这时CPU进行调度,切换到T2线程,T2线程可能不会去执行write方法,而是去执行multiply方法;
-
这时T1线程已经将标志位flag改为true,所以T2就可以直接执行平方运算,然后result的值经过运算就变成了0,显然与预期的结果值4 (2*2) 不一致,因此在多线程下可能会存在线程不安全问题
关键字:使用volatile、synchronized
- 那么要怎样使程序编译时不会发生指令重排呢?
为了让a=2和flag=fals按照我们所写的顺序来执行,可以使用synchronized同步锁来修饰write方法
- 那么可以使用volatile进行修饰来解决该问题吗?
不可以,因为这里的方法存在两个成员变量,即a和flag,而volatile只能修饰一个成员变量
- 那么什么时候可以使用volatile呢?
比如我们在new一个User对象的时候,首先在堆中申请一块内存,然后给内存中的属性赋值,最后赋值给栈中的变量,多线程情况下,可能User对象申请完内存后,还没来得及赋值,就被线程给调用了,这时候我们可以使用volatile来修饰这个对象
好了,今天有关并发三大特性的学习就到此结束了,欢迎大家学习和讨论!
参考视频链接:
https://www.bilibili.com/video/BV1Eb4y1R7zd (B站UP主程序员Mokey的Java面试100道)