上一篇去研究了下处理器内的内存和缓存之间数据的一致性保证,了解到的内容还是很浅的,不着急,慢慢来,以后会继续深入的。
基于此,今天来看看java的虚拟机内的内存模型,其实是可以类比处理器的内存模型,理解起来不难。
java内存模型,术语称之为(java Memory Model JMM)。它的主要目标是定义程序中各个变量的的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这种底层细节。注意此处的变量(Variables)与java中所说的变量是不同的,它包括了实例字段,静态字段和构造数组对象的元素,但是不包括局部变量与方法形参,因为后者是线程私有的,不会被共享,并且是存储在栈中的,这些知识点在第一篇里面的java内存区域有所介绍了。
java内存模型规定了所有的变量都存储在主内存中(Main Memory),要注意此处的主内存和物理主内存叫法一致,但实际上这个主内存只是虚拟机中的一部分,但是可以理解为物理机的主内存,类比着看。此外,每条线程还有自己的工作内存(Working Memory)。线程的工作内存中保存了被该线程使用到的变量的主内存的副本拷贝(此处注意下副本拷贝的概念,假设这边一个对象有10M,但是并不会将10M的整个对象都拷贝在工作内存中,有可能保存的是引用或者变量中的某个字段)。线程对变量所有的操作都是基于工作内存,这边的操作指读取,赋值。不能直接操作主内存中的变量,(注意此处对于volatile变量也是如此,因为volatile变量也是有工作内存的拷贝,但是由于操作顺序性规定,使得它看起来是直接在主内存操作的一样)。
需要注意的是这边讲的主内存和工作内存与java内存区域的概念不是同一层次的划分,两者没有任何关系。
关于内存间的交互操作,深入理解虚拟机书中介绍了原有的定义,比较繁琐,这边我也列出来,稍后会列出比较简单的等效判断原则,先了解一下历史也未尝不可。
首先来看8个操作,虚拟机的实现必须保证每个操作都是原子的,不可再分的,但是这也是不绝对,因为存在long和double的干扰,这个不谈,不是重点。
lock(锁定),unlock(解锁),read(读取),load(载入),use(使用),assign(赋值),stroe(存储),write(写入)。
lock:作用与主内存,它把一个变量标识为一条线程独占的状态
unlock:作用与主内存,它把一个处于锁定状态的变量释放出来,释放后的变量才能被其他线程锁定。
read:作用与主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便于随后的load操作。
load:作用与工作内存的变量,它把read操作从主内存中得到的变量值放入到工作内存的变量副本中。
user:作用与工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时会执行这个操作。
assign:作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store:作用与工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write。
write:作用与主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
综上所述,
1.如果现在要把一个变量从主内存中复制到工作内存中,就需要顺序的执行 read 和load操作。
2.如果要把变量从工作内存中同步到主内存中,就压顺序的执行store和write。
需要注意的是,java的内存模型只要求顺序执行以上两个操作,并不是连续执行,因此read a 、read b、load b、load a是允许的。
另外内存模型还规定了执行上述8个操作必须满足如下规则,,规则好多啊,看的真心累,打字也累~~~~~~~
1. 不允许 read和load、store和write操作之一单独出现,这个很好理解。
2.不允许丢弃掉最近的assign操作,即在工作内存中改变了这个变量的值必须把该变化同步到主内存。
3.不允许一个线程无原因的(没有发生过任何assign操作),就把数据从工作内存同步到主内存。不难理解,没有对变量进行过操作,是不允许同步回主内存的。
4.一个变量的产生只能在主内存中,不允许在工作内存中直接使用一个未被初始化的变量(load或者assign)
5.如果对一个变量执行lock操作,必须先清空工作内存的值,在执行引擎使用之前,重新执行load或者assing操作,不难理解,lock操作在主内存。
6.如果一个变量实现没有lock,那么不允许对它unlock,也不允许去unlock一个被其他线程lock的变量,正常规则。
7.对一个变量执行unlock前,必须把此变量同步回主内存中(执行store,write操作)
---------------------------------------------------------------------------------
以上就是java模型对于所有情况作出的一个严谨但是很繁琐的规定,至于volatile类型的变量,需要特地拎出来说明一下,因为它有些特别。
关键字volatile是java提供的最轻量级的同步机制。当一个变量被声明成volatile类型时,具备两种特性。
1. 保证了此变量对所有线程的可见性,也即是当一个线程修改了这个变量的值之后,新值对于其他线程来说是可以立即得知的,但是普通变量不行,普通变量如果被线程A修改之后的话,需要向主内存进行会写,另外一个线程B在线程A回写完成之后再从主内存进行读取,新值才对线程B可见。
需要特别注意的是仅仅是可见性,但是并没有原子性,因此如果对这个变量进行非原子性的操作,比如说++操作的话(++操作在Class文件中是由4条字节码组成的,volatile关键字只能保证读取数据至栈顶时能读取到最新的,但是不能保证下面执行的iadd操作是一致的),并不能保证最终结果是正确的,这边例子比较多,不列举了。
2.禁止指令重新排序优化,普通变量仅仅会保证在该方法的执行过程中所依赖的赋值结果的地方都能获取到正确的结果,并不能保证变量赋值操作的顺序
与程序代码中的执行顺序一致。
public class Test{
private volatile static Test test;
public static Test get(){
if(test == null){
Synchronized(Test.class){
test = new Test();
}
}
return test;
}
public void static main(String[] args){
Test.get();
}
}
比对一下就能发现当有修饰符volatile时,会出现一个lock的指令,这个操作相当于一个内存屏障(Memory Barrier),有了这个内存屏障之后,就不能把后面的指令重排序到内存屏障之前的位置上去。只有一个CPU时,是不会出现内存屏障的,但是一旦出现多个CPU访问同一块内存时,且其中一个在观测另一个时,就需要内存屏障来保证一致性。
这里,我不清楚lock指令的实际含义,笔者没有经历过汇编的时代,书上明确说明出lock指令会使得本CPU的Cache写入内存,
该写入动作会引起别的CPU或者别的内核无效化
所以volatile变量之所以能对其他CPU可见,就是因为出现了内存屏障(lock指令),
使得本Cache的修改能立马回到内存,并且能使得其他Cache中的数据无效化。
综上来看,volatile的变量的读和普通变量其实没有区别,区别在于写的时候,由于写的时候禁止了指令重排序,
因此可能会慢一点,但是这个慢相比较Synchronized来说的话仍然是轻量级的。
最后总结下从java内存模型出发总结下valotile变量的特殊规则:
假定T是一个线程,V和W分别是两个volatile变量,那么在进行read,load,use,assign,store,write的时候需要满足如下要求:
1. 只有当 T 对 V 执行的前一个动作是 load时,T才能对 V执行 use;并且只有当T对V执行的后一个动作是use的时候,T才能对V执行load动作。
因此,T对V进行的use操作可以认为是和 T对V的load和read操作相关联,必须连续出现
(这条规则其实也就是在要求工作内存,每次使用V前都必须先从主存内刷新最新的值)
2.只有当T对V执行的前一个动作是assign的时候,T才能对V执行store动作,并且只有当T对V执行的后一个动作是store的时候,
T才能对V执行assign动作,可以认为assign动作和store,write动作相关联
(这条规则其实就是要求在工作内存中,每次修改V后都必须立刻同步回主内存中)
3.动作A,动作F,动作P是对V操作。动作B,动作G,动作Q是对W操作。
动作A是对V的use或者assign,动作F是和动作A相关联的load或者store操作。动作P是和动作F相关联的read或者write操作。类似的,
动作B是对W的use或者assign,动作G是和动作A相关联的load或者store操作。动作Q是和动作P相关联的read或者write操作。如果A先于B,
那么P先于Q。
(也就是volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序和程序的顺序相同,对于先对V进行操作,就会先于W写回内存去)