JVM学习(五):JVM内存区域划分和内存模型

1.JVM内存区域划分

1.1.运行时数据区

根据之前的学习,我们先回顾下Java代码是如何在JVM中运行的,如下图:

img

由上图我们可以看到,Java代码(.class文件)运行的流程如下:

  • 通过编辑器编译为字节码文件
  • 通过JVM的类加载器加载,交由JVM执行(初始化等等,都是通过解释执行器或JIT即时编译器来执行)实际运行中,虚拟机会执行方法区内的代码,其中JVM在执行过程中用来存储数据的区域我们就叫做运行时数据区(Runtime Data Area),也就是常说的JVM内存

那么运行时数据区包括哪些部分呢?

  • 程序计数器(Program Counter Register)
  • Java栈(VM Stack)
  • 本地方法栈(Native Method Stack)
  • 方法区(Method Area)
  • 堆(Heap)

当然,上述的这些划分都是JVM虚拟机的规范,并且其都是抽象的概念,不管是堆还是栈或者计数器,其中的数据在操作系统层面而言可能会存储到CPU缓存、主内存(RAM)、寄存器都有可能,并且根据不同的虚拟器厂商,其实现方式也会存在区别,下文我们都将以Java7的JVM默认的虚拟机HotSpot为例进行讲解

img

1.1.1.程序计数器

线程私有,占用比较小的内存,用于指示当前线程所执行的字节码执行到了第几行,可以理解为当前线程的行号指示器。字节码解释器在工作的时候,会通过改变这个计数器的值来取下一条语句指令

存储内容:当前需要执行的虚拟机字节码指令地址,如果该方法是一个native方法,则值为undfine。由于只是存储当前指令地址,存储的数据所占空间的大小不会随程序的执行而发生改变。

异常:是JVM中唯一一个没有定义OutOfMemoryError的区域

1.1.2.虚拟机栈(JVM Stack)

Java栈是Java方法执行的内存模型

存储内容:栈帧,每个栈帧对应一个调用的方法。当线程执行一个方法时,就会创建一个栈帧,并将建立的栈帧压栈,方法执行完毕后,将栈帧出栈。因此线程当前执行方法在栈顶。每个线程都有一个,互不干扰

栈帧包含如下:

  • 局部变量表(Local Variables):存储方法中的局部变量(方法中的非静态变量和方法的形参),局部变量表的大小在编译器就可确定,因此大小不会发生改变。局部变量在执行完方法后就会回收,成员变量会从堆中拷贝引用存入变量表中,其实存入的就是this哦,也就是下文的Slot 0位,成员变量也是通过this引入,因为this存储了方法实例的引用。但是对于成员变量是所有线程共享的,因此在每个线程的工作空间内,在对共享变量进行操作时都会存有一份共享变量的拷贝(工作空间),并基于此拷贝进行修改,再写入主存

    成员变量,线程共享
    以基本数据类型或引用数据类型为成员变量,变量值都是在堆中存放的。

    (1)当声明的是基本类型的变量其变量名及其值放在堆内存中的

    (2)引用类型时,其声明的变量仍然会存储一个内存地址值,该内存地址值指向所引用的对象。引用变量名和对应的对象仍然存储在相应的堆中

    局部变量,线程私有

    • 基本数据类型,参数和参数值在Java虚拟机栈中存放的。
    • 引用数据类型,参数在Java虚拟机栈中存放的,参数值是对象实例,在堆中存放的。

    一个本地变量(Slot)可以存32位以内的数据,可以保存类型为 int, short, reference, byte, char, floath和returnAddress的数据,两个本地变量可以保存类型为long和double的数据;

    表中Slot的分配:

    • 局部变量表的第0位始终是this;
    • 方法参数(入参)
    • 方法内定义的局部变量,因作用域不同会有不同的情况

    image-20210508160003970

  • 操作数栈(Operand Stack):程序中的所有计算过程都是借助操作数栈完成,类似于栈计算表达式求值中的作用

  • 指向当前方法所属类的运行时常量池的引用(Reference to runtime constant pool):指向运行时常量池中该栈帧所属性方法的引用,

    为了支持方法调用过程中的动态连接。Class文件中存在大量的符号引用(因为暂时不知道其他类和方法的具体地址),字节码中的方法调用就以常量池中指向方法的符号作为参数,这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态链接(泛型)

  • 方法返回地址(Return Address):调用该方法的地址,用于方法执行完毕的返回

    退出时可能执行的操作:

    • 恢复上层方法局部变量表和操作数栈;
    • 如有有返回值,把该值压入调用者栈帧的操作数栈中;
    • 调整PC计数器的值以指向方法调用指令的下一条指令地址。
  • 额外的附加信息

image-20191230225433436

异常:如果线程调用的栈深度大于虚拟机允许的最大栈深度,则抛出StatckOverFlowError(栈溢出),如果直到内存不足,则会抛出 OutOfMemoryError(内存溢出)

1.1.3.本地方法栈

和虚拟机栈类似,区别只不过是Java栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的

1.1.4.堆

栈是线程私有的,而堆是所有线程共享的一块内存区域,在虚拟机启动时就会创建,并且也是GC(垃圾回收)的主要区域。原则上说,所有的对象和数组都会在堆上分配内存。

异常:如果在执行垃圾回收之后,仍没有足够的内存分配,也不能再扩展,将会抛出OutOfMemoryError:Java heap space异常

1.1.5.方法区

方法区是各个线程共享的区域,用于存储已经被虚拟机加载的类信息(类的名称、方法信息、字段信息)、final信息、静态变量、编译器即时编译的代码等

方法区可以选择是否执行垃圾回收机制,一般方法区上执行垃圾回收机制是很少的,所以在HotSpot中方法区又被称为永久代

运行时常量池:是方法区的一个部分,用于存储编译期间就产生字面常量、符号引用(链接阶段的解析)、翻译出来的直接引用(符号引用就是编码是用字符串表示某个变量、接口的位置,直接引用就是根据符号引用翻译出来的地址,将在类链接阶段完成翻译),除了存储编译期常量外,也存储在运行时产生的常量(例如String类的intern()方法,String维护了一个常量池,如果调用的字符“abc”已经在常量池中,则返回池中的字符串地址,否则,新建一个常量加入池中,并返回地址)

异常:在方法区上定义了OutOfMemoryError:PermGen space异常,在内存不足时抛出

1.1.6.直接内存

直接内存并不是JVM管理的内存,可以这样理解,直接内存,就是 JVM以外的机器内存,比如,你有4G的内存,JVM占用了1G,则其余的3G就是直接内存,JDK中有一种基于通道(Channel)和缓冲区 (Buffer)的内存分配方式,将由C语言实现的native函数库分配在直接内存中,用存储在JVM堆中的DirectByteBuffer来引用。 由于直接内存收到本机器内存的限制,所以也可能出现OutOfMemoryError的异常

2.Java内存模型

2.1.基本概念

Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,其中JMM规范了Java虚拟机和操作系统的内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值(可见性、重排序、happens-before),以及在必须时如何同步的访问共享变量(锁机制)

2.2.硬件内存架构

上面也说了,Java内存模型是一种规范,用于屏蔽不同操作系统的差异,可以看作一层抽象,因此我们先来了解下传统的硬件内存架构

img

  • 多CPU:现代计算机通常有多个CPU,其中CPU可能还要多个物理核、逻辑核,意味着存在多个线程同时运行的可能,也就是说Java程序多个线程可能同时(并发)运行

  • CPU寄存器:每个CPU都包含一系列的寄存器,CPU在寄存器上执行的速度远大于在主存上执行的速度,因为CPU访问寄存器的速度更快

  • 高速缓存Cache:为了解决计算机存储设备和CPU的运算速度之间的差距(这个差距很大,如果CPU每次计算都从内存中读取的话,会导致计算总是需要等待数据的存取),计算机系统加入了一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器中间的缓冲:将运算需要用到的数据复制入缓存,让运算能够快速运行,当结束后再同步回内存中。

    其中:CPU访问寄存器速度 > CPU访问缓存 > CPU访问内存

  • 运作原理:CPU需要读取主存时,会将主存的数据读到CPU缓存中,甚至可能将CPU缓存中的内容会读取到内部寄存器中,在寄存器中操作。当需要将结果写回主存时,CPU会将内部寄存器中的值刷新到缓存,然后再某个时间点刷新回主存

这样可能就会导致一些问题:

  • 缓存一致性问题

    多处理器系统中,每个处理器都有自己的高速缓存,但是共享一块主存。当多个处理器的运算任务都涉及到同一块主存内存区域时,将可能导致并发下的各种问题,例如同步回主存时,以谁的数据为准?此时为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写数据时根据协议来进行操作,例如MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等,java在很多地方都有用到这些一致性协议

    img

  • 指令重排序问题

    为了让处理器内部的运算单元得到充分利用,处理器可能会对代码进行乱序执行优化。当然这个优化是在保证执行的结果不会发生改变的前提下,但是不保证程序中各个语句的执行顺序和书写顺序一致,因此这样可能会导致,如果一个任务A依赖于任务B的一个中间结果,那么两个任务的执行顺序性并不能通过代码顺序来保证。JVM中的即时编译器(JIT)也会有类似的指令重排序

那么我们该如何解决上述的问题呢?因此我们就有了JMM的相关规范

2.3.Java内存模型和硬件内存架构之间的桥接

上文有提到,Java内存模型是为了屏蔽掉各种硬件和操作系统对于内存访问的差异,那么Java内存模型是如何操作的呢?

JVM的内存划分将内存划分为了线程栈、堆等。但是硬件架构并没有区分线程栈和堆,因此我们就能够明白其实线程栈、堆这些都是JVM虚拟机的抽象。对于硬件而言,所有线程栈和堆都分布在内存中,部分线程栈和堆可能有时候会出现在CPU缓存中和CPU内部的寄存器中。

img

从抽象角度来看,JMM定义了线程和主内存之间的抽象关系:

  • 线程之间的共享变量存储在主内存(Main Memory)中

  • 每个线程都有一个私有的本地内存(Local Memory),本地内存是JMM的一个抽象概念,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化(栈、本地方法区、计数器PC等)。本地内存中存储了该线程以读/写共享变量的拷贝副本。

  • 主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。

  • Java内存模型中的线程的工作内存(working memory)是cpu的寄存器和高速缓存的抽象描述。而JVM的静态内存储模型(JVM内存模型)只是一种对内存的物理划分而已,它只局限在内存,而且只局限在JVM的内存

    这里来去区分下线程的本地内存和工作内存:

    本地内存:每个线程都会有自己的本地内存,是线程私有的,包括的东西很多

    工作内存:可以理解为本地内存执行读/写共享变量拷贝就是在这里进行的,也是一个抽象概念,本地内存就包括了工作内存

img

2.4.JMM模型下的线程通信

线程间的通信必须要经过主内存。

例如有两个线程,他们之间通信的步骤如下:

  • 线程A把本地内存A中中更新过的变量刷新到主存中
  • 线程B到主存中去读取线程A之前已经更新过的共享变量

img

关于主内存和工作内存的具体交互协议,Java内存模型定义了下述八个操作:

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占的状态
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放,释放后的变量才可以被其他线程锁定
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,供后续的load操作
  • load(载入):作用于工作内存的变量,把read操作得到的变量值放入工作内存的变量副本中
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量值的字节码指令时将会使用该操作
  • assign(赋值):作用于工作内存的变量,把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个需要给变量赋值的字节码指令时,会执行这个操作(写场景)
  • store(存储):作用于工作内存的变量,把工作内存的变量值传送到主内存,以便随后的write操作
  • write(写入):作用于主内存的变量,把store操作从工作内存中读取的变量值传送到主内存的变量中

执行上述八种操作时,需满足如下规定:

  • 不可分割性:将变量从主内存复制到工作内存、工作内存写回主内存的两种操作read和load、store和write是不可分割的整体,不允许其中某一个操作单独出现,但是这里只是保证read和load、store和write是顺序执行,但是不保证连续执行,也就是说read一定在load操作前执行,但是不保证read和load紧挨着执行
  • 写操作不可丢弃性(写后同步):不允许一个线程丢弃他最新的assign的操作,即变量在工作内存中发生了改变之后必须同步到主内存中
  • 不可随意写回:不允许线程在没有发生写场景(assign)就将变量从工作内存同步回主内存
  • 初始化前提:新的变量只能在内存中诞生,不允许在工作内存中直接使用一个未被初始化(load、assign)的变量,也就是说变量在执行use和store之前,都需要进行load和assign操作
  • lock可重入性:一个变量在同一时刻只允许一个线程对其进行lock操作,但是该线程可对该变量重复执行lock操作,多次执行lock操作执行,需要执行同样数量的unlock操作,且两个操作需成对执行
  • lock重载变量:如果对一个变量执行lock操作,将清空工作内存中该变量值,在执行引擎(代码)中使用这个变量前需要重新执行load或assign操作初始化变量的值
  • unlock限制:一个变量事先未被执行lock操作,则无法对其进行unlock操作,且无法unlock操作其他线程的lock操作。执行unlock前需要将该变量从工作内存写回主内存中

2.5.Java内存模型解决的问题

遇到的问题:

  • 多线程下,如何处理多线程读同步问题和可见性(工作内存和指令重排序)
  • 多线程下,如何处理多线程写同步问题和原子性(多线程竞争race condition)

2.5.1.多线程读同步和可见性

可见性:当一个线程修改了共享变量的值,其他线程能够立刻得知这个修改,也就是说的时候读不到最新值

2.5.1.1.线程缓存导致的可见性问题

发生原因:当多个线程同时将一个共享变量读取到工作内存之后,其中一个线程对工作内存的该变量进行修改,只要工作内存中的该变量没有写回主内存,则其他线程对于该变量的拷贝就与修改线程的工作内存的该变量出现了数据不一致,就是说修改后的变量其他线程未得知这个修改

解决方法:下面的方法都能保证多线程场景下读到最新的值

  • volatile关键字:

    Java内存模型共享变量中普通变量和volatile变量读写的区别:

    普通变量-读:共享变量读取前将主内存中的共享变量读取到工作内存中,只有第一次也就是说当前工作内存无该共享变量且共享变量未失效时才会执行该操作,之后的读都会直接读取工作内存

    普通变量-写:变量修改后将新值同步回主内存,写操作不可丢弃性(写后同步)

    volatile变量-读:与普通变量一样

    voliatile变量-写:新值能立即同步到主内存,以及每个线程在每次使用volatile变量前都立即从主内存刷新,涉及到嗅探机制,在voliatile中会讲述。因此我们可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点

    voliatile解决线程缓存导致的可见性问题

    将代码编译后我们发现,volatile变量修饰的共享变量进行写操作编译后会带上一个lock addl $0×0,(%esp)-Lock前缀指令;

    Lock前缀指令在voliatile变量的写操作执行了两个操作:

    • 将当前处理器缓存行(工作内存)的数据写回到系统内存

    • 一个处理器的缓存回写到内存会导致其他处理器的缓存失效(所以其他线程读取voliatile变量时总能到最新的值)。因为处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。

      在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了

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

      为了实现volatile的内存语义,编译期在生成字节码时会对使用volatile关键字修饰的变量进行处理,在字节码文件里对应位置生成一个Lock前缀指令,Lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成

      volatile内存语义的底层实现原理——内存屏障,具体如何实现见:见下文2.5.1.3.重排序导致的可见性问题

  • synchronized关键字:

    表面上看是将共享变量的读写操作锁起来,只允许单线程访问就能保证可见性

    语义上看:lock操作会清空工作内存中的该变量,在读操作之前,需要重新执行load或assign操作初始化变量的值,unlock操作必须将该变量写回主内存

  • final关键字:被final关键字修饰的字段在构造器中一旦被初始化完成,且构造器没有将”this“引用传递出去,那么在其他线程就能看见final字段的值(无须同步)。也因为初始化后不允许写,因此自然不存在可见性的问题

    类的final字段(静态变量)的初始化在()方法中完成

    其可见性由JVM类加载过程保证

    final字段的初始化在()方法中完成

    可见性由sfence保证

    JVM在final变量后插入一个sfence,sfence禁用了sfence前后对store的重排序,并且保证了sfence之前的内存更新对sfence之后都是可见的

img

2.5.1.2.重排序

首先简单介绍下重排序:

重排序(指令序列)的重排序包括如下

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  • 指令级并行的重排序:现代处理器采用了指令级别并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序:由于处理器使用缓存和读写缓存区,使得加载load和存储store操作看上去可能是乱序执行

v2-a92ef160e8ba8d33541fb57b8a32de9c_1440w

img

数据依赖

编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序(这里指的是单线程场景下)

img

2.5.1.3.重排序导致的可见性问题

发生原因:对于变量,在代码中看起来可能是写后的读都会读取到最新值,但是在多线程场景下,可能会由于重排序从而导致变量读取到的值并非最新的值,所以这里我们说可读性遭到了破坏。

我们可以以下图为例子看下重排序对于可读性的影响:

image-20210509173408266

例如此时线程A先执行writer方法,线程B执行reader方法,我们知道如果线程B进入到了if循环,此时循环内的变量a的值一定是为1,而不是0,因为进入循环代表flag已经为true,也就是说操作1、操作2都执行完了,但是这只是对于看上去而言,那么发生了重排序之后呢?a的值还会为1吗,重排序看来,操作1和操作2并没有数据依赖的关系,因此操作2可以先于操作1执行,相应的,进入了循环后,就可能出现操作1没有执行的情况,此时读取到的变量a的值就可能为0,所以我们说重排序破坏了可见性,因为按照代码而言此时循环内读到的a肯定为修改后的值,但是却由于重排序读到了老值,也就是共享变量的修改线程B没有立即得知。

如何解决:

  • volatile关键字本身就包含了禁止指令重排序的语义,下文会讲述volatile是如何禁止重排序的语义的(内存屏障)
  • synchronized:一个变量在同一个时刻只允许一条线程对其进行lock操作,这个规则决定了持有同一个锁的两个同步块只能串行地进入

除此之外,还有两个规范会处理指令的重排序问题:

  • as-if-serial语义

    不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。(编译器、runtime和处理器都必须遵守as-if-serial语义)

  • happens before

    从JDK 5开始,Java使用新的JSR-133内存模型,JSR-133使用happens-before的概念来阐述操作之间的内存可见性:在JMM中,如果一个操作执行的结果需要对另一个操作可见(两个操作既可以是在一个线程之内,也可以是在不同线程之间),那么这两个操作之间必须要存在happens-before关系

    • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
    • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
    • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
    • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

    一个happens-before规则对应于一个或多个编译器和处理器重排序规则

  • 内存屏障禁止特定类型的处理器重排序

    对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成字节码指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence指令),通过内存屏障指令来禁止特定类型的处理器重排序

    硬件层的内存屏障分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障,内存屏障的作用有两个:

    • 阻止屏障两侧的的指令重排
    • 强制把高速缓存中的数据更新或者写入到主存中。Load Barrier负责更新高速缓存, Store Barrier负责将高速缓冲区的内容写回主存

    组合屏障
    LoadLoad,StoreStore,LoadStore,StoreLoad实际上是Java对上面两种屏障的组合,来完成一系列的屏障和数据同步功能:

    • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
    • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
    • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
    • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

    img

    StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)

    JMM针对编译器制定volatile重排序规则表

    image-20210509181151784

    • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
    • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
    • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序

    为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序

    下面是基于保守策略的JMM内存屏障插入策略:

    • 在每个volatile写操作的前面插入一个StoreStore屏障。

    • 在每个volatile写操作的后面插入一个StoreLoad屏障。

    • 在每个volatile读操作的后面插入一个LoadLoad屏障。

    • 在每个volatile读操作的后面插入一个LoadStore屏障。

      从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语义(内存可见性),这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止

      QQ截图20181207145109

    • StoreStore屏障可以保证在volatile写之前,所有的普通写操作已经对所有处理器可见,StoreStore屏障保障了在volatile写之前所有的普通写操作已经刷新到主存。

    • StoreLoad屏障避免volatile写与下面有可能出现的volatile读/写操作重排。因为编译器无法准确判断一个volatile写后面是否需要插入一个StoreLoad屏障(写之后直接就return了,这时其实没必要加StoreLoad屏障),为了能实现volatile的正确内存语意,JVM采取了保守的策略。在每个volatile写之后或每个volatile读之前加上一个StoreLoad屏障,而大多数场景是一个线程写volatile变量多个线程去读volatile变量,同一时刻读的线程数量其实远大于写的线程数量。选择在volatile写后面加入StoreLoad屏障将大大提升执行效率(上面已经说了StoreLoad屏障的开销是很大的)。

      QQ截图20181207145120

      • LoadLoad屏障保证了volatile读不会与下面的普通读发生重排
      • LoadStore屏障保证了volatile读不回与下面的普通写发生重排

      总结一下:

      下面是基于保守策略的JMM内存屏障插入策略保证了voliatile修饰的变量不会出现由于重排序从而导致的可见性问题,表面的实现是通过LOCK前缀指令,实际的实现是基于JMM的内存屏障插入策略,也就是上面所说的,上面的两张图可能会有点难于理解,其实就是在voliatile修饰的变量前后放入了不通的屏障来保障重排序,用到的屏障就是组合屏障,组合屏障其实就是单个屏障Load、Store的组合罢了,单个屏障能够保障屏障两次的指令不会重排序,那么单个屏障组合起来自然能够保障两个屏障操作Load和Store及其两边的操作不会重排序,关于上图也很好理解,对于写操作,自然是会用到Store操作,因此用到的组合屏障自然与Store相关,例如voliatile上面的写操作之前插入的屏障为StoreStore屏障,之后为StoreLoad屏障,可有看到两个屏障靠内的位置都是一个Store操作,voliatile写同理。

      那么为什么说voliatile不能用来做自增的操作呢?

      自增操作分为四步:load -> Increment -> Store -> StoreLoad Barrier(Lock 前缀指令的效果)

      虽然Lock前缀指令会无效化其他数据,并保证变量对其他线程是可见的,但是前面三个步骤是不安全的,例如线程A对变量进行自增执行到了Store操作,线程B此时也执行了自增操作且执行完成并写回的主内存,此时线程A再执行Store操作就会导致线程B的更新丢失

2.5.2.多线程写同步原子性

**原子性:**指一个操作是按原子的方式执行的。要么该操作不被执行;要么以原子方式执行,即执行过程中不会被其它线程中断

发生原因:想象一下,如果线程A读一个共享对象的变量count到它的CPU缓存中。再想象一下,线程B也做了同样的事情,但是往一个不同的CPU缓存中。现在线程A将count加1,线程B也做了同样的事情。现在count已经被增加了两次,每个CPU缓存中一次。如果这些增加操作被顺序的执行,变量count应该被增加两次,然后原值+2被写回到主存中去。然而,两次增加都是在没有适当的同步下并发执行的。无论是线程A还是线程B将count修改后的版本写回到主存中取,修改后的值仅会被原值大1,尽管增加了两次:

解决措施:

  • 由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store、write,我们大致可以认为基本数据类型变量、引用类型变量、声明为volatile的任何类型变量的访问读写是具备原子性的(long和double的非原子性协定:对于64位的数据,如long和double)
  • 同步块技术。Java内存模型提供了lock和unlock操作来满足这种需求。虚拟机提供了字节码指令monitorenter和monitorexist来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步快——synchronized关键字

参考链接

https://www.cnblogs.com/hnrainll/p/3410042.html

https://www.cnblogs.com/mrhgw/p/10819234.html

https://blog.csdn.net/xtayfjpk/article/details/41924283?utm_source=tuicool&utm_medium=referral

https://www.itzhai.com/articles/how-java-runtime-data-area-works.html

# JVM 

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×