【线程与并发】详谈 可见性,有序问题
1.现代CPU架构带来的可见性问题
如果我们要进行一个数据处理,我们先需要在硬盘中存储的数据读取到我们的内存中,此时cpu要将数据读取出来进行计算,是直接从内存中读取放到自己的register中,然后进行计算,这个速度在计算机看来还是不够快,这个时候就出现了缓存,现在计算机有三级缓存。
根据下表可以看出来3、2、1级缓存读取时间依次减少。
现在我们的CPU一个里面有多颗CPU,如果是双核CPU那么一颗CPU里有两个核,L1、L2都存在核内,L3存在核外CPU内。
所以我们读取的数据很有可能是某个缓存中的数据,导致我们读取的数据不是最新的,所以可见性会有问题。我们线程中如果读取数据实际上是先从自己的工作区中读取到数据,如果没有会向主内存中获取,然后加载到自己的工作区然后进行和cpu进行交互。这样就有可能造成如果工作区有数据,主内存数据变了,可能无法第一时间读取到最新的值。
2.有序性带来的this溢出问题
举例:看下面这个代码,我先创建这个类的对象的时候,执行最后的结果很有可能最后输出的this.num的值不是8,而是0;
对象创建的过程
为什么会出现这种情况,我们要讲解一下对象创建的过程是怎么样的。那么我们可以看一下创建一个对象的过程的汇编码。
我们先写了一个类然后对这个类进行创建对象。然后看一下汇编码,第一步我们先new 了一下,这个原理就是在底层开辟了一个空间,那么这个没创建完成的对象里面的成员变量会设置一个初始值this.num=0。接下来调用init方法会调用它的构造器方法,这个时候成员变量就会赋值变成this.num=8,最后astore建立关联,将t变量指向创建好的这个对象地址。
再回到刚才那个例子我们在这个类的构造器里写的是开启了一个线程,执行输出成员变量的值。在main方法里面创建这个类的对象,通过我们刚刚学习的对象创建的过程我们知道,new了这个对象,过程是会先开辟一个空间那么num存的值应该是一个初始的this.num=0,接下来会调用构造方法赋一些值,其实我们这个构造方法还没完成,但是我们在构造器还没完全完成的时候就调用新的线程输出num,很有可能输出的是num还没赋值后的值。
3.Java靠什么来维持可见性和有序性
在这里我们不得不提一个设计模式--单例模式,简单的说一下单例模式是什么,一个类将构造器方法私有化,然后声明一个方法来获可以取出创建好的对象。
这里谈JUC,那么我们直接使用线程安全的单例懒汉模式。这里使用了DCL(double check lock)双重检查锁,第一个if判断进行检查当前这个类对象有没有被创建,如果没被创建就获取这个锁,获取到锁后进入再进行第二个if判断这个对象有没有被创建,这是因为如果多个线程进来的时候,可能最开始都是没有被创建,于是过了第一个检查锁,接下来等待获取锁,第一个获取到锁后创建对象,后面获取到锁的线程可能因为已经经过了判断过对象没被被创建,导致直接又创建一个对象,所以避免重复创建可以进行第二次if判断检查,如果没被创建就可以正常赋值,如果已经被创建了就直接返回创建好的对象。那么我们可以仔细想想,本来本来一个synchronized里加一个检查就可以完成的为什么还要加外边那个if?因为如果多个线程进来想获取对象,如果前几个线程中已经有一个线程创建好对账了,后面的线程再进行获取锁是不是会想jvm申请锁消耗资源,效率非常低,但是如果经过一个if判断,别的线程看到对象已经被创建就可以直接返回创建好的对象了,目的阻挡他们获取锁,if判断的效率要比一堆线程获取锁的性能要高很多的。
已经介绍完线程安全模式的单例模式,可以正式讲解。
指令重排:
就是指为了让性能最快,让最终结果一致而导致的代码底层命令执行的顺序发送了重排,这对于单线程是没有问题的,因为我一个线程按顺序执行,最终的结果一致就可以,但是多线程中就会出现问题。
多线程情况下:
比如说有两个线程一块进行想要创建获取这个对象,那么在new 对象的过程中,如果第一个线程发生一个叫之类重排的现象,我们前面已经了解过new 对象过程中汇编层会发什么事情,那么指令应该是 开辟空间设对象里的初始值->调用构造器给对象设置值->最好将变量指向当初开辟的空间 如果按照这个顺序来说,最好创建好的对象是好着的,但是一旦出现指令重排,例如 先开辟空间,接着第二步和第三步的顺序发生了调换,那么就会先将对象变量指向这个新开辟的对象空间地址,然后再设置调用构造方法设置值。别的线程在第一个线程的第二步的时候就能根据变量获取的这个对象了,只不过里面的是没有设置好我们定义好的值的。这个时候第二个线程在第一个检查if里发现这个变量不是null(因为开辟好空间指的是里面对象里属性变量等的值是空或者0,不是这个变量指向的对象本身是null)于是直接将这个指向这个对象的变量返回回去,这就大错特错了,因为返回的是一个没有创建完成的对象。
使用volatile关键字
如果使用了volatile关键字就会读取的时候直接从主内存中读取,并且操作的时候会将修改的数据同步刷新到主内存中保持可见性。
最关键的一点是和上面的解释相连接,保证有序性,防止代码进行重排,经过volatile关键字声明过的代码就不会重排,原理就是通过一个一个的JVM的内存屏障,将读读 写写 读写 写读直接或者前后加上一个内存屏障,就可以保证代码的有序性,时间上Java底层的c++代码中是通过lock代码实现这种内存屏障。