一、介绍
研讨会需要研讨发言,因此准备如下草稿。内容探讨volitle的可见性与及为什么多线程会存在并发问题。
(文章建立在群体已经充分了解以下知识点)
------了解JVM的内存模型
------了解JVM如何加载一个java文件到内存
------了解线程是java程序运行的最小单位,JVM进程与线程之间的区别。
------了解volitle变量的用法
------了解CPU是一个用来解析执行指令的硬件,内部分为寄存器,处理器[ 控制器,运算器,高速缓冲存储器],数据传输的总线
二、分析
(依旧按照原来的流程做如下前提简述)
1、什么情况下会发生并发问题?
当多个线程同时访问一份相同的数据时,将对争夺该数据的使用权。这个时候将可能产生并发问题。
2、JVM与CPU之间的关系?
JVM是java运行的一个进程。而CPU是一个翻译与及执行指令的机器。JVM的作用是向上提供服务功能,将class文件加载到方法区中,创建对象生成在内存中,向下调用系统的API来向CPU发送指令,让CPU干活。他们交互的空间就是内存。
3、描绘一个场景?
当多个线程同时竞争一个资源的时候,他们是各自在内存中复制了一份高速缓存,在高速缓存中修改后,再同步到内存中。
正常我们的数据在内存中也是一个物理标记,而当我们的数据同时被两个线程盯上的时候就会产生并发问题了。
(以下正文)
通常情况下,我们会使用【volitle实现变量可见性】,【同时禁止指令重排序】(这个概念是错误的) 来解析。我们分析线程会先复制一份到自己的【高速缓存空间】先在高速缓存空间修改完成后,再去判断状态是否和内存中的一致,如果一致执行同步。并让其他线程监听到内存中的数据已经被修改,自动失效高速缓存的数据。
此处的分析依据是基于缓存一致性的MESI协议,即他规定每条缓存记录都存在着一个状态位,有如下四种变化:
此处解析:
我们知道CPU的组成中存在着高速缓冲存储器,这一块是属于CPU内部的缓存,我们平时说的高速缓存就是指的这里。通常情况下,CPU是通过将内存中的数据加载到高速缓存中,再经过CPU运算器进行运算。最终写回内存。对于个CPU而言,高速缓冲存储器里面是具备很多块高速缓存的。它的运行速度远比jvm的运行速度要快得多,几乎达到了纳秒级别,正常情况下是不会对代码产生影响的。
正常情况下的争夺问题,CPU的高速缓存足以应对,其处理速度并不会导致缓存长时间读取不到。【CPU缓存一致性】
(为什么会存在CPU的缓存呢?)
无论并发线程到底有多快,在宏观层面我们看到的可能是两个线程同时并发操作,但实际上再微观层面,也就是CPU底层他们是必须有个先来后到。我们都知道数据在内存中也就是一个物理标识位,那么假设,如果不存在高速缓存块不存在缓存一致性问题。那么当两个线程来抢夺同一块数据的时候,总有一个先抢到,而抢不到的线程则只能阻塞等待【存在锁机制】。这样对CPU的性能无疑是一种浪费。因此CPU高速缓存块其实是为了提高CPU的性能而设置的不可或缺的一部分。同时也不会造成并发问题。
快速结论:正常数据进入CPU是先从内存中到CPU的高速缓存!
(既然如此那么为什么java还是会出问题呢?)
问题出在JVM,也就是java在没经过CPU允许的情况下,对CPU的缓存进行了利用。我们知道java是编译解析型语言,其进入内存到被生成对象的过程是,JVM通过类加载器将.java文件读取转化为class文件,经过一系列的加载验证解析的操作后将class文件读取到方法区中,在解析成对象后到内存中后,通过执行引擎进行数据处理。
执行引擎在这里做了什么操作呢? 主要负责垃圾回收,执行class文件,代码优化。垃圾回收是我们常说的【GC】(此处不做讨论),执行文件则是将方法区中的class二进制解析实例化为对象的过程。而代码优化则是执行引擎中另外一部分【即时编译器】
即时编译器负责的工作主要是在不改变单指令的结果的情况下,将我们的代码进行优化。此部分结合如下代码分析:
说明:从以上可以看到我们的设计逻辑是这样的:启动一个线程,每次都去读取布尔值flag,如果flag为true那么则加1,如果检测到不为ture那么则停止线程内的加1循环。
咋一看似乎没什么问题,但实际上这段代码是不会停下来的。因为他被JIT编译器进行了优化。在JIT编译器中,正常我们思考的步骤是,while体每次循环都去读取一次flag标志。然后再执行判断。而在JIT看来,既然每次都要去读取flag标志,而你在系统编译中又将flag赋值为true,那么我为什么需要每次都去重复读取呢?于是将 读取的操作直接替换为 已经被赋值的结果。
其替换思路如下:(Z相当于Flag,由于每次都需要读取,因此被JIT给优化了)
解析:从第一段代码中得到结果,flag标志需要每次都读取用于判断是否进入循环,但因为JIT及时编译器的优化,flag标志变成了首次读取后便不再变更的值,所以代码最终也没有按照我们的要求跑出循环。
结论:并发问题出现所见非所得问题可以则是由于JVM的JIT即时编译器导致的。这个过程是JIT想着把值给缓存到高速缓存中而带来的后果。
由于Java的JVM编译与CPU硬件开发商之间在没有经过任何协议的基础上进行了利用缓存的操作,因此java社区【JCP】便制定了关于JSR规范,用于约束开发厂商对java的利用标准。Volitle就是基于JCP提出的JSR.133号规范。在规范中明确约束看对volitle的可见性是实现效果和要求。明确要求volitle修饰的变量不允许缓存。
基于JVM与CPU之间天然的关系,CPU同时也跟进更新,向JVM提供了内存屏障的两个指令。【读屏障,写屏障】
写屏障:当某个线程改变了某个被Volitle修饰的变量的时候,执行指令将最新的数据写入到主内存中。让其他线程可见
读屏障:在执行读指令之前,先执行一个让高速缓存中的数据失效的指令,强制从主内存读取新的数据。
(Volitle的可见性原理【为什么】)
所谓指令重排序?
【volitle具有禁止指令重排序的能力】这句话本身是有问题的。JIT编译器的指令重序是为了优化代码,是一种优化手段,volitle并没有能力去阻止他不去指令重排序。volitle只能让JVM去遵循某些规则。【程序的执行结果不能被改变,编译器和处理器都必须遵循as-if-serial协议,也即是不会对存在数据依赖关系的操作做重排序】
结果:volitle声明了不能被缓存,从而导致jvm在运行过程中需要遵循as-if-serial协议,不能改变程序原有的结果和语义,(必须实在地去拿数据,不能用缓存去替换实在数据)。
科普:什么是数据依赖关系? 变量之间存在着数据传递的过程则存在数据依赖关系。比如:
A = 1;
B = 2;
----------<A与B之间不存在数据依赖关系,即使指令重排序也没关系>
B = A + 1;
C = B ;
----------<A与B之间或第一条数据第二条数据之间存在着变量赋值,不允许被改变顺序>
在上面的代码中我们的flag做了一个操作:--------------------------->
(正常情况下:)
flag = true;
flag = false;//赋值
flag == true ?
(排序后:)
flag == true;
flag == true?
flag == false; //此代码因为上面持续执行进入循环,执行不到此处
注:上面的几句存在着数据传递的过程因此不能被排序,否则会出错。
结论:JAVA程序放在内存中运行,是基于CPU运行的,CPU是非常底层的处理器,提供了优化加速性能的高速缓存区域,本身没有问题,但他提供的这个缓存被JIT即时编译器进行了利用。JVM里面自己的指令重排序做为一种优化手段之一,本质上是为了节约资源而设置,也没有问题。CPU缓存和java的JIT运行的指令优化机制的混合使用最终才导致了多线程下出现所见非所得的问题。
根据可见性原理,以上面那段代码为例,如果我们要保证代码的有效性。可以从如下三个方面进行操作。
1、将flag变量设置为 由volite修改的可见性变量(实际上不可缓存变量,添加了内存屏障指令)
2、在JVM配置中将JVM设置为禁止JIT即时编译器启用指令优化机制
3、在循环体中添加【Sychronized】锁。
关于Sychronized为什么会生效?
1、jls8【java 语言编程规范8】中规定了关键字必须达到实现要求。jls8中明确指出,【sychronized得到获取到锁之前和锁之后的所有数据变动】也就是说关键字必须获得到锁的实时数据,并根据实时数据进行判断。此处可以理解在在获取锁之前执行了读的内存屏障指令,从而保证了不会获取到CPU缓存中的数据状态。
2、需要注意的是,此处锁描述的获取锁前后,并不包括获取锁中间。也即是说Sychronized在进入锁之前和退出锁时,获取到的是实时性的设置了内存屏障的数据。而在锁中间的数据则可能不是实时的。因此基于上面的代码,如果使用sychronized,我们需要添加锁的位置是在循环体的里面。
总结:由于研讨开会讨论交流的很多文件内容都经过优化,不允许被公布,此文章的目的应该着重于建立一个CPU内部数据流转图的意识图。基于此意识图帮助我们梳理多线程章程的分析和思考。我们得到?
------线程是一段代码搬运流
------JVM是一个与CPU交互的API工具,通过调用操作系统提供的API向CPU发送指令
------volitle可见性问题本质上是对缓存的禁止导致了JIT绕过了指令排序
------CPU的内部数据形象
------JCP社区与JSLT文件是什么?