Java并发基础三:Java内存模型(JMM)

前言

在并发编程需要处理的两个关键问题是:线程之间如何通信线程之间如何同步。通信 是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存 和 消息传递同步: 是指程序用于控制不同线程之间操作发生相对顺序的机制。前篇文章介绍了物理机为了提高效率,引入三级缓存和相关解决缓存一致性的方案,本篇将介绍Java内存模型是什么,为什么需要Java内存模型,以及Java内存模型解决了什么问题。

一、JMM诞生背景

不同架构的物理计算机可以有不一样的内存模型,Java 虚拟机也有自己的内存模型。虽然java程序所有的运行都是在虚拟机中,涉及到的内存等信息都是虚拟机的一部分,但实际也是物理机的,只不过是虚拟机作为最外层的容器统一做了处理。虚拟机的内存模型,以及多线程的场景下与物理机的情况是很相似的,可以类比参考。

结合前面介绍的物理机的处理器处理内存的问题,可以类比总结出 JVM 内存操作的问题。下面介绍的 Java 内存模型的执行处理解决的两个问题:1、工作内存数据一致性 2、指令重排序导致运行结果与预期一致性。

为了更好解决上面提到的系列问题,内存模型被总结提出,我们可以把内存模型理解为在特定操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。

二、Java内存模型概念

Java 虚拟机规范中试图定义一种 Java 内存模型(Java Memory Model,简称 JMM),来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果,不必因为不同平台上的物理机的内存模型的差异,对各平台定制化开发程序。

更具体一点说,Java内存模型是一种规范,他规范了java虚拟机与计算机内存如何协调工作 ,定义程序中变量的访问规则,他规定了一个线程如何及何时看到其他线程修改过的变量的值,以及在必须时,如何同步的访问共享变量。
在这里插入图片描述
Java内存模型的主要目标是定义程序中变量的访问规则。即在虚拟机中将变量存储到主内存或者将变量从主内存取出这样的底层细节。需要注意的是这里的变量跟我们写java程序中的变量不是完全等同的。这里的变量是指实例字段,静态字段,构成数组对象的元素,但是不包括局部变量和方法参数(因为这是线程私有的)。这里可以简单的认为主内存是java虚拟机内存区域中的堆,局部变量和方法参数是在虚拟机栈中定义的。 但是在堆中的变量如果在多线程中都使用,就涉及到了堆和不同虚拟机栈中变量的值的一致性问题了。

三、Java 内存模型结构

主内存

Java 内存模型规定了所有变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件的主内存名字一样,两者可以互相类比,但此处仅是虚拟机内存的一部分)。

工作内存

每条线程都有自己的工作内存(Working Memory,又称本地内存,可与前面介绍的处理器高速缓存类比),线程的工作内存中保存了该线程使用到的变量的主内存中的共享变量的副本拷贝。
工作内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java 内存模型抽象示意图如下:
在这里插入图片描述

从上图来看,如果线程 A 和线程 B 要通信的话,要如下两个步骤:
1、线程 A 需要将本地内存 A 中的共享变量副本刷新到主内存去
2、线程 B 去主内存读取线程 A 之前已更新过的共享变量
从整体上看,这两个步骤是线程 1 在向线程 2 发消息,这个通信过程必须经过主内存。
JMM 通过控制主内存与每个线程本地内存之间的交互,来为各个线程提供共享变量的可见性。

四、Java 内存间的交互操作

在理解 Java 内存模型的系列协议、特殊规则之前,我们先理解 Java 中内存间的交互操作。关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java 内存模型中定义了下面 8 种操作来完成。
虚拟机实现时必须保证下面介绍的每种操作都是原子的,不可再分的(对于 double 和 long 型的变量来说,load、store、read、和 write 操作在某些平台上允许有例外)。
在这里插入图片描述
8 种基本操作,如上图:
lock (锁定) ,作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
unlock (解锁) ,作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read (读取) ,作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
load (载入) ,作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
use (使用) ,作用于工作内存的变量,它把工作内存中一个变量的值传递给Java虚拟机执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时就会执行这个操作。
assign (赋值) ,作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store (存储) ,作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后 write 操作使用。
write (写入) ,作用于主内存的变量,它把 Store 操作从工作内存中得到的变量的值放入主内存的变量中。

JMM 在执行前面介绍 8 种基本操作时,为了保证内存间数据一致性,JMM 中规定需要满足以下规则:

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

看起来这些规则有些繁琐,其实也不难理解:

规则 1、规则 2,工作内存中的共享变量作为主内存的副本,主内存变量的值同步到工作内存需要 read 和 load 一起使用。工作内存中的变量的值同步回主内存需要 store 和 write 一起使用,这 2 组操作各自都是一个固定的有序搭配,不允许单独出现。

规则 3、规则 4,由于工作内存中的共享变量是主内存的副本,为保证数据一致性,当工作内存中的变量被字节码引擎重新赋值,必须同步回主内存。如果工作内存的变量没有被更新,不允许无原因同步回主内存。

规则 5,由于工作内存中的共享变量是主内存的副本,必须从主内存诞生。

规则 6、7、8、9,为了并发情况下安全使用变量,线程可以基于 lock 操作独占主内存中的变量,其他线程不允许使用或 unlock 该变量,直到变量被线程 unlock。

五、内存交互基本操作的 3 个特性

Java 内存模型是围绕着在并发过程中如何处理这 3 个特性来建立的,这里先给出定义和基本实现的简单介绍,后面会逐步展开分析。

原子性(Atomicity)

原子性,即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。

可见性(Visibility)

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
正如上面“交互操作流程”中所说明的一样,JMM 是通过在线程 1 变量工作内存修改后将新值同步回主内存
线程 2在变量读取前从主内存刷新变量值,这种依赖主内存作为传递媒介的方式来实现可见性。

有序性(Ordering)
有序性规则表现在以下两种场景:

  • 线程内,从某个线程的角度看方法的执行,指令会按照一种叫“串行”(as-if-serial)的方式执行,此种方式已经应用于顺序编程语言。
  • 线程间,这个线程“观察”到其他线程并发地执行非同步的代码时,由于指令重排序优化,任何代码都有可能交叉执行。

通俗理解:Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性和可见性。
JMM关于volatile和synchronized有序性保证:

  • volatile的有序性: volatile本身包含了禁止指令重拍序的语义。
  • synchronized的有序性: synchronize的有序性是由“一个变量同一时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。

JMM关于synchronized可见性两条规定:

  • 线程解锁前,必须把共享变量的最新值刷新到主内存
  • 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值(注意-加锁与解锁是同一把锁)

JMM关于volatile可见性两条规定:通过加入内存屏障和禁止重排序优化来实现

  • 对volatile写操作时,会在写操作后加一个store屏障指令,将本地内存中的共享变量值刷新到主内存
  • 对volatile读操作时,会在读操作前加一条load屏障指令,从主内存中读取共享变量

总结: Java 内存模型的一系列运行规则看起来有点繁琐,但总结起来,是围绕原子性、可见性、有序性特征建立。归根究底,是为实现共享变量的在多个线程的工作内存的数据一致性,多线程并发,指令重排序优化的环境中程序能如预期运行。

六、volatile原理

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制。被volatile修饰的变量有以下三个特性:
(1)可见性
volatile变量,用来确保将变量的更新操作通知到其他线程。
(2)禁止指令重拍序
当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序,底层原理是内存屏障,包括cpu的内存屏障和编译器的内存屏障
(3)不保证原子性

volatile关键字解决的问题就是:当一个线程写入该值后,另一个线程读取的必定是新值。

volatile保证了修饰的共享变量在转换为汇编语言时,会加上一个以lock为前缀的指令,当CPU发现这个指令时,立即会做两件事情:

  • 将当前内核中线程工作内存中该共享变量刷新到主存;
  • 通知其他内核里缓存的该共享变量内存地址无效;重新从主内存中读

volatile 可以保证可见性和有序性,但是当线程2已经使用旧值完成了运算指令,且将要回写到内存时,是不能保证原子性的。即:极端情况-多个线程同时使用旧值完成运算指令,把更新后的变量值同时刷新回主内存,可能导致得到的值不是预期结果。

举个例子:定义 volatile int count = 0,2 个线程同时执行 count++ 操作,每个线程都执行 500
次,最终结果小于 1000。

原因是每个线程执行 count++ 需要以下 3 个步骤:

  • 线程从主内存读取最新的 count 的值。
  • 执行引擎把 count 值加 1,并赋值给线程工作内存。
  • 线程工作内存把 count值保存到主内存。

有可能某一时刻 2 个线程在步骤 1 读取到的值都是 100,执行完步骤 2 得到的值都是 101,最后刷新了 2 次 101 保存到主内存。

volatile 型变量实现原理

具体实现方式是在编译期生成字节码时,会在指令序列中增加内存屏障来保证,下面是基于保守策略的 JMM 内存屏障插入策略:
在这里插入图片描述
在每个 volatile 写操作的前面插入一个 StoreStore 屏障。该屏障除了保证了屏障之前的写操作和该屏障之后的写操作不能重排序,还会保证了 volatile 写操作之前,任何的读写操作都会先于 volatile 被提交。

在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。 该屏障除了使 volatile 写操作不会与之后的读操作重排序外,还会刷新处理器缓存,使 volatile 变量的写更新对其他线程可见。

在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。 该屏障除了使 volatile 读操作不会与之前的写操作发生重排序外,还会刷新处理器缓存,使 volatile 变量读取的为最新值。

在每个 volatile 读操作的后面插入一个 LoadStore 屏障。 该屏障除了禁止了 volatile 读操作与其之后的任何写操作进行重排序,还会刷新处理器缓存,使其他线程 volatile 变量的写更新对 volatile 读操作的线程可见。

volatile 型变量使用场景
总结起来,就是“一次写入,到处读取”,某一线程负责更新变量,其他线程只读取变量(不更新变量),并根据变量的新值执行相应逻辑。例如状态标志位更新,观察者模型变量值发布。

七、JMM相关规则

什么是happen-before规则

定义: happen-before 关系,是Java内存模型中保证多线程可见性的机制,在Java内存模型中,happens-before 应该翻译成:前一个操作的结果可以被后续的操作可见。讲白点就是前面一个操作把变量a赋值为1,那后面一个操作肯定能知道a已经变成了1。

Java内存模型中,允许编译器和处理器对指令进行重排序,以提高性能,但是重排序过程不会影响单线程的执行,却会影响到多线程并发执行的正确性。Java内存模型有一些先天的有序性,不需要其他手段就能保证有序性。这个保证机制就是happen-before原则。如果两个操作的执行顺序不能从happen-before原则推导出来,那么就不能保证有序性以及可见性。虚拟机可以随意的对他们进行重排序。

目的: 为了解决多线程的可见性问题,就搞出了happens-before原则,让线程之间遵守这些原则。编译器还会优化我们的语句,所以等于是给了编译器优化的约束。不能让它优化的不知道东南西北了!

规则:

程序次序规则: 在一个线程内一段代码的执行结果是有序的。就是还会指令重排,但是随便它怎么排,结果是按照我们代码的顺序生成的不会变!

管程锁定规则: 就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果!(管程是一种通用的同步原语,synchronized就是管程的实现)

volatile变量规则: 就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。

线程启动规则: 在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。

线程终止规则: 在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。

线程中断规则: 对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()检测到是否发生中断。

传递规则: 这个简单的,就是happens-before原则具有传递性,即A happens-before B , B happens-before C,那么A happens-before C。

对象终结规则: 这个也简单的,就是一个对象的初始化的完成,也就是构造函数执行的结束一定 happens-before它的finalize()方法。

这几条规则就是面向我们这些开发人员的,掌握了这几条规则能让我们更好的开发出符合我们预期的并发程序的代码!

什么是as-if-serial 语义

as-if-serial 语义的意思指: 不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义。
单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会 干扰他们,也无需担心内存可见性问题。

为了遵守 as-if-serial 编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是如果操作之间没有数据依赖关系,这些操作就可能被编译器和处理器重排序。
举个例子:

1double pi = 3.14;     //A
2double r  = 1.0;       //B
3double area = pi * r * r;     //C

A 和 C 之间存在数据依赖关系,同时 B 和 C 之间也存在数据依赖关系。因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面,程序的结果将会被改变)。但 A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。

文章参考:
https://www.cnblogs.com/zhiji6/p/10037690.html
https://mp.weixin.qq.com/s/YIaeYc1XE-iN62XzvXKI6Q

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章