java 内存模型JMM解析

java 内存模型JMM解析

  • 一、CPU多核并发缓存架构解析
    •    1、以往的内存读取
    •    2、后来的内存读取
  • 二、java内存模型实现原理
    •    1、验证上图模型
      •      1)案列代码
      •      2)解析案列代码
      •      3)运行案列代码
    •    2、JMM数据原子操作(八种)
      •      1)总线加锁(性能太低)
      •      2)MESI缓存一致性协议(偏硬件的协议)
  • 三、深入理解volatile关键字
  • 四、并发编程的可见性、原子性和有序性
    •    1、原子性
    •    2、可见性
    •    3、有序性
    •    4、volatile关键字对并发编程的保证
      •      1)无法保证原子性的解析
      •      2)synchronized锁机制保证原子性的解析

一、CPU多核并发缓存架构解析

   1、以往的内存读取

读取
读取
读取
主内存
数据存储于硬盘
CPU
CPU

     存在的问题就是,主内存的运行速度严重限制了CPU的效率

   2、后来的内存读取

读取
读取
读取
读取
读取
主内存
数据存储于硬盘
CPU缓存
CPU
CPU缓存
CPU

     CPU缓存的运行速度非常快,比主内存的运行速度快上了好几个级别

二、java内存模型实现原理

   java内存模型,其实称之为 java线程内存模型 更为合适;更多的是在多线程并发时需要重点考虑的
   java线程内存模型与CPU缓存模型类似;是基于CPU缓存模型来建立
   java线程内存模型是标准化的,屏蔽掉了底层不同计算机的区别
java 内存模型JMM解析_第1张图片

   1、验证上图模型

     1)案列代码

java 内存模型JMM解析_第2张图片

     2)解析案列代码

     上述代码中,使用 Thread 运行了两个线程;
       线程一:如果 initFlag 不被修改,就会死循环不输出 “=====================seccess”
       线程二:修改 initFlag 的值

     3)运行案列代码

       运行了之后,会出现下图情况;说明线程一在死循环,也就是说 initFlag 没有被修改
java 内存模型JMM解析_第3张图片
       很明显,线程二修改了initFlag的值,但是线程一却没被修改;说明上面的java线程内存模型是对的

   2、JMM数据原子操作(八种)

  • read:读取;从主内存读取数据
  • load:载入;将主内存读取到的数据写入到工作内存
  • use:使用;从工作内存读取数据来计算
  • assign:赋值;将计算好的值重新赋值到工作内存中
  • store:存储;将工作内存数据写入主内存(注意,此时还未修改原数据值,而是另外存储的)
  • write:写入;将store过来的变量值赋值给主内存中的变量
  • lock:锁定;将主内存变量加锁,标识为线程独占状态,其他线程无法
  • unlock:解锁;将主内存变量解锁,解锁后其他线程可以使用或锁定该变量
         注意: 在上述的代码中,修改完initFlag值后,并不会马上进行store操作;而是需要在 run 方法执行完毕后 才会进行store操作
    java 内存模型JMM解析_第4张图片

     1)总线加锁(性能太低)

       总线: 其实就跟电线差不多,只是电线传输的是电信息,总线传输的是CPU之间的信息
       lock与unlock解析:
线程二在read操作的时候,为主内存的变量加上了lock锁,线程一无法read;在write操作的时候进行unlock解锁,再让线程一read。这样就可以保证数据一致性(当然,前提是线程二比线程一先read;但上述案例中并不是,故而即使加lock锁也不行)
       注意: lock操作是加锁,导致其他线程无法使用线程变量;这也就变成了多线程串联运行,这并不符合多线程的初衷。故而,该方法也不能用
java 内存模型JMM解析_第5张图片

     2)MESI缓存一致性协议(偏硬件的协议)

       多个CPU中主内存中读取同一个数据到各自的高速缓存,当其中某个CPU修改了缓存里的数据,该数据会马上同步回主内存,其他的CPU通过 总线嗅探机制 可以感知(监听)到数据的变化,从而将自己缓存里的数据失效,使其重新读取

三、深入理解volatile关键字

   在很多时候,我们需要的就是一个线程的操作影响到另一个线程;这时候,很明显,上述的 java 线程内存模型是无法满足要求的
   这时候就可以使用关键字 volatile;将上述代码修改成如下形式:

private static volatile boolean initFlag = false;

   volatile关键字:底层实现主要是通过汇编lock前缀指令;这个指令的作用就是采用了MESI缓存一致性协议,主要有三个操作。马上同步回主内存无需等待run方法执行完);其他线程通过总线嗅探机制监听并使缓存中数据失效在store操作时为主内存加上lock锁,等待write操作执行完才允许其他线程进行read操作(这就保证了,在其他线程监听并使数据失效时,重新获取数据的时候是修改后的数据)
   注意: 对于多线程中只有一个线程进行修改变量时,是可用的;但是对于 多线程中有多个线程(一个以上) 修改变量而言,就会出现问题

四、并发编程的可见性、原子性和有序性

   在并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。我们先看具体看一下这三个概念:

   1、原子性

     即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断;要么就都不执行

     一个很经典的例子,就是银行账户转账问题:

       比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。
       试想一下,假如从账户A减去1000元之后,操作突然中止。这样就会导致账户A减去了1000元,但是账户B没有收到1000元。
       所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。

     同样地反映到并发编程中会出现什么结果呢?

      举个最简单的例子,大家想一下假如;为一个32位的变量赋值过程不具备原子性的话,会发生什么后果?

i = 9;

     假若一个线程执行到这个语句时,我暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。

那么就可能发生一种情况:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据。

   2、可见性

     指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
       举个简单的例子,看下面这段代码:

//线程1执行的代码
int i = 0;
i = 10;

//线程2执行的代码
j = i;

       假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。
       此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.
     这就是可见性的问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。

   3、有序性

     即程序执行的顺序按照代码的先后顺序执行
       举个简单的例子,看下面这段代码:

int i = 0;             
boolean flag = false;
i = 1;                //语句1 
flag = true;          //语句2

       从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(InstructionReorder)

     指令重排序: 一般来说,处理器为了 提高程序运行效率,可能会对输入代码进行 优化,它 不保证 程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会 保证 程序最终执行结果和代码顺序执行的结果是一致的
       比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行,而语句1后执行

     但是要注意:虽然处理器会对指令进行重排序,靠什么保证结果不被影响?
       再看下面一个例子:

int a = 10;    //语句1
int r = 2;    //语句2
a = a + 3;    //语句3
r = a*a;     //语句4

       这段代码有4个语句,那么可能的一个执行顺序是:
在这里插入图片描述
       那么可不可能是这个执行顺序呢: 语句2 语句1 语句4 语句3
       不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction2之前执行。

     虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2

//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

       上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2;而此时线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法;而此时context并没有被初始化,就会导致程序出错。

     从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性

   也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确

   4、volatile关键字对并发编程的保证

     保证了并发编程的可见性和有序性;却无法保证原子性
       保证可见性,就是一个线程修改了共享变量,其他线程能够监听到
       保证有序性,就是其他线程重新获取共享变量时,肯定是新的数据值(store步骤时的lock锁)
     对于原子性,volatile关键字无法保证;故而需要synchronized锁机制

     1)无法保证原子性的解析

       当有多个线程都需要修改共享变量时,有可能出现原子操作中断的情况
       具体如下:
java 内存模型JMM解析_第6张图片
     其中,t.join() 方法是指:等待 t 线程执行完成,回并到主线程

       上述代码运行10个线程,每个线程都对num共享变量(用volatile修饰了)进行1000次的累加操作
       上述代码,如果满足原子性的话,最终结果应该是输出10000

     运行后,得到的结果:
java 内存模型JMM解析_第7张图片
       这只是其中一个结果,还有其他多种结果;但全部满足小于等于10000,说明程序有问题

     具体原因是:
java 内存模型JMM解析_第8张图片
       从上图中,可以看出;两个线程,明明执行了3次的累加操作,原本结果应该是3的;却由于线程二中第一次操作的中断(原子性未被保证),而丢失了一次操作数据,导致最终结果只有2

     2)synchronized锁机制保证原子性的解析

       原子性问题的源头线程切换(在多核CPU情况下,就是线程间的互相影响)
       (1)单核CPU与多核CPU的差异: 以 32 位 CPU 上执行 long 型变量的写操作为例,long 型变量是 64 位,在 32 位 CPU 上执行写操作会被拆分成两次写操作
         在单核 CPU 场景下:同一时刻只有一个线程执行,禁止 CPU 中断,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得 CPU 使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性
         在多核场景下:同一时刻,有可能有两个线程同时在执行,一个线程执行在 CPU-1 上,一个线程执行在 CPU-2 上,此时禁止 CPU 中断,只能保证 CPU 上的线程连续执行,并不能保证同一时刻只有一个线程执行,如果这两个线程同时写 long 型变量高 32 位的话,那就有可能出现Bug

       (2)单核CPU情况下(线程切换)的解决方案:
         如果能够禁用线程切换不就能解决这个问题了吗?而操作系统做线程的切换是依赖CPU中断的,所以禁用CPU发生中断就能够禁止线程切换

       (3)多核CPU情况下(线程间互相影响)的解决方案:
         保证同一时刻只有一个线程执行,称之为 互斥。如果我们能够保证对共享变量的修改是互斥的,那么无论是单核 CPU 还是多核 CPU,就都能保证原子性;这就可以使用锁机制
       如:将上述代码修改为:

public static synchronized void increase() {
	num++;
}


参考文献:并发编程的三个概念(原子性、可见性和有序性)存在的问题及其解决方案
     并发编程原子性解决方案-----互斥

你可能感兴趣的:(JMM,java,java内存模型)