线程安全问题

线程安全问题

首先,请看下面的问题。

请编写一段程序,以两个线程的方法实现对变量a从0自增到10_0000;

对于这道题,刚开始我们就有一个很简单的思路,那就是按照它的题意我们创建两个线程,在每个线程里 用循环来对a进行自增,直接上代码!

可以看到,这里的代码是按照我们的思路来进行敲写的,看起来是不是感觉没有任何的问题,两个线程,每一个中都有一个循环来对a进行自增,那么我们来看代码结果:>

在这里插入图片描述

再运行一次:>

在这里插入图片描述

这时发现,wc…怎么和我们预期的结果不太一样?首先,我们可以来对这段代码进行分析。

其实a++操作,我们可以拆分为三步操作。

  1. cpu在内存中读取到a的值。
  2. 执行a+1的操作。
  3. 赋值给a

这样的话,我们浅画一个图来进行分析。
在这里插入图片描述

但实际情况和我们想象的并不相同,有可能会出现这种情况:>

在这里插入图片描述

因为每个线程都是独立执行的,因此,这里t1和t2在读取数据的时候,读取到的a很有可能就是相同的数据,这样的话会使修改丢失。

如果我们取两边两个极端,如果每次t1和t2都能以正确的顺序对a进行修改的话,那么最后的结果一定会是10_0000, 如果每次t1和t2都是读取到了相同的值并且两次改的操作都覆盖的话,那么a的值就是5_0000。也就是说,最终在上面这种情况下,a的值介于5_0000到10_0000之间。

那么我们又该如何去解决这个问题?这就牵涉到了我们今天的内容——线程安全问题。

线程安全

首先给出线程安全的概念:>

当多个线程访问某个方法时,不管你通过怎样的调用方式、或者说这些线程如何交替地执行,我们在主程序中不需要去做任何的同步,这个类的结果行为都是我们设想的正确行为,那么我们就可以说这个类是线程安全的。

用大白话来说,就是这个进程的运行结果符合我们的预期,这样的话就是线程安全的。

我们首先来看造成线程不安全的原因:>

1.操作系统对线程的随机调度。(可以说这是一个根本问题,我们无法去解决它)。
2.多个线程修改同一个变量。我们来举个例子:>

在这里插入图片描述

在这里的话我们开启了两个线程,分别是t1和t2,然后我们在t1和t2中都对flag的值进行修改。最后我们来看看效果:>
在这里插入图片描述

这里我们可以看到,flag的值偶尔是10,又偶尔是20,可以说是薛定谔的flag了。这就是因为我们两个线程都对flag的值进行修改,势必会导致一方的修改丢失。

这样的情况下,我们可以通过改变代码逻辑来进行规避,但也只能部分规避。

3.有些操作不是原子性的

像上述我们的i++操作,就不是一种原子性操作,它可以分为三步,分别为load,add,save
在这里插入图片描述

在这种情况下,我们开启两个线程对一个变量进行++时。就会出现很多种情况,我们列举出几种情况:

在这里插入图片描述

上图是我们的第一种情况,即线程B在线程A后对变量进行加载,那么最后所导致的结果必然是只自增一次。
在这里插入图片描述

第二种情况是我们希望看到的,线程B在线程A对变量写回内存后,此时再在内存中加载A,这样的话就能成功增加两次。此外,还有很多种情况,我们可以对这两个线程进行排列组合了可以说,因此,最终的结果很难是我们所预期的结果。这种不是原子性的操作也会导致我们的线程不安全。

4.内存可见性问题。

接着我们来说说内存可见性问题,我们还是以一段程序进行切入。

在这里插入图片描述

我们来看这个代码,这个代码的核心要素就在于这个啥都不加的死循环,我们是通过flag来进行控制的。

在这里插入图片描述

要注意的是,这里我们等到线程t1跑起来之后在主线程里对flag进行了修改(这里休眠的主要原因是怕主线程运行太快,主线程率先对flag进行了修改。)

在这里插入图片描述

那么这段代码的运行结果为
在这里插入图片描述

这里我们已经打印出了flag的值了,程序还没有结束,意味着t1里面的循环还没有终止。在这里,就产生了内存可见性问题。

我们来对这道题进行解析

在这里插入图片描述

在上图我们画出了cpu的两个核心,假设两个线程运行在不同的核心上,要注意的是,这里的local cache是三级缓存,用来存放变量的值。

在主线程将flag = true写入主存之后,线程t1是无法直接读取到内存中flag的变化的。

这是因为,jvm的优化机制。请看下图:>

在这里插入图片描述

在这里线程t1所做的工作是,加载flag到内存中,然后进行判断。由于线程t1中并没有对flag进行修改,并且while的转速极快(因为我们的while中没写内容),这就导致了Jvm将其优化为只读一次,然后不断地在缓存中进行Text就可以了。

在这种情况下,就会导致我们的内存可见性问题。后续我们将用volatile关键字进行处理。

5.指令重排序问题。

我们仍然给出一段代码进行举例

在这里插入图片描述

这里我们有四个变量a,b,x,y 我们在主线程中开启一个循环,并且同时开启两个线程对y和x进行赋值。要注意的是,在多线程情况下,这里x和y的值应该会有三种情况。

1.当t1执行到a = 1时,t2执行到了b = 1,此时,x = 1, y = 1;

2.当t1执行完的时候,t2还没开始执行,此时x = 0, y = 1;

3.当t2执行完的时候,t1还没开始执行,最终x = 1, y = 0;

从理论情况来说是不可能出现x = 0, y = 0的情况的,当我们代码运行起来之后,我们可以发现。
在这里插入图片描述

此时出现了x = 0和y = 0的情况。这是为什么呢?

在我们写的代码中,编译器不一定按照我们所写的顺序来进行执行,这就是指令重排序

在这段代码中因为指令重排序,导致了x = b, y = a先于 a = 1, b = 1执行,就会出现x = 0, y = 0的结果。

在这里插入图片描述

线程安全问题解决

首先我们请看3.有些操作不是原子性的问题的解决。

1.对不是原子性操作的操作进行加锁处理

在这里插入图片描述

此处请看,我们在自增的循环里,加了一个synchronized(locker){}将循环自增a++的代码包裹了起来,这里要注意的是

synchronized(){}是同步代码块,此处的()里面存放的锁对象
(synchronized只能对对象进行加锁,每一个对象只能有一把锁),也就意味着被synchronized(对象)的线程,需要争夺到对象的锁,才能执行synchronzied里面的内容,否则就会阻塞等待。(因为每个对象只有一把锁),我们画一个图出来解析

在这里插入图片描述

此外,我们也可以通过对方法加锁,来达到上述效果。

在这里插入图片描述

这里的原理也是与上面相同的,synchronized如果对方法进行加锁,如果由同一个对象去调用它的话,多个线程之前就会进行抢锁操作。但要注意的是,如果是不同对象则没有这种效果。请看:>

在这里插入图片描述

这里我们又对代码进行了修改,将count_add设为了普通方法,新建了两个对象,并在两个线程中分别用两个对象去调用count_add方法。这其实是达不到效果的,还记得那句话吗**,一个对象只有一把锁,当你是两个对象,那就。。。。。各自拿各自的锁**。

2.我们还可以通过AtomicInteger类提供的变量来解决原子性问题

在这里插入图片描述

我们将a变量更换成这种形式,其次将a++代码更换为在这里插入图片描述

这样的话,也能够解决我们的问题,我们让程序运行起来

在这里插入图片描述

也是能够解决问题的。

接下来我们来解决内存可见性问题

内存可见性问题

我们还是以我们上面在讲内存可见性问题的例子来进行举例

在这里插入图片描述

这里我们说,因为编译器的优化导致了local cache中没有计时获取到最新的值。

在这里插入图片描述

解决的办法也很简单,但就是在修改的变量前加一个volatile关键字修饰即可。

在这里插入图片描述

volatile关键字修饰变量的时候,会强制保证主存中的flag值和local cache的值相同,这样的话就能保证在while(!flag)中flag的值能实时与内存相同。

我们让程序运行起来看看效果:>

在这里插入图片描述

可以看到此时程序正常停止,也就是说循环正常终止了。

但是!!volatile还有一个作用,那就是阻止指令重排序!!

指令重排序问题解决

在这里插入图片描述

还记得内存可见性的那个例子吗,这里我们将a和b用volatile关键字进行修饰。我们让程序运行起来,就可以发现,

在这里插入图片描述

程序是一直在跑的,没有出现x = 0, y = 0的出现。

写到这里,我们来做一个总结。

volatile可以解决内存可见性问题和指令重排序问题。

synchronized可以解决非原子性操作的问题

谢谢观看!
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值