Java内存模型 != JVM内存模型!!!!!!

一、引入

​ 由于计算机的存储设备与处理器的运算能力之间有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中没这样处理器就无需等待缓慢的内存读写了。
  基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主存,如下图所示:多个处理器运算任务都涉及同一块主存,需要一种协议可以保障数据的一致性,这类协议有MSI、MESI、MOSI及Dragon Protocol等。Java虚拟机内存模型中定义的内存访问操作与硬件的缓存访问操作是具有可比性的,后续将介绍Java内存模型。

二、Java内存模型(JMM)是什么

Java内存模型简称JMM(Java Memory Model),是Java虚拟机所定义的一种抽象规范,用来屏蔽不同硬件和操作系统的内存访问差异,让java程序在各种平台下都能达到一致的内存访问效果。

Java内存模型本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)(不包括局部变量与方法参数)的访问方式。

2.1 主内存和工作内存

注意:这里的主内存、工作内存与Java内存区域的Java堆、栈、方法区不是同一层次内存划分,这两者基本上没有关系,不要混淆。

  • 主内存(Main Memory):共享、类信息、常量、静态变量。
  • 本地内存(Working Memory)-工作内存:存主内存中数据的副本

2.2 内存间交互操作(八大指令)

指令 作用对象 作用
lock(锁定) 主内存的变量 把一个变量标识为一个线程独占状态
unlock(解锁) 主内存的变量 把锁定的变量释放出来,释放出来的变量才能被其他线程使用
read(读取) 主内存的变量 把一个变量的值从主内存传输到线程的工作内存,后续被load使用
load(载入) 工作内存的变量 把read操作得到的值放入到工作内存的变量副本中
eg:read是货车,工作内存是仓库,load把货车里东西放进仓库
use(使用) 工作内存的变量 把变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时,将会执行这个操作
assign(赋值) 工作内存的变量 把use中执行引擎操作的结果赋值给工作内存的变量
eg:use好比站点进行快递员分配,站点说我把快递分给你了快递员A。快递员A接收到快递 assign开始派送。
store(存储) 工作内存的变量 把工作内存的变量传送到主内存中,以便后面的write操作
write(写入) 主内存的变量 把store操作中的变量值传送到主内存的变量中
eg:store和 write就好理解了,快递员A将快递送到你家门口(store),然后你得签收(write)

这些行为具有原子性,在使用上相互依赖

  • read-load从主内存复制变量到当前工作内存,
  • use-assign执行代码改变共享变量值
  • store-write用工作内存数据刷新主存相关内容,指令顺序不能变
  • 上面操作指令顺序不能变,但是之间可以插入其他的指令,eg:read a,read b,load b, load a

2.3 指令规则

  1. 不允许read和load、store和write操作之一单独出现
  2. 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  3. 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  4. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  5. 一个变量在同一时刻只允许一条线程对其进行lock操作,lock和unlock必须成对出现
  6. 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  7. 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  8. 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

2.4 long和double型变量的特殊规则

long和double是64位的数据类型,如果没有被volatile修饰,每次操作划分为2次32位进行操作,原子性对于这个不起作用,如果多个线程操作这样的数据,会出现数据错误。

三、JMM存在的必要性

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据。

线程与主内存中的变量操作必须通过工作内存间接完成。主要过程是将变量从主内存拷贝到每个线程各自的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存。如果存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程安全问题。

如下图,主内存中存在一个共享变量x,现在有A和B两条线程分别对该变量x=1进行操作,A/B线程各自的工作内存中存在共享变量副本x。假设现在A线程想要修改x的值为2,而B线程却想要读取x的值,那么B线程读取到的值是A线程更新后的值2还是更新前的值1呢?

答案是,不确定。即B线程有可能读取到A线程更新前的值1,也有可能读取到A线程更新后的值2,这是因为工作内存是每个线程私有的数据区域,而线程A变量x时,首先是将变量从主内存拷贝到A线程的工作内存中,然后对变量进行操作,操作完成后再将变量x写回主内存,而对于B线程的也是类似的。这样就有可能造成主内存与工作内存间数据存在一致性问题。假如A线程修改完后正在将数据写回主内存,而B线程此时正在读取主内存,即将x=1拷贝到自己的工作内存中,这样B线程读取到的值就是x=1,但如果A线程已将x=2写回主内存后,B线程才开始读取的话,那么此时B线程读取到的就是x=2,但到底是哪种情况先发生呢?这是不确定的,这也就是所谓的线程安全问题。

为了解决类似上述的问题,JVM定义了一组规则,通过这组规则来决定一个线程对共享变量的写入何时对另一个线程可见,这组规则也称为Java内存模型(即JMM),JMM是围绕着程序执行的原子性、有序性、可见性展开的,下面我们看看这三个特性。

四、Java内存模型的三大特征(原子性、可见性、有序性)

4.1 原子性(synchronized具有原子性)

由Java内存模型来直接保证的原子性变量操作包括read、load、use、assign、store和write六个,大致可以认为基础数据类型的访问和读写是具备原子性的。如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock与unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐匿地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块—synchronized关键字,因此在synchronized块之间的操作也具备原子性。

原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。比如对于一个静态变量int x,两条线程同时对他赋值,线程A赋值为1,而线程B赋值为2,不管线程如何运行,最终x的值要么是1,要么是2,线程A和线程B间的操作是没有干扰的,这就是原子性操作,不可被中断的特点。

4.2 可见性(volatile、synchronized、final)

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

Java内存模型是通过在变量修改后将新值同步到主内存,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量不能保证这一点。

除了volatile之外,Java还有两个关键字能实现可见性,即synchronized和final。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store write操作)”这条规则获得的,而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把"this"的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见final字段的值。

工作内存与主内存同步延迟现象就造成了可见性问题。另外指令重排以及编译器优化也可能导致可见性问题。

4.3 有序性(volatile、synchronized)

Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

换句话说有序性是指对于单线程环境下代码执行的最终结果和按顺序依次执行的结果一致。但对于多线程环境,则可能出现乱序现象。因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致。

可能有点不太容易理解,有序性就是,不管哪个线程来看,操作都是有序的

五、指令重排序(volatile、synchronized、有序性有关)

5.1 基本概念:

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序(简单理解就是原本我们写的代码指令执行顺序应该是A→B→C,但是现在的CPU都是多核CPU,为了提高并行度,为了提高性能等,可能会出现指令顺序变为B→A→C等其他情况)。

当然CPU们也不是随便就去重排序,

需要满足以下两个条件(遵循的规则):

  • 在单线程环境下不能改变程序运行的结果;
  • 存在数据依赖关系的不允许重排序

5.2 数据依赖性

重排序遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。但是这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

5.3 重排序分三类

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

从 Java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

六、volatile关键字

volatile是Java虚拟机提供的轻量级的同步机制。

volatile关键字有如下两个作用:

  • 保证被volatile修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知,这个线程会通知其他线程从新读取主内存的值

  • 禁止指令重排序优化。

  • 不具有原子性,i++等会出现线程安全问题

七、final

final在Java中是一个保留的关键字,可以声明成员变量、方法、类以及本地变量。 被final修饰的变量不能被修改,方法不能被重写,类不能被继承。

对于final,编译器和处理器要遵守两个重排序规则:写规则和读规则

八、总结

JMM就是一组规则,解决在并发编程可能出现的线程安全问题,提供了内置解决方案(happen-before原则)及其外部可使用的同步手段(synchronized/volatile等),确保了程序在多线程并发执行中的原子性、可见性、有序性。

重排序是多核CPU等为了性能进行的优化操作,但会导致可见性等问题。为了解决这些问题,所以JMM需要制定一些规则,不让其随意重排序。

as-if-serial只保证单线程环境的不可随意重排序。

多线程下用,happens-before是JMM制定的最终目的,内存屏障则是实现happens-before的具体手段。

参考资料:

  1. https://blog.csdn.net/j080624/article/details/85320108
  2. https://www.cnblogs.com/nexiyi/p/java_memory_model_and_thread.html