首页 / JAVA  

Java内存模型(JMM)解析


什么是Java内存模型?

在了解java内存模型之前,我们先来看看计算机的内存模型 

CPU执行过程

大家都知道,计算机在执行程序时,每条指令都是在CPU中执行的,而执行的时候,又免不了要和数据打交道,而计算机上面的临时数据,是储存在主存中的(计算机物理内存)。

计算机内存包括高速缓存和主存。

我们知道CPU执行指令的速度比从主存读取数据和向主存写入数据快很多,所以为了高效利用CPU,在它们之间添加了一层缓存区(高速缓存(cache))来匹配CPU的执行速度,最终程序的执行过程如下

1.首先会将数据从主存中复制一份到CPU的高速缓存中

2.当CPU执行计算的时候就可以直接从高速缓存中读取数据和写入数据

3.当运算结束后,再将高速缓存的数据更新到主存中


数据一致性问题

上面的执行过程在单线程情况下并没有问题,但是在多线程情况下就会出现问题,因为CPU如果含有多个核心,则每个核心都有自己独占高速缓存,如果出现多个线程同时执行同一个操作,那么结果是无法预知。例如2个线程同时执行i++,假设i的初始值是0,那么我们希望2个线程执行完成之后i的值变为2,但是在并发情况下,当线程1从主内存中读取i的值为0,把它改为1时,写入到自己的高速缓存中,但是还没有写入主内存;当线程2读取i的值时,此时还是读取的是主内存的i(此时的i值还是为0);当两个线程完成操作后,主内存的i可能会是1,而不是我们希望的2;出现这个情况,我们称为缓存不一致问题。

那么如何解决CPU出现的缓存不一致问题呢?通常使用的解决方法有2种:

1.通过给总线加锁

2.使用缓存一致性协议


第1种方法虽然也达到了目的,但是在总线被锁住的期间,其他的CPU也无法访问主存,效率很低,所以就出现了缓存一致性协议

第2种方法,其中最出名的就是Intel的MESI协议,MESI协议保证每个CPU高速缓存中的变量都是一致的。它的核心思想是,当CPU写数据时候,如果发现操作的变量是共享变量(即其他CPU上也存时,会立即刷到主内存中,然后通知其它CPU),就会发出信号通知其他CPU将它高速缓存中缓存这个变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己高速缓存中缓存该变量的缓存行为无效状态,那么它就会从主存中重新读取。


处理器重排序问题

在多线程场景下,CPU除了会出现缓存一致性问题,还会出现因为处理器重排序即处理器(CPU)为了提高效率可能会对输入的代码进行乱序执行,而造成多线程的情况下出现问题。

例如:

//线程1:
context = getContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingByText(context);

线程1由于处理器重排序,先执行性了语句2,那么此时线程2会认为context已经初始化完成,那么跳出循环,去执行doSomethingByText(context)方法,实际上此时context并未初始化(即线程1的语句1还未执行),而导致程序出错。

什么是计算机内存模型

计算机中内存分为高速缓存内存和主内存,线程在执行过程中会将数据依次写入到两个内存中去

上面提到的缓存一致性问题、处理器重排序问题都是在多线程情况下CPU可能出现的问题,那我们应该怎么处理这些问题?

可见性即当一个变量修改后,这个变量会马上更新到主存中,其他线程会收到通知这个变量修改过了,使用这个变量的时候重新去主存获取

Java内存模型(Java Memory Model,JMM)

JMM规定了所有的变量都存储在主存中,每个线程都有自己的工作区(相当于计算机中的高速缓存区),线程将使用到的变量从主存中复制一份到自己的工作区,线程对变量的所有操作(读取、赋值等)都必须在工作区,不同的线程也无法直接访问对方工作区,线程之间的消息传递都需要通过主存来完成。可以把jvm中的主存类比成计算机内存模型中的主存,工作区类比成计算机内存模型中的高速缓存。

而我们知道JMM其实是工作在计算机主存中的,Java内存模型中的工作区也是计算机主存中的一部分,所以可以这样说Java内存模型解决的是内存一致性问题(java主存和计算机主存)而计算机内存模型解决的是缓存一致性问题(CPU高速缓存和主存),这两个模型类似,但是作用域不一样,Java内存模型保证的是主存和主存之间的原子性、可见性、有序性,而计算机内存模型保证的是CPU高速缓存和主存之间的原子性、可见性、有序性。


工作内存与主内存(jvm主内存)之间交互操作



  关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了以下8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的:

1、lock(锁定):作用于主内存中的变量,它把一个变量标识为一条线程独占的状态。

2、unlock(解锁):作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

3、read(读取):作用于主内存中的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。

4、load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

5、use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。

6、assign(赋值):作用于工作内存中的变量,它把一个从执行引擎接收到的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

7、store(存储):作用于工作内存中的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。

8、write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中。

如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行以下两个操作:

(1)由JVM主内存执行的读(read)操作;

(2)由Java线程的工作内存执行相应的load操作。

反过来,如果把变量从工作内存中同步回主内存中,也出现两个操作:

(1)由Java线程的工作内存执行的存储(store)操作;

(2)由JVM主内存执行的相应的写(write)操作。

      Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺序是read a,read b,load b, load a。

volatile型变量的作用

      上面说过,read,load,store,write的操作都是原子的,即执行期间不会被中断!但是各个原子操作之间可能会发生中断!对于普通变量,如果一个线程中那份主内存变量值的拷贝更新了,并不能马上反应在其他变量中,因为Java的每个线程都私有一个工作内存,里面存储了该条线程需要用到的主内存中的变量拷贝!(比如实例的字段信息,类型的静态变量,数组,对象……)如图:


A,B两条线程直接读or写的都是线程的工作内存!而A、B使用的数据从各自的工作内存传递到同一块主内存的这个过程是有时差的,或者说是有隔离的!通俗的说他们之间看不见!也就是之前说的一个线程中的变量被修改了,是无法立即让其他线程看见的!如果需要在其他线程中立即可见,需要使用 volatile 关键字。现在引出volatile关键字:

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制。一个变量被定义为volatile后,它将具备两种特性:

1、保证此变量对所有线程的"可见性",所谓"可见性"是指当一条线程修改了这个变量的值,新值对于其它线程来说都是可以立即得知的,而普通变量不能做到这一点,普通变量的值在在线程间传递均需要通过主内存来完成。例如,线程A修改一个普通变量的值,然后将变量的值写回主内存,另外一个线程B在线程A回写完成了之后再从主内存进行读取操作,新变量值才会对线程B可见。另外,java里面的运算并非原子操作,会导致volatile变量的运算在并发下一样是不安全的。再强调一遍,volatile只保证了可见性,并不保证基于volatile变量的运算在并发下是安全的

2、使用volatile变量的第二个语义是禁止指令重排序优化,普通变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。


可见性

1.每次读取前必须先从主内存刷新最新的值。(线程在读取使用volatile修饰的共享变量时,不会直接使用旧的工作内存中的拷贝值,而是先到主内存中把该变量的值重新拷贝一下到工作内存中,然后再使用)

2.每次写入后必须立即同步回主内存当中。(线程只要改变了使用volatile修饰的共享变量时,就会立马同步刷新到主内存中,)

public class MutableInteger {
    private int value;
    public int get(){
        return value;
    }
    public void set(int value){
        this.value = value;
    }
}
MutableInteger不是线程安全的,因为get和set方法都是在没有同步的情况下进行的。如果线程1调用了set方法,那么正在调用的get的线程2可能会看到更新后的value值,也可能看不到。

解决方法很简单,将value声明为volatile变量:

private volatile int value;
所以使用volatile修饰的共享变量,其它线程时可见的。


防止指令重排

重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。重排序需要遵守一定规则:

a.重排序操作不会对存在数据依赖关系的操作进行重排序。

  比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运

行时这两个操作不会被重排序。

b.重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变

  比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系, 所以可能会发

生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。

重排序在单线程下一定能保证结果的正确性,但是在多线程环境下,可能发生重排序,影响结果,下例中的1和2由于不存在数据依赖关系,则有可能会被重排序,先执行status=true再执行a=2。而此时线程B会顺利到达4处,而线程A中a=2这个操作还未被执行,所以b=a+1的结果也有可能依然等于2。

public class  Test{
int a = 1;
boolean status = false;
public void changeStatus(){
   a = 2 // ----------01
   status = true // -----------02
}
public void run(){
if(status){
   int b = a+1  // -----------03
   System.out.println(b)
}
}
}
使用volatile关键字修饰共享变量便可以禁止这种重排序。若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,volatile禁止指令重排序也有一些规则:

a.当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后

面的操作可见;在其后面的操作肯定还没有进行;

b.在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放

到其前面执行。

即执行到volatile变量时,其前面的所有语句都执行完,后面所有语句都未执行。且前面语句的结果对volatile变

量及其后面语句可见。

原子性、可见性、有序性

什么是原子性?

在Java中,对基本数据类型的变量的操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。看例子:
int x = 10;         //语句1
y = x;               //语句2
x++;                //语句3
x = x + 1;        //语句4

其实只有语句1是原子性操作,其他三个语句都不是原子性操作。语句1是直接将数值10赋值给x,也就是说线程执行这个语句会直接将数值10写入到工作内存中。线程执行语句2实际上包含2个操作,它先要去主内存读取x的值,再将x的值写入工作内存,虽然读取x的值以及将x的值写入工作内存这2个操作都是原子性操作,但是合起来就不是原子性操作了。同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。所以上面4个语句只有语句1的操作具备原子性。也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。所以使用volatile关键字修饰上面的x变量,如果它们是执行语句2,3,4这样的操作时,也是不能保证原子性的


什么是可见性?

     就是一个线程修改了变量,其他线程可以立即能够知道。保证可见性可以使用之前提到的volatile关键字(强制立即写入主内存,使得其他线程共享变量缓存行失效),还有重量级锁synchronized (也就是线程间的同步,unlock之前,写变量值回主存,看作顺序执行的),最后就是常量——final修饰的(一旦初始化完成,其他线程就可见)。其实这里忍不住还是补充下,关键字volatile 的语义除了保证不同线程对共享变量操作的可见性,还能禁止进行指令重排序!也就是保证有序性。


什么是有序性?

    Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无须的。Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由"一个变量在同一时刻只允许一条线程对其进行lock操作"这条规则获得的,这条规则规定了持有同一个锁的两个同步块只能串行地进入。

    工作内存和主内存同步延时(也就是线程A先后更新两个变量m和n,但是由于线程工作内存和JVM主内存之间的同步延时,线程B可能还没完全同步线程A更新的两个变量,可能先看到了n……对于B来说,它看A的操作就是无序的,顺序无法保证)。


2019-10-30