0
点赞
收藏
分享

微信扫一扫

java内存模型(JMM)解刨

JMM简介

Java的内存模型JMM(Java Memory
Model)JMM主要是为了规定了线程和内存之间的一些关系。根据JMM的设计,系统存在一个主内存(Main
Memory),Java中所有实例变量都储存在主存中,对于所有线程都是共享的。每条线程都有自己的工作内存(Working
Memory),工作内存由缓存和堆栈两部分组成,缓存中保存的是主存中变量的拷贝,缓存可能并不总和主存同步,也就是缓存中变量的修改可能没有立刻写到主存中;堆栈中保存的是线程的局部变量,线程之间无法相互直接访问堆栈中的变量

JMM是什么

JMM (Java Memory Model)是Java内存模型,JMM定义了程序中各个共享变量的访问规则,即在虚拟机中将变量存储到内存和从内存读取变量这样的底层细节.

为什么要设计JMM

屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果.

为什么要理解JMM

理解JMM是理解并发问题的基础.
主内存,工作内存和线程三者的交互关系
JMM规定了共享变量都存储在主内存中.每条线程还有自己的工作内存,线程的工作内存保存了主内存的副本拷贝,对变量的操作在工作内存中进行,不能直接操作主内存中的变量.不同线程间无法直接访问对方的工作内存变量,需要通过主内存完成。如下图:


JMM的基本概念

包括“并发、同步、主内存、本地内存、重排序、内存屏障、happens before规则、as-if-serial规则、数据依赖性、顺序一致性模型、JMM的含义和意义”。下面一一讲解 搞懂我们写的代码到底是怎么工作的

1、并发

定义:即,并发(同时)发生。在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。

并发需要处理两个关键问题:线程之间如何通信线程之间如何同步。

(01) 通信 —— 是指线程之间如何交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。

(02) 同步—— 是指程序用于控制不同线程之间操作发生相对顺序的机制。在Java中,可以通过volatile,synchronized, 锁等方式实现同步。

  • 1.1并发编程中的三个概念

    • 1.1.1 原子性

举个例子:

i = 0;       //1
j = i ;      //2
i++;         //3
i = j + 1;   //4
  • 1.1.2可见性

举个例子:

//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
  • 1.1.3有序性

即程序执行的顺序按照代码的先后顺序执行:

//线程1执行的代码
int i = 0;              
boolean flag = false;
i = 1;                //语句1  
flag = true;          //语句2

2、主内存和本地内存

主内存 —— 即main memory。在java中,实例域、静态域和数组元素是线程之间共享的数据,它们存储在主内存中。

本地内存 —— 即local memory。 局部变量,方法定义参数 和 异常处理器参数是不会在线程之间共享的,它们存储在线程的本地内存中。

  • 2.1、 主内存与工作内存交互协议

  • 2.1.1内存间交互操作
  • lock(锁定)
    作用于主内存中的变量,它将一个变量标志为一个线程独占的状态。

  • unlock(解锁)
    作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。

  • read(读取)
    作用于主内存中的变量,它把一个变量的值从主内存中传递到工作内存,以便进行下一步的load操作。

  • load(载入)
    作用于工作内存中的变量,它把read操作传递来的变量值放到工作内存中的变量副本中。

  • use(使用)
    作用于工作内存中的变量,这个操作把变量副本中的值传递给执行引擎。当执行需要使用到变量值的字节码指令的时候就会执行这个操作。

  • assign(赋值)
    作用于工作内存中的变量,接收执行引擎传递过来的值,将其赋给工作内存中的变量。当执行赋值的字节码指令的时候就会执行这个操作。

  • store(存储)
    作用于工作内存中的变量,它把工作内存中的值传递到主内存中来,以便进行下一步write操作。

  • write(写入)
    作用于主内存中的变量,它把store传递过来的值放到主内存的变量中。

  • 2.1.2JMM对交互指令的约束
  • 不允许read和load、store和write操作之一单独出现

  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。

  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。

  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。

  • 一个变量在同一时刻只允许一条线程对其进行lock操作,lock和unlock必须成对出现

  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值

  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。

  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。
    具体执行流程图:


3、 重排序

计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下3种

  • 编译器优化的重排
  • 指令并行的重排
  • 内存系统的重排

流程图如下:


举个例子 下面代码执行顺序是什么?

int  x =1; //语句1
int  y=2; //语句2

下面写一段代码来验证下重排序。这段代码绝对是看完理解绝对能爽5天 。

/**
 * @author hongwang.zhang
 * @version: 1.0
 * @date 2018/7/3118:25
 * @see
 **/
public class jmmReorder {

          private static int x = 0, y = 0;
          private static int a = 0, b =0;

          public static void main(String[] args) throws InterruptedException {
               int i = 0;
               for(;;) {
                    i++;
                    x = 0; y = 0;
                    a = 0; b = 0;
                    Thread one = new Thread(new Runnable() {
                         public void run() {
                              //由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
                              shortWait(100000);
                              a = 1;
                              x = b;
                         }
                    });

                    Thread other = new Thread(new Runnable() {
                         public void run() {
                              b = 1;
                              y = a;
                         }
                    });
                    one.start();other.start();
                    one.join();other.join();
                    String result = "第" + i + "次 (" + x + "," + y + ")";
                    if(x == 0 && y == 0) {
                         System.err.println(result);
                         break;
                    } else {
                         System.out.println(result);
                    }
               }
          }


          public static void shortWait(long interval){
               long start = System.nanoTime();
               long end;
               do{
                    end = System.nanoTime();
               }while(start + interval >= end);
          }
     }

出道题考考大家 以上代码可能执行的结果是:
A、1,0
B、0,1
C、1,1
D、0,0
E、以上均有可能

答案:69 请参考ascll编码十进制寻找答案

如果你了解了重排序 那么就再来一份代码告诉C等于几

int a = 1;
int b = 2;
int c = a + b;

答案: C=3

3、1数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:

名称 代码示例 说明
写后读 a = 1;b = a; 写一个变量之后,再读这个位置。
写后写 a = 1;a = 2; 写一个变量之后,再写这个变量。
读后写 a = b;b = 1; 读一个变量之后,再写这个变量。

总结: 为了提高程序的并发度,从而提高性能!但是对于多线程程序,重排序可能会导致程序执行的结果不是我们需要的结果!因此,在多线程环境下就需要我们通过“volatile,synchronize,锁等方式”作出正确的实现同步,因为单线程遵循as-if-serial语义

4、as-if-serial语义

as-if-serial语义:
所有的动作都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。Java编译器、运行时和处理器都会保证Java在单线程下遵循as-if-serial语义。

为了具体说明,请看下面计算圆面积的代码示例:

double pi  = 3.14;    //A
double r   = 1.0;     //B
double area = pi * r * r; //C 

上面三个操作的数据依赖关系如下图所示:


了解后as-if-serial语义后如下代码 X等于几

public class Reordering {
    public static void main(String[] args) {
        int x, y;
        x = 1;
        try {
            x = 2;
            y = 0 / 0;    
        } catch (Exception e) {
        } finally {
            System.out.println("x = " + x);
        }
    }
}

答案是2

总结: as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

5、 happens-before规则

常见的满足happens- before原则的语法现象:

  • 对象加锁:对一个监视器锁的解锁,happens-before 于随后对这个监视器锁的加锁。
  • volatile变量:对一个volatile域的写,happens-before 于任意后续对这个volatile域的读。

在java语言中大概有8大happens-before原则,分别如下:

  • 程序次序规则(Pragram Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环结构。
int a = 3; //1
int b = a + 3; //2
int a = 3;
int b = 4;

两个语句直接没有依赖关系,所以指令重排序可能发生,即对b的赋值可能先于对a的赋值。

  • 监视器规则(Monitor Lock Rule):
  • volatile变量规则(Volatile Variable Rule):
  • 线程启动规则(Thread Start Rule) :
  • 线程终于规则(Thread Termination Rule) :
  • 线程中断规则(Thread Interruption Rule) :
  • 对象终结规则(Finalizer Rule):
  • 传递性(Transitivity):
案例分析:
package com.test.volatiles;

import java.util.Vector;

/**
 * @author hongwang.zhang
 * @version: 1.0
 * @date 2018/8/215:03
 * @see
 **/
public class Test2 {
     private static Vector<Integer> vector = new Vector<Integer>();
     public static void main(String[] args) {
          while (true) {
               for (int i = 0; i < 10; i++) {
                    vector.add(i);
               }
               Thread removeThread = new Thread(new Runnable() {
                    public void run() {
                         for (int i = 0; i < vector.size(); i++) {
                              vector.remove(i);
                         }
                    }
               });
               Thread getThread = new Thread(new Runnable() {
                    public void run() {
                         for (int i = 0; i < vector.size(); i++) {
//          尝试加入首先判断i是否在vector size范围内,结果同样报错,
//              if (i < vector.size()) {
//                  continue;
//              }
                              vector.get(i);
                         }
                    }
               });
               removeThread.start();
               getThread.start();
               //不要同时产生过多的线程,否则会导致操作系统假死
               while (Thread.activeCount() > 20) ;
          }
     }
}

程序次序:不满足,remove(i)与get(i)在控制流顺序没有先行发生关系;
管程锁定:不满足,remove(i)与get(i)方法都是synchronized修饰,但各自持有不同的锁,不满足管程锁定要求的同一个锁;
volatile变量:不满足,没有volatile修饰变量,无视;
线程启动:不满足,removeThread.start()先与vector.remove(i),getThread.start()先于vector.get(i),但后两者明显没有关系;
线程终止:不满足;
线程中断:不满足;
对象终结:不满足,不存在对象终结的关系;
传递性:不满足,加入size()验证作为参考,假定A是remove(),B是size()验证,C是get(),B先于C,但A可能介乎于BC之间,也可能在B之前。因此不符合传递性。
结论:Vector作为相对线程安全对象,其单个方法带Synchronized修饰,是相对线程安全的,但Vector方法之间不是线程安全的,不能保证多个方法作用下的数据一致性。执行例子get()会报错:java.lang.ArrayIndexOutOfBoundsException。

时间上的先后顺序”与“先行发生”之间有什么不同:

private int value=0;
pubilc void setValue(int value){
    this.value=value;
} public int getValue(){
    return value;
}

通过上面的例子,我们可以得出结论:一个操作“时间上的先发生”不代表这个操作会是“先行发生”,那如果一个操作“先行发生”是否就能推导出这个操作必定是“时间上的先发生”呢?很遗憾,这个推论也是不成立的,一个典型的例子就是多次提到的“指令重排序”,演示例子如下代码所示:

//以下操作在同一个线程中执行
int i=1;
int j=2;

以上代码的两条赋值语句在同一个线程之中,根据程序次序规则,“int i=1”的操作先行发生于“int j=2”,但是“int j=2”的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性,因为我们在这条线程之中没有办法感知到这点。
上面两个例子综合起来证明了一个结论:时间先后顺序与先行发生原则之间基本没有太大的关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。

6、内存屏障

下面是常见处理器允许的重排序类型的列表
上面我们说了处理器会发生指令重排,现在来简单的看看常见处理器允许的重排规则,换言之就是处理器可以对那些指令进行顺序调整:

处理器 Load-Load Load-Store Store-Store Store-Load 数据依赖
x86 N N N Y N
PowerPC Y Y Y Y N
ia64 Y Y Y Y N

先简单了解两个指令:

  • Store:将处理器缓存的数据刷新到内存中。
  • Load:将内存存储的数据拷贝到处理器的缓存中。

表格中的Y表示前后两个操作允许重排,N则表示不允许重排.与这些规则对应是的禁止重排的内存屏障.

注意:处理器和编译都会遵循数据依赖性,不会改变存在数据依赖关系的两个操作的顺序.所谓的数据依赖性就是如果两个操作访问同一个变量,且这两个操作中有一个是写操作,那么久可以称这两个操作存在数据依赖性.举个简单例子:

a=100;//write
b=a;//read

或者
a=100;//write
a=2000;//write
或者
a=b;//read
b=12;//write

以上所示的,两个操作之间不能发生重排,这是处理器和编译所必须遵循的.当然这里指的是发生在单个处理器或单个线程中.

内存屏障的分类

屏障类型 指令市里 说明
LoadLoad Barriers Load1; LoadLoad; Load2 确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。
StoreStore Barriers Store1; StoreStore; Store2 确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。
LoadStore Barriers Load1; LoadStore; Store2 确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。
StoreLoad Barriers Store1; StoreLoad; Load2 确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

StoreLoad Barriers同时具备其他三个屏障的效果,因此也称之为全能屏障(mfence),是目前大多数处理器所支持的;但是相对其他屏障,该屏障的开销相对昂贵。

内存屏障对性能的影响(Performance Impact of Memory Barriers)

总结: 通过内存屏障可以禁止特定类型处理器的重排序,从而让程序按我们预想的流程去执行。


举报

相关推荐

0 条评论