欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

JMM(Java内存模型Java Memory Model)学习

程序员文章站 2022-05-04 15:06:40
...

JMM(Java内存模型Java Memory Model)是什么?

下面是我收集到的对它的一些定义和描述:

1, Java并发采用的共享内存模型:定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM。

2, 一种抽象的概念 :由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方成为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作空间,然后对变量进行操作,操作完成再将变量写回主内存。糙一点的说主内存就是内存条,而工作内存,它并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

3, 它描述的是一组规则或规范通过规范定制了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

是不是感觉就是一堆屁话,那我们可以先略过这一趴,先从应用入手,先来聊聊并发编程的一些东东。

1.线程之间的通信

首先,线程间通信的模型有两种:共享内存和消息传递

共享内存 : 顾名思义就是允许两个不相关的进程访问同一个逻辑内存,共享内存是两个正在运行的进程之间共享和传递数据的一种非常有效的方式。

消息传递 : ,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信,在java中典型的消息传递方式就是wait()和notify()。

JMM就是Java的并发采用的是共享内存模型

1.1 线程间同步

同步是指程序用于控制不同线程之间操作发生相对顺序的机制。这里我们只聊共享内存并发模型里的同步,也就是JMM关于同步规定:

1.线程解锁前,必须把共享变量的值刷新回主内存

2.线程加锁前,必须读取主内存的最新值到自己的工作内存

3.加锁解锁是同一把锁

聊到这里,我们好像已经对JMM有了一点认识,但还不深刻,不急,我们继续,这次我们从最底层开始。

2.计算机的内存模型

先贴张图:
JMM(Java内存模型Java Memory Model)学习
首先我们得知道,我们电脑的处理器在运行过程中,绝大多数情况是需要与内存交互的,而cpu的指令速度又远超内存的存取速度,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
JMM(Java内存模型Java Memory Model)学习
处理器与内存的速度矛盾解决了,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。
在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。
当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,举例说明变量在多个CPU之间的共享。如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。

该内存模型带来的问题 :
现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!

举个例子
JMM(Java内存模型Java Memory Model)学习
处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2)。

最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到x=y=0的结果。
JMM(Java内存模型Java Memory Model)学习
从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序为:A1→A2,但内存操作实际发生的顺序却是A2→A1。

3.Java内存模型(JMM)

终于聊到了这里!

再回头看之前那段话,Java内存模型(JMM)是什么?

JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。

它是一种抽象的概念 :由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方成为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作空间,然后对变量进行操作,操作完成再将变量写回主内存。糙一点的说主内存就是内存条,而工作内存,它并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

JMM(Java内存模型Java Memory Model)学习

3.1 JVM对Java内存模型的实现

Java虚拟机的内存结构分为线程共有和线程私有两部分:

线程共有:Java堆和方法区

线程私有:虚拟机栈、本地方法栈和程序计数器

JMM(Java内存模型Java Memory Model)学习

4.Java内存模型带来的问题

4.1 可见性问题
CPU中运行的线程从主存中拷贝共享对象obj到它的CPU缓存,把对象obj的count变量改为2。但这个变更对运行在右边CPU中的线程不可见,因为这个更改还没有flush到主存中:要解决共享对象可见性这个问题,我们可以使用java volatile关键字或者是加锁。
JMM(Java内存模型Java Memory Model)学习
4.2 竞争现象
线程A和线程B共享一个对象obj。假设线程A从主存读取Obj.count变量到自己的CPU缓存,同时,线程B也读取了Obj.count变量到它的CPU缓存,并且这两个线程都对Obj.count做了加1操作。此时,Obj.count加1操作被执行了两次,不过都在不同的CPU缓存中。如果这两个加1操作是串行执行的,那么Obj.count变量便会在原始值上加2,最终主存中的Obj.count的值会是3。然而下图中两个加1操作是并行的,不管是线程A还是线程B先flush计算结果到主存,最终主存中的Obj.count只会增加1次变成2,尽管一共有两次加1操作。 要解决上面的问题我们可以使用java synchronized代码块。
JMM(Java内存模型Java Memory Model)学习
4.3 Java内存模型中的重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。

重排序类型
JMM(Java内存模型Java Memory Model)学习

1,编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

2,指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

3,内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

重排序与依赖性

数据依赖性:

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列3种类型,这3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
JMM(Java内存模型Java Memory Model)学习
控制依赖性:

flag变量是个标记,用来标识变量a是否已被写入,在use方法中比变量i依赖if (flag)的判断,这里就叫控制依赖,如果发生了重排序,结果就不对了。

   public void use(){
        if(flag){//3
            int i = a * a;//4
        }
    }

as-if-serial :

不管如何重排序,都必须保证代码在单线程下的运行正确,连单线程下都无法正确,更不用讨论多线程并发的情况,所以就提出了一个as-if-serial的概念。

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。(强调一下,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。)但是,如果操作之间不存在数据依赖关系,这些操作依然可能被编译器和处理器重排序。

{
         int a = 1;//1
         int b = 2;//2
         a + b;//3
}

1和3之间存在数据依赖关系,同时2和3之间也存在数据依赖关系。因此在最终执行的指令序列中,3不能被重排序到1和2的前面(3排到1和2的前面,程序的结果将会被改变)。但1和2之间没有数据依赖关系,编译器和处理器可以重排序1和2之间的执行顺序。
asif-serial语义使单线程下无需担心重排序的干扰,也无需担心内存可见性问题。

并发下重排序带来的问题
JMM(Java内存模型Java Memory Model)学习
这里假设有两个线程A和B,A首先执行init ()方法,随后B线程接着执行use ()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入呢?

答案是:不一定能看到。

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;

同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作1和操作2重排序时,可能会产生什么效果?

操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还没有被线程A写入,这时就会发生错误!

当操作3和操作4重排序时会产生什么效果?

在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。

为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。

以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中。

当操作3的条件判断为真时,就把该计算结果写入变量i中。猜测执行实质上对操作3和4做了重排序,问题在于这时候,a的值还没被线程A赋值。

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

解决在并发下的问题

内存屏障——禁止重排序

Store:将处理器缓存的数据刷新到内存中。
Load:将内存存储的数据拷贝到处理器的缓存中。
JMM(Java内存模型Java Memory Model)学习
Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,从而让程序按我们预想的流程去执行。

1、保证特定操作的执行顺序。
2、影响某些数据(或则是某条指令的执行结果)的内存可见性。

编译器和CPU能够重排序指令,保证最终相同的结果,尝试优化性能。插入一条Memory Barrier会告诉编译器和CPU:不管什么指令都不能和这条Memory Barrier指令重排序。

Memory Barrier所做的另外一件事是强制刷出各种CPU cache,如一个Write-Barrier(写入屏障)将刷出所有在Barrier之前写入 cache 的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。

JMM把内存屏障指令分为4类,StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。

4.4 Happens-Before

用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系 。

两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second) 。

1,如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。(对程序员来说)

2,两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序是允许的(对编译器和处理器 来说)

在Java 规范提案中为让大家理解内存可见性的这个概念,提出了happens-before的概念来阐述操作之间的内存可见性。对应Java程序员来说,理解happens-before是理解JMM的关键。

JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。

因此,happens-before关系本质上和as-if-serial语义是一回事。as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。

JMM(Java内存模型Java Memory Model)学习
Happens-Before规则-无需任何同步手段就可以保证的

1,程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。

2,监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

3,volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

4,传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

5,start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。

6,join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回.

7 ,线程中断规则:对线程interrupt方法的调用happens-before于被中断线程的代码检测到中断事件的发生。

5.实现原理

说了这么多,JMM怎么实现呢?

JVM 中的内存方面 volatile,synchronize,atomic,lock 之类的。。。

下次再聊。。。。

相关标签: JMM JVM