Java基础: JVM(九) Java内存模型

Posted by ZhouJ000 on July 9, 2019

Java基础: JVM(一) JVM概述与字节码
Java基础: JVM(二) 常量池
Java基础: JVM(三) JVM执行引擎01
Java基础: JVM(四) Java栈帧
Java基础: JVM(五) JVM执行引擎02
Java基础: JVM(六) 类变量和类方法解析
Java基础: JVM(七) 类生命周期与类加载器
Java基础: JVM(八) 热加载
Java基础: JVM(九) Java内存模型
Java基础: JVM(十) 编译相关

Java内存模型

jmm

共享变量:如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量

可见性:一个线程对共享变量的修改,更够及时的被其他线程看到

原子性:整个操作要么全部完成,要么全部不完成,不可能停滞在中间某个状态

Java内存模型(JMM,Java Memory Model):描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取出变量这样的底层细节
1、所有的变量都存储在主内存中
2、每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝)
3、线程对共享变量的所有操作都必须在自己的工作内存,不能直接从相互内存中读写也不能从主内存中操作
4、线程间变量值得传递需要通过主内存来完成

线程模型
1、保证了线程间的操作最终都会可见,一个线程对一个字段的修改最终都会被另一个线程看见,但是这个最终会花费多久是不确定的
2、线程模型同样也允许了在没有使用同步的情况下,可见性不一致的情况
3、调用Thread.sleep()或者是其他一些可以让CPU闲下来的操作都可以使得最终可见性发生,比如涉及到内存分配或IO操作
*Thread.yield(),Thread.sleep(),Object.wait()都会让出CPU,其中yield让当前线程让出CPU后,当前线程依旧处于就绪状态,可以竞争CPU;sleep让当前线程休眠一定时间,时间到后重新进入就绪状态,且并不会释放锁资源;wait则让当前线程处于waiting状态,直到被notify或notifyAll唤醒进入就绪状态,wait主要作用是线程间通信,且只能在被Synchronized的代码块中调用

jmm2

重排序

重排序:指代码的顺序与实际执行的顺序不同,指令重排序是编译器或处理器为了提高程序性能而做的优化

其包括三种优化:
1、编译器优化的重排序(编译器优化)
2、指令级并行的重排序(处理器优化)
3、内存系统的重排序(处理器优化)

happens-before规则

如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间

原则定义
1、如果一个操作happens-before(先行发生于)另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前
2、两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法

相关规则
1、程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作(还会指令重排)
2、锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
3、volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
4、传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
5、线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
6、线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
7、线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
8、对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

as-if-serial语义

指的是无论如何重排序,程序执行的结果应该与代码顺序执行的结果一致(Java编译器和处理器都会保证Java在单线程下遵循as-if-serial语义)

重排序不会给单线程带来内存可见性的问题;而多线程中程序交错执行,重排序可能会造成内存可见性问题

参考:
java内存模型以及happens-before规则

CPU缓存一致性

计算机中的所有运算都在CPU的寄存器中完成,CPU指令的执行肯定涉及数据的读与写操作,CPU只能访问内存储器,然而随着CPU发展频率不断提升,而内存访问速度并没太大突破,因此CPU处理速度与内存访问速度的差距越来越大,由于这巨大的不对等,于是有了在CPU与内存之间增加缓存的设计

现在缓存可以加到3级,最靠近CPU的缓存成为L1,然后依次为L2、L3、主内存,又由于程序指令和程序数据的行为和热点分部差异很大,因此L1 Cache被划分为了L1i和L1d这两种专门用途的缓存。那么程序在运行时,会将运算所需要的数据从主存中复制一份到CPU Cache中,这样CPU可以直接对Cache中的数据进行读取和写入,当运算结束后再将最新数据刷新到主内存中,CPU里用这种缓存方式代替直接访问主存,大大提高了CPU的吞吐能力,但是同时也引入了缓存不一致的问题。比如2个线程同时执行一个i++操作,每个线程的本地内存都有一个副本,然后从主存读取i值存入CPU Cache中,然后计算后放入主存,可能出现经过2次自增后i为1的情况。为了解决多线程环境下的该问题,主流解决办法为:
1、通过总线加锁的方式(早年使用,悲观方式)
2、通过缓存一致性协议(MESI协议等)

cpu-cache cpu-cache2

Java内存可见性

导致共享变量在线程间不可见的原因:
1、多线程的交叉执行
2、重排序结合线程交叉执行
3、共享变量更新后的值没有在工作内存与主内存间及时更新(线程对共享变量的所有操作都必须在自己的工作内存中进行,不能从主内存中读写;而且不同线程之间无法直接访问其它线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成)

要实现共享变量的的可见性,就必须保证:
1、线程修改后的共享变量值能够及时从工作内存刷新到主内存中
2、其他线程能够及时把共享变量的最新值从主内存更新到自己的工作内存中
3*、保证对变量复合操作的原子性

synchronized

synchronized不仅能通过互斥锁来实现同步保证复合操作的原子性,而且还能够实现可见性。Java内存模型关于Synchronized有两条规定:
1、线程释放锁之前,JMM会将工作内存中的共享变量刷新到主内存中
2、线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值

因此线程执行同步代码的过程:
1、获取监视器锁(互斥锁)
2、清空工作内存
3、从主内存中拷贝变量的最新副本到工作内存
4、执行代码
5、将更改后的共享变量的值刷新到主内存
6、释放监视器锁(互斥锁)
*如果某个任务处于一个对标记为synchronized的方法的调用中,那么在这个线程从该方法返回之前,其它所有要调用类中任何标记为synchronized方法的线程都会被阻塞。

volatile

volatile通过加入内存屏障禁止指令重排序优化来实现的:
1、对volatile变量执行写操作时,会在写操作后加入一条store屏障指令,这样就会把读写时的数据缓存加载到主内存中
2、对volatile变量执行读操作时,会在读操作前加入一条load屏障指令,这样就会从主内存中加载变量
所以volatile变量在每次被线程访问时,都强迫从主内存中重读该变量的值,而当该变量发生变化时,就会强迫线程将最新的值刷新到主内存,这样任何时刻不同的线程总能看到该变量的最新值。

然而volatile关键字并不能保证变量复合操作的原子性,因此仍然可能导致共享变量的不可见,所以要在多线程中安全使用volatile变量,必须同时满足:
1、对变量的写入操作不依赖其当前值,比如num++(分为3个步骤)不满足,boolean满足
2、该变量没有包含在具有其他变量的不变式中

volatile与synchronized的比较:
1、volatile比synchronized更轻量级,不用加锁,不会阻塞线程
2、volatile没有synchronized使用的广泛
3、synchronized既能保证可见性,又能保证原子性;而volatile只能保证可见性,无法保证原子性。

final

Final变量在并发当中,原理是通过禁止cpu的指令集重排序,来提供现成的可见性,来保证对象的安全发布,防止对象引用被其他线程在对象被完全构造完成前拿到并使用

与锁和volatile相比较,对final域的读和写更像是普通的变量访问。对于final域,编译器和处理器要遵守两个重排序规则
1、在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序
2、初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序

final关键字在JMM中也有特殊的语义,即定义为final的变量能够被保证在所属对象构建结束后被完全创建。而创建一个对象的操作可以被拆分成两步:
1、创建一个对象。包括堆内存分配,变量初始化等
2、对象引用赋值。赋值后,对象就能通过引用被调用
正常逻辑应该是在1执行完之后再执行2,但由于多线程中表现出来的指令重排现象,在其他线程看来可能出现2先于1执行完毕的情况。由于2先执行完毕,对象已经处于可以被使用的状态,此时就会有未被完全创建的对象被使用的风险,从而产生错误的结果。一般final用于不可变变量的安全发布(初始化),而volatile可用于安全发布不可变变量,也可提供可变变量的可见性

内存屏障

为了实现volatile内存语义,JMM会分别限制编译器重排序和处理器重排序 mf

内存屏障(Memory Barrier,内存栅栏/Memory Fence):是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序

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

为了实现JSR-133的规定,Java编译器会这样使用内存屏障: mf2

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。JMM基于保守策略的JMM内存屏障插入策略:
1、在每个volatile写操作的前面插入一个StoreStore屏障
2、在每个volatile写操作的后面插入一个SotreLoad屏障
3、在每个volatile读操作的后面插入一个LoadLoad屏障
4、在每个volatile读操作的后面插入一个LoadStore屏障

x86处理器仅仅会对写-读操作做重排序,因此会省略掉读-读、读-写和写-写操作做重排序的内存屏障。在x86中,JMM仅需在volatile后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在x86处理器中,volatile写的开销比volatile读的大,因为StoreLoad屏障开销比较大

扩展:
Java内存模型的深入理解
深入理解Java内存模型(一)——基础
深入理解Java内存模型(二)——重排序
深入理解Java内存模型(三)——顺序一致性
深入理解Java内存模型(四)——volatile
深入理解Java内存模型(五)——锁
深入理解Java内存模型(六)——final
深入理解Java内存模型(七)——总结