JMM

183 阅读16分钟

领域的关键问题

  • 线程之间的通信

    线程之间的通信 是指线程间以何种机制来交换信息,在编程中有两种机制为「共享内存」和「消息传度」

    • 在共享内存的模型里面,线程之间共享程序的公共状态,线程间通过读些程序中的公共状态来实现隐士、式的通行,典型的共享内存的方式就是共享对象
    • 在消息传递的模型里面,线程间没有共享状态,线程之间必须通过明确的发送消息来实现显示的通信,在java中典型的就是wait()和notify()
  • 线程之间的同步

    同步是指程序用于控制不同线程之间的操作发生的相对的先后顺序的机制

    • 在共享内存模型里买,同步是显示进行的,程序员必须显示的指定某段代码或者某个方法与需要在线程之间互斥执行
    • 在消息传递的模型里面,由于消息的发送必须在消息的接受之前,因此同步是隐式进行的

    JMM

    现代计算机的内存模型

image-20200508163536343

如上图所示:由于cpu的数独远远大于来内存的速度,所以现代cpu都会有高速缓存这样的一个存在,做一个缓冲区,将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,举例说明变量在多个CPU之间的共享。如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。

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

如下所示:

处理器A和处理器B按程序的顺序并行执行内存访问,最终可能得到x=y=0的结果。

处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到x=y=0的结果。 从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序为:A1→A2,但内存操作实际发生的顺序却是A2→A1

image-20200508163648734 image-20200508163708156

JMM(Java Memory Model)

JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

JMM图示

image-20200508164308982 image-20200508164316230

JMM对Java内存模型的实现

所有的原始类型(boolean, byte,short,char,int,long,float,double)的局部变量都保存在线程的栈中,对于他们的值在各个线程之间都是独立的。对于原始类型的局部变量,一个线程可以传递一个副本给另一个线程,但他们之间是无法共享的。

堆区包含了Java应用创建的所有的对象信息。不管对象是哪个线程创建的。包括原始类的封装类(Integer Long等等),不管对象是成员变量还是局部变量,他都会被存储在堆里面。

一个局部变量如果是原始类型,那么他会被完全储存到栈区,一个局部变量也不可能是一个对象的引用,在这种情况下,这个引用会被储存到本地栈中,但是对象本身还是存储在堆中。对于一个对象的成员方法,这些方法中包含局部变量,仍需要存储在栈区,即使他们所属的对象是存储在堆中的。对于一个对象的成员变量不管是原始类型还是包装类型,都是存储在堆区的。static相关的变量以及类本身相关的信息是存在堆中的。

截屏2020-05-08 下午4.48.14

Java内存模型带来的问题

  • 可见性问题

    image-20200509085534967
    • 可见性问题如上图所示:当左边的cpu将count改为2的时候还未刷新如主内存,右边的cpu又在读,导致了左边的操作对右边的不可见,我们可以使用volitale或者加锁来解决
    image-20200509085645569
    • 竞争问题如上图所示,当左边cpu将值改为+1,右边也将值改为+1,这样结果主内存中的值为2,如果是串行执行的话值为3,我们可以用synchronized来解决
  • 指令重排序

    除了共享内存和工作内存所带来的问题,还存在重拍序的问题,在执行的过程中,为了提高性能,编译器和处理器常常都会对指令做重排序

Java内存模型中的重排序

  • 重排序类型

    image-20200509091246465

    • 编译器优化重排序:编译器咋不改变单线程程序语义的情况下可以重新安排语句的执行顺序
    • 指令级并行重排序:现代处理器采用了指令级并行处理(IPL)来i将多条指令重叠执行,如果不存在数据依赖,处理器可以改变处理器对应指令的顺序。
    • 内存系统重排序:由于处理器使用缓存和读/写缓冲区,这使的加载和存储操作看上去是在乱序执行的
  • 重排序与依赖性

    • 数据依赖

      如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时者两个变量就存在数据依赖性,如下图,数据依赖呀分为下面三种情况,只要重拍这两个顺序,程序的结果就会被改变

      image-20200509091540838
    • 控制依赖

      image-20200509091708853

      flag变量是个标记,用来标识a是不被写入,在use中变量i依赖if(flag)的执行,这就叫控制依赖,如果3-4发生重拍,执行结果就存在问题

  • as-if-serial

    不管如何重排序,都必须保证代码在单线程的情况下运行正确,连单线程的情况都不能满足的话,更别说在多线程的并发情况了,所以就提出了一个as-if-serial

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

    image-20200509092745684

    如上图所示:1-3,2-3存在数据数据依赖,所以最终指令顺序3必须放在1,2之后,但是1-2没有数据依赖关系,可以重拍,as-if-serial语言使单线程无需担心重排序的干扰,也无需担心内存可见性的问题。

并发重排序带来的问题

image-20200509094628945

假设A先执行init方法,B执行use方法,线程B执行4的时候,是否能看到线程A在1对a的修改呢?不一定能够看到,

由于操作1和操作2没有数据依赖的关系,编译器和处理器可对这两个操作进行重排序,同样操作3,和操作4没有数据依赖关系,编译器和处理器也可对这两个操作进行重排序,有以下情况

若A先执行flag=true,然后B执行if(flag) int i= a*a;此时a = 0;那么i0,所以程序结果不一致

当操作3和操作4发生重排序的时候怎么办呢?

当代码中存在控制依赖的时候,会影响指令运行的并行度,所以编译器和处理器会使用「猜测」(Speculation)来克服对并行度的影响,在以上例子中,若先执行4,让后把结果临时放入一个名为重排序缓冲的硬件缓存中,当3判断为true的时候就把这个结果赋值给i,猜测执行实质上对操作3和4做了重排序,问题在于这时候,a的值还没被线程A赋值。在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

解决并发下的问题

  • 内存屏障

    java编译器 生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,从而让程序按照我门预想的流程区执行

    • 保证操作的执行顺序
    • 影响某些数据(或者某条指令的执行结果)的内存可见性

    编译器和CPU能够重拍指令顺序,保证最终相同的结果,尝试优化性能,插入一条内存屏障(memory barrier)会告诉cpu和编译器,不管什么指令都不能个这条memory barrier重排序。

    memor barrier做的另一件事就是强制刷出各CPU cache,如一个Write-barrier(写入屏障)将刷出在这条指令之前所有写入cache的数据,因此任何cpu上的任何线程都能读到最新数据。

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

    屏障类型 指令示例 说明
    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会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。
  • 临界区

    临界区的代码可以重排序(但JMM不允许临界区中的代码“溢出”到临界区外,那样会破坏监视器的语义),JMM在进入和退出临界区做了一些特别的处理,虽然线程A在临界区内做了重排序,但是由于监视器的互斥特性,这里线程B无法观察到线程A内部的排序,这种排序即提高了效率,由保证了结果的一致性。

image-20200509111304904

Happens-before

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

两个操作之间具有happens-before关系的哈,并不意味着一个操作必须要在另一个操作之后执行。happens-before仅仅要求前一个操作(执行结果)对后一个操作可见,且前一个操作顺序安排在后一个之前,

  • 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,且第一个操作的执行顺序在第二个操作之前(对于程序员而言)
  • 两个操作存在happens-before关系,并不意味着对于java平台而言必须按照happens-before指定的顺序来执行。如果重排序之后的执行结果与happens-before指定的执行顺序执行的结果一致,那么这种重排序是被允许的(对于编译器而言)

如下图所示:

image-20200509113731208

无需任何同步手段就能保证的:

  • 程序顺序规则:一个线程的任意线程都应该happens-before该线程后的任意操作
  • 监视锁规则:对一个锁的解锁应该happens-before随后对这个锁的加锁
  • volitale变量规则:一个volitale域的写应该happens-before后所有对与这个volitale域的读
  • 传递性:如果A happens-before B ,B happens-before C,那么A happens-beforeC
  • start()规则:若在线程A执行ThreadB.start(),哪个ThreadB.strat()应该happend-before线程B中的任意操作。
  • join()规则:如果线程A执行ThreadB.join()成功,那么B线程的任意操作都happens-before线程A中ThreadB.jion()的操作成功返回
  • 线程中断规则:对线程interrupt方法的调用happens-before被中断线程的代码检测中断事件的发生

Volitale的内存语义

可以把对volitale变量的单个读/写,看成同一个锁对这歌单步读/写加锁

image-20200509120436513 image-20200509120428526

volitale本身具有下列的特新:

  • 可见性:对一个volitale变量的读,总是能看到(任意线程)对这个变量的最后写入
  • 原子性:读任意单个volitale变量的读和写具有原子性,但是类似于volitale++这样的复合操作不具有原子性

下面是volitale读与写的内存语义

volatile写的内存语义如下:

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。

image-20200509120835671

当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

image-20200509120914493

volatile重排序规则表

第一个操作 第二个操作
普通读/写 volatile读 volatile写
普通读/写 不允许
volatile读 不允许 不允许 不允许
volatile写 不允许 不允许

JMM内存语义的实现

  • 每个volitale写之前都会插入一个StoreStore屏障,之后都会插入StoreLoad

    image-20200509121326667
  • 每个volitale读之后都会插入一个LoadLoad和一个LoadStore

    image-20200509121606610

锁的内存语义

  • 当前线程释放锁,JMM把该线程对应的本地内存刷新会主内存中
  • 当线程获取锁时,JMM会把该线程对应的本地内存设置为无效,从而使得监视器保护的临界区代码必须从主内存中读取共享变量

image-20200509135857941

final的内存语义

编译器与处理器遵循的两个重排序原则

  • 在构造函数中对一个final域的写入,与随后把这个构造对象的引用赋值给一个引用变量,这两个操作不能重排序
  • 初次读一个包含final域的对象的引用,与随后初次读这个final域,不能呢重排序。

final域为引用类型:

  • 增加了一下规则,在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造的对象的引用赋值给一个引用类型的变量,这两者之间不能重排序

final语义在内存中的实现

  • 要求编译器在finak与的写入之后,构造函数写之前插入一个StoreStore的内存屏障
  • 读final的重排序要求编译器在读之前插入一个LoadLoad屏障

volitale实现的原理

  • 将当前缓存的数据写入系统内存
  • 写入操作会导致其他CPU里买该变量的缓存的内存地址失效

syschronized实现的原理

使用moniterexter和moniterexit来实现的

  • moninterEnter指令在在编译后插入同步块开始的位置,而moniterExit指令插入同步快结束或者异常的位置
  • 每个moniterEnter和moniterEnter之间要求配对
  • 任何对象都有一个moniter与之关联,当一个moniter被持有后,它将处于锁定状态

锁的存放位置:

截屏2020-05-09 下午2.13.33

了解各种锁

锁的状态:

  • 无状态

  • 偏向锁:大多数情况下,不仅不存在线程间的竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低引入偏向锁,无竞争时不需要CAS来加锁和解锁

  • 轻量级锁:cas

  • 重量级锁:lock

image-20200509141541787