JUC(3)Java内存模型JMM

2021/7/29 7:05:52

本文主要是介绍JUC(3)Java内存模型JMM,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

因为CPU的缓存导致CPU的速度比物理主内存的速度快很多,CPU的运行并不是直接操作内存,而是先把内存里边的数据读到缓存,而内存的读和写操作的时候就会造成不一致的问题。

Java虚拟机规范中试图定义一种Java内存模型(java Memory Model,简称JMM) 来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。本身是一种抽象的概念,实际上并不存在,它仅仅描述的是一组规则或规范,通过这组规范定义了程序中各个变量的访问方式。(这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题)

JMM关于同步的规定:

  • 线程解锁前,必须把共享变量的值刷新回主内存
  • 线程加锁前,必须读取主内存的最新值,到自己的工作内存
  • 加锁和解锁是同一把锁

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写会主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程:

上图提到了两个概念:主内存 和 工作内存

  • 主内存:就是计算机的物理内存。
  • 工作内存:存放在CPU LN缓存,也就是CPU寄存器。如下图我们实例化 new student,那么 age = 25 也是存储在主内存中
    • 当同时有三个线程同时访问 student中的age变量时,那么每个线程都会拷贝一份到各自的工作内存,从而实现了变量的拷贝

即:JMM内存模型的可见性,指的是当主内存区域中的值被某个线程写入更改后,其它线程会马上知晓更改后的值,并重新得到更改后的值。如果没有可见性就会导致脏读的现象:

JMM数据同步的八大原子操作

一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成

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

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

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

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

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

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

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

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

    如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行

public class CodeVisibility {
    private static boolean initFlag = false;
    private volatile static int counter = 0;

    public static void refresh(){
        log.info("refresh data.......");
        initFlag = true;
        log.info("refresh data success.......");
    }

    public static void main(String[] args){
        Thread threadA = new Thread(()->{
            while (!initFlag){
                //System.out.println("runing");
                counter++;
            }
            log.info("线程:" + Thread.currentThread().getName() + "当前线程嗅探到initFlag的状态的改变");
        },"threadA");
        threadA.start();

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

        Thread threadB = new Thread(()->{
            refresh();
        },"threadB");
        threadB.start();
    }
}

小总结

  • 我们定义的所有共享变量都储存在物理主内存中
  • 每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝)
  • 线程对共享变量所有的操作都必须先在线程自己的工作内存中进行后写回主内存,不能直接从主内存中读写(不能越级)
  • 不同线程之间也无法直接访问其他线程的工作内存中的变量,线程间变量值的传递需要通过主内存来进行(同级不能相互访问)

缓存一致性

为什么这里主线程中某个值被更改后,其它线程能马上知晓呢?其实这里是用到了总线嗅探技术

在说嗅探技术之前,首先谈谈缓存一致性的问题,就是当多个处理器运算任务都涉及到同一块主内存区域的时候,将可能导致各自的缓存数据不一。

为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,这类协议主要有MSI、MESI等等。

MESI

当CPU写数据时,如果发现操作的变量是共享变量,即在其它CPU中也存在该变量的副本,会发出信号通知其它CPU将该内存变量的缓存行设置为无效,因此当其它CPU读取这个变量的时,发现自己缓存该变量的缓存行是无效的,那么它就会从内存中重新读取。

总线嗅探

那么是如何发现数据是否失效呢?

这里是用到了总线嗅探技术,就是每个处理器通过嗅探在总线上传播的数据来检查自己缓存值是否过期了,当处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从内存中把数据读取到处理器缓存中。

总线风暴

总线嗅探技术有哪些缺点?

由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和CAS循环,无效的交互会导致总线带宽达到峰值。因此不要大量使用volatile关键字,至于什么时候使用volatile、什么时候用锁以及Syschonized都是需要根据实际场景的。

JMM的特性

  • 可见性

    当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更 ,JMM规定了所有的变量都存储在主内存中。

  • 原子性

    一个操作是不可中断的,即多线程环境下,操作不能被其他线程干扰

  • 有序性

    对于一个线程的执行代码而言,我们总是习惯性认为代码的执行总是从上到下,有序执行。但为了提供性能,编译器和处理器通常会对指令序列进行重新排序。指令重排可以保证串行语义一致,但没有义务保证多线程间的语义也一致,即可能产生脏读,简单说,两行以上不相干的代码在执行的时候有可能先执行的不是第一条,不见得是从上到下顺序执行,执行顺序会被优化。
    ![image-20210728213838507](/Users/zorantaylor/Library/Application Support/typora-user-images/image-20210728213838507.png)

指令重排序

单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。

处理器在进行重排序时必须要考虑指令之间的*数据依赖性*

多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测

指令重排 - example 1

public void mySort() {
	int x = 11;//1
	int y = 12;//2
	x = x + 5;//3
	y = x * x;//4
}

按照正常单线程环境,执行顺序是 1 2 3 4

但是在多线程环境下,可能出现以下的顺序:

  • 2 1 3 4
  • 1 3 2 4

上述的过程就可以当做是指令的重排,即内部执行顺序,和我们的代码顺序不一样

但是指令重排也是有限制的,即不会出现下面的顺序

  • 4 3 2 1

因为处理器在进行重排时候,必须考虑到指令之间的数据依赖性

例子

int a,b,x,y = 0

线程1 线程2
x = a; y = b;
b = 1; a = 2;
x = 0; y = 0

因为上面的代码,不存在数据的依赖性,因此编译器可能对数据进行重排

线程1 线程2
b = 1; a = 2;
x = a; y = b;
x = 2; y = 1

这样造成的结果,和最开始的就不一致了,这就是导致重排后,结果和最开始的不一样,因此为了防止这种结果出现,volatile就规定禁止指令重排,为了保证数据的一致性

指令重排 - example 2

比如下面这段代码

/**
 * ResortSeqDemo
 */
public class ResortSeqDemo {
    int a= 0;
    boolean flag = false;

    public void method01() {
        a = 1;//1
        flag = true;//2
    }

    public void method02() {
        if(flag) {
            a = a + 5;
            System.out.println("reValue:" + a);
        }
    }
}

我们按照正常的顺序,分别调用method01()和 method02(()那么,最终输出就是 a = 6

但是如果在多线程环境下,因为方法1 和 方法2,他们之间不能存在数据依赖的问题,因此原先的顺序可能是

a = 1;
flag = true;

a = a + 5;
System.out.println("reValue:" + a);
        

但是在经过编译器,指令,或者内存的重排后,可能会出现这样的情况

flag = true;//线程1

a = a + 5;//线程2
System.out.println("reValue:" + a);//线程2

a = 1;//线程1

也就是先执行 flag = true后,另外一个线程马上调用方法2,满足 flag的判断,最终让a + 5,结果为5,这样同样出现了数据不一致的问题

为什么会出现这个结果:多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

happens-before

在JMM中, 如果一个操作执行的结果需要对另一个操作可见性 或者 指令重排序,那么这两个操作之间必须存在happens-before关系。

x = 5 线程A执行
y = x 线程B执行
上述称之为:写后读

y是否等于5呢?如果线程A的操作(x= 5)happens-before(先行发生)线程B的操作(y = x),那么可以确定线程B执行后y = 5 一定成立;

如果他们不存在happens-before原则,那么y = 5 不一定成立。这就是happens-before原则的威力。包含可见性和有序性的约束

如果Java内存模型中所有的有序性都仅靠volatile和synchronized来完成,那么有很多操作都将会变得非常啰嗦,但是我们在编写Java并发代码的时候并没有察觉到这一点。我们没有时时、处处、次次,添加volatile和synchronized来完成程序,这是因为Java语言中JMM原则下有一个“先行发生”(Happens-Before)的原则限制和规矩。这个原则非常重要: 它是判断数据是否存在竞争,线程是否安全的非常有用的手段。依赖这个原则,我们可以通过几条简单规则解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入Java内存模型苦涩难懂的底层编译原理之中。

总原则

  • 如果一个操作happens-before另一个操作,那么第一个操作的执行结果对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前(可见性,有序性)
  • 两个操作之间存在happens-before关系,并不意外着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法(可以指令重排)(1+2+3=3+2+1)

8条规则

  1. 次序规则
    一个线程内,按照代码顺序,写在前面的操作先行发生于写在后面的操作(强调的是一个线程),前一个操作的结果可以被后续的操作获取。说白了就是前面一个操作把变量X赋值为1,那后面一个操作肯定能知道X已经变成了1

  2. 锁定规则
    一个unlock操作先行发生于后面(这里的"后面"是指时间上的先后)对同一个锁的lock操作(上一个线程unlock了,下一个线程才能获取到锁,进行lock)

  3. volatile变量规则
    对一个volatile变量的写操作先行发生于后面对这个变量的读操作,前面的写对后面的读是可见的,这里的"后面"同样是指时间是的先后

  4. 传递规则
    如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出A先行发生于操作C

  5. 线程启动规则(Thread Start Rule)
    Thread对象的start( )方法先行发生于线程的每一个动作

  6. 线程中断规则(Thread Interruption Rule)

    对线程interrupt( )方法的调用先发生于被中断线程的代码检测到中断事件的发生

    可以通过Thread.interrupted( )检测到是否发生中断

  7. 线程终止规则(Thread Termination Rule)
    线程中的所有操作都先行发生于对此线程的终止检测

  8. 对象终结规则(Finalizer Rule)
    对象没有完成初始化之前,是不能调用finalized( )方法的

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

假设存在线程A和B,线程A先(时间上的先后)调用了setValue(1),然后线程B调用了同一个对象的getValue(),那么线程B收到的返回值是什么?我们就这段简单的代码分析happens-before的规则

  • 1 由于两个方法是由不同的线程调用,不在同一个线程中,所以肯定不满足程序次序规则;
  • 2 两个方法都没有使用锁,所以不满足锁定规则;
  • 3 变量不是用volatile修饰的,所以volatile变量规则不满足;
  • 4 传递规则肯定不满足;

所以我们无法通过happens-before原则推导出线程A happens-before线程B,虽然可以确认在时间上线程A优先于线程B指定,但就是无法确认线程B获得的结果是什么,所以这段代码不是线程安全的。那么怎么修复这段代码呢?

  • 把getter/setter方法都定义为synchronized方法
  • 把value定义为volatile变量,由于setter方法对value的修改不依赖value的原值,满足volatile关键字使用场景


这篇关于JUC(3)Java内存模型JMM的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程