线程安全问题
首先,请看下面的问题。
请编写一段程序,以两个线程的方法实现对变量a从0自增到10_0000;
对于这道题,刚开始我们就有一个很简单的思路,那就是按照它的题意我们创建两个线程,在每个线程里 用循环来对a进行自增,直接上代码!
可以看到,这里的代码是按照我们的思路来进行敲写的,看起来是不是感觉没有任何的问题,两个线程,每一个中都有一个循环来对a进行自增,那么我们来看代码结果:>
再运行一次:>
这时发现,wc…怎么和我们预期的结果不太一样?首先,我们可以来对这段代码进行分析。
其实a++操作,我们可以拆分为三步操作。
- cpu在内存中读取到a的值。
- 执行a+1的操作。
- 赋值给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可以解决非原子性操作的问题
谢谢观看!