JUC并发编程学习之并发三大特性

前言

在并发编程的学习中,并发三大特性是我们肯定会遇到的知识点,在面试中,也会常常被问到,那么并发的三大特性到底包括哪些呢?我们如何能够快速理解它,今天的博客将会带大家有一个彻底的了解!

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

  1. 如果有两个线程T1和T2,它们会分别从主存中把i变量值读取到工作内存中 (这里其实是对i变量值的一个复制,T1和T2都会有一个自己的工作内存)
  2. 然后分别开始进行加1的操作(即i值变为1),将i=1的结果写入到T1和T2的工作内存中(前三步操作都是确定的,而最后一步操作是不确定的)
  3. 最后将i=1的值再写回到主存中去(由操作系统来决定,不确定时间)

总结:程序中的原子性是指最小的操作单元,比如自增操作,它本身其实不是原子性操作,分了三步,包括读取变量的原始值,进行加1操作,写入工作内存

1-4 常规并发操作

如果是常规的并发操作,其具体执行步骤如下

  1. T1线程先从主存中将i=0的复制值读取到工作内存中,但这时CPU进行调度,切换到T2线程来执行;
  2. T2线程首先将i=0的复制从主存中读取到工作内存中,然后执行加1操作,即i值变为1,将i=1存入到T2的工作内存;
  3. 再切换到T1线程,T1还没有进行加1操作,这时T1的i=0的值被写回到主存,这样肯定就导致其结果错误了
1-5 保证add方法原子性

如果保证add方法是原子性的,具体执行步骤如下

  1. T1线程先从主存中将count=0的复制值读取到工作内存中,然后执行加1操作,count值变为1,将count=1存入到T1的工作内存;
  2. 然后再T2线程再执行此方法,重复与T1相同的操作,T2线程在其工作内存中的count值也为1
    让前三步一块执行,这样就保证了原子性;
  3. 但还是由于最后一步何时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道)

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

狂奔の蜗牛rz

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值