java架构之路(多线程)JMM和volatile关键字

 说到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 barriers2nd operation
1st operationNormal LoadNormal StoreVolatile LoadVolatile Store
Normal Load


LoadStore
Normal Store


StoreStore
Volatile LoadLoadLoadLoadStoreLoadLoadLoadStore
Volatile Store

StoreLoadStoreStore

(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操作从工作内存中的一个变量的值传送 到主内存的变量中 

流程图大致是这样的:

如果你觉得文章不错,文末的赞 ???? 又回来啦,记得给我「点赞」和「在看」哦~

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值