java架构之路(一)JMM和volatile关键字

隐身守侯 提交于 2019-11-26 12:45:55

 

  说到JMM大家一定很陌生,被我们所熟知的一定是jvm虚拟机,而我们今天讲的JMM和JVM虚拟机没有半毛钱关系,千万不要把JMM的任何事情联想到JVM,把JMM当做一个完全新的事物去理解和认识。

我们先看一下计算机的理论模型,也是冯诺依曼计算机模型,先来张图。

其实我们更关注与计算机的内部CPU的计算和内存之间的关系。我们在来深入的看一下是如何计算的。

我们来看一下这个玩意的处理流程啊,当我们的数据和方法加载的内存区,需要处理时,内存将数据和方法传递到CPU的L3->L2->L1然后再进入到CPU进行计算,然后再由L1->L2->L3->再返回到主内存中,但是我们的科技反展的很快的,现在貌似没有单核的CPU了吧,什么8核16核的CPU随处可见,我们这里只的CPU只是CPU的一个核来计算这些玩意。假设我们的方法是f(x) = x + 1,我们入参是1,期望得到结果是2,1+1=2,我计算的没错吧。如果我们两个核同时执行该方法呢?我们的CPU2反应稍微慢了一点呢?假如当我们的内存再向CPU2发送参数时,可能CPU1已经计算完成并且已经返回了。这时CPU2取得的参数就是2,这时再进行计算就是2+1了。结果并不是我们期望的结果。这其实就是数据未同步造成的。我们应该想尽我们的办法去同步一下数据。

  

我们中间加了一层缓存一致性协议。也就是我们的MESI,在多处理器系统中,每个处理器都有自己的高速缓存,而它们的又共享同一个主内存

我来简单说一下,我们的MESI是咋回事,是怎么做到缓存一致性的。英语不好,我就不误解大家解释MESI是什么单词的缩写了(是不是缩写我也不知道,但是我知道工作原理)。

我们还是从内存到CPU的这条线路,这时我们多了一个MESI,当变量X被共同读取时,CPU1和CPU2是共享一个X变量,但是分别存在CPU1和2内,也就是我们X(S)的状态。然后CPU1和2一起准备要计算了。

然后1和2一定会有一个厉害的。比如1得到了胜利,这时CPU1里的X(S)变为X(E)由共享状态变为独享状态,并且告诉CPU2把X(S)变为X(I)的状态,由共享状态变为失效状态。然后CPU1就可以计算了。计算完成,

又将X(S)变为X(M)的状态,由独享状态变为了修改的状态。

M: 被修改(Modified)

该缓存行只被缓存在该CPU的缓存中,并且是被修改过的(dirty),即与主存中的数据不一致,该缓存行中的内存需要在未来的某个时间点(允许其它CPU读取请主存中相应内存之前)写回(write back)主存。

当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。

E: 独享的(Exclusive)

该缓存行只被缓存在该CPU的缓存中,它是未被修改过的(clean),与主存中数据一致。该状态可以在任何时刻当有其它CPU读取该内存时变成共享状态(shared)。

同样地,当CPU修改该缓存行中内容时,该状态可以变成Modified状态。

S: 共享的(Shared)

该状态意味着该缓存行可能被多个CPU缓存,并且各个缓存中的数据与主存数据一致(clean),当有一个CPU修改该缓存行中,其它CPU中该缓存行可以被作废(变成无效状态(Invalid))。

I: 无效的(Invalid)

说到这也就是是我们JMM的内存模型的工作机制了。所以说JMM是一个虚拟的,和JVM一点关系都没有的。切记不要混淆。

这里也有三个重要的知识点。

JVM 内存模型(JMM) 三大特性

原子性:指一个操作是不可中断的,即使是多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰

比如,对于一个静态全局变量int i,两个线程同时对它赋值,线程A 给他赋值 1,线程 B 给它赋值为 -1,。那么不管这两个线程以何种方式,何种步调工作,i的值要么是1,要么是-1,线程A和线程B之间是没有干扰的。这就是原子性的一个特点,不可被中断

可见性:指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。显然,对于串行程序来说,可见性问题  是不存在。因为你在任何一个操作步骤中修改某个变量,那么在后续的步骤中,读取这个变量的值,一定是修改后的新值。但是这个问题在并行程序中就不见得了。如果一个线程修改了某一个全局变量,那么其他线程未必可以马上知道这个改动。

有序性:对于一个线程的执行代码而言,我们总是习惯地认为代码的执行时从先往后,依次执行的。这样的理解也不能说完全错误,因为就一个线程而言,确实会这样。但是在并发时,程序的执行可能就会出现乱序。给人直观的感觉就是:写在前面的代码,会在后面执行。有序性问题的原因是因为程序在执行时,可能会进行指令重排,重排后的指令与原指令的顺序未必一致(指令重排后面会说)。

我们来看一下volatile关键字

先看一段代码吧,不上代码,总觉得是自己没练习到位。

private static  int counter = 0;

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(()->{
                for (int j = 0; j < 1000; j++) {
                    counter++; //不是一个原子操作,第一轮循环结果是没有刷入主存,这一轮循环已经无效
                }
            });
            thread.start();
        }

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(counter);
    }

按照JMM的思想流程来解读一下这段代码,我们先创建10个线程。我们这里叫做T1,T2,T3...T100。然后分别去拿counter这个数字,然后叠加1,循环1000-counter次。当T1拿到counter,开始计算,假如,我们计算到第50次时,这时线程T2,也开始要拿counter这个数字,这时得到的counter数字为50,则T2就要循环950次,最后我们计算得到的counter就是9950。也就是说,内部是没有内存一致性协议的。所以我们的输出一定是<=10000的数字。

我们来尝试改一下代码,使用一下我们的volatile关键字。

private static volatile int counter = 0;

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(()->{
                for (int j = 0; j < 1000; j++) {
                    counter++; //不是一个原子操作,第一轮循环结果是没有刷入主存,这一轮循环已经无效
                }
            });
            thread.start();
        }

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(counter);
    }

这时我们加入了volatile关键字,我们经过多次运行会发现,每次结果都为10000,也就是说每次都是我们期待的结果,volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。

也就是我们加入了volatile关键字时,java代码运行过程中,会强制给予一层内存一致性的屏障,做到了,我们计算直接不会相互影响,得到我们预期的结果。

1、可见性实现:

  在前文中已经提及过,线程本身并不直接与主内存进行数据的交互,而是通过线程的工作内存来完成相应的操作。这也是导致线程间数据不可见的本质原因。因此要实现volatile变量的可见性,直接从这方面入手即可。对volatile变量的写操作与普通变量的主要区别有两点:

  (1)修改volatile变量时会强制将修改后的值刷新的主内存中。

  (2)修改volatile变量后会导致其他线程工作内存中对应的变量值失效。因此,再读取该变量值的时候就需要重新从读取主内存中的值。相当于上文说到的从S->E,另一个线程从S->I的过程。

  通过这两个操作,就可以解决volatile变量的可见性问题。

2、内存屏障

  为了实现volatile可见性和happen-befor的语义。JVM底层是通过一个叫做“内存屏障”的东西来完成。内存屏障,也叫做内存栅栏,是一组处理器指令,用于实现对内存操作的顺序限制。下面是完成上述规则所要求的内存屏障:

Required barriers 2nd operation
1st operation Normal Load Normal Store Volatile Load Volatile Store
Normal Load       LoadStore
Normal Store       StoreStore
Volatile Load LoadLoad LoadStore LoadLoad LoadStore
Volatile Store     StoreLoad StoreStore

(1)LoadLoad 屏障
执行顺序:Load1—>Loadload—>Load2
确保Load2及后续Load指令加载数据之前能访问到Load1加载的数据。

(2)StoreStore 屏障
执行顺序:Store1—>StoreStore—>Store2
确保Store2以及后续Store指令执行前,Store1操作的数据对其它处理器可见。

(3)LoadStore 屏障
执行顺序: Load1—>LoadStore—>Store2
确保Store2和后续Store指令执行前,可以访问到Load1加载的数据。

(4)StoreLoad 屏障
执行顺序: Store1—> StoreLoad—>Load2
确保Load2和后续的Load指令读取之前,Store1的数据对其他处理器是可见的。

   总体上来说volatile的理解还是比较困难的,如果不是特别理解,也不用急,完全理解需要一个过程,在后续的文章中也还会多次看到volatile的使用场景。这里暂且对volatile的基础知识和原来有一个基本的了解。总体来说,volatile是并发编程中的一种优化,在某些场景下可以代替Synchronized。但是,volatile的不能完全取代Synchronized的位置,只有在一些特殊的场景下,才能适用volatile。总的来说,必须同时满足下面两个条件才能保证在并发环境的线程安全:

  (1)对变量的写操作不依赖于当前值。

  (2)该变量没有包含在具有其他变量的不变式中。

 参考地址:https://www.cnblogs.com/paddix/p/5428507.html

JMM-同步八种操作介绍

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

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

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

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

(5)use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎

(6)assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存 的变量

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

(8)write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送 到主内存的变量中 

流程图大致是这样的:

 

 后面会继续谈谈并发的问题。也会仔细说一下指令重排这里,这篇博客只是暂时的说了一下而已,后续还有很多重要的知识点要说。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!