内存模型产生背景
在介绍 Java 内存模型之前,我们先了解一下物理计算机中的并发问题,理解这些问题可以搞清楚内存模型产生的背景。物理机遇到的并发问题与虚拟机中的情况有不少相似之处,物理机的解决方案对虚拟机的实现有相当的参考意义。
1、物理机的并发问题
(1)硬件的效率问题
计算机处理器处理绝大多数运行任务都不可能只靠处理器“计算”就能完成,处理器至少需要与内存交互,如读取运算数据、存储运算结果,这个 I/O 操作很难消除(无法仅靠寄存器完成所有运算任务)。
由于计算机的存储设备与处理器的运算速度有几个数量级的差距,为了避免处理器等待缓慢的内存完成读写操作,现代计算机系统通过加入一层读写速度尽可能接近处理器运算速度的高速缓存。
缓存作为内存和处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速运行,当运算结束后再从缓存同步回内存之中
。
通常情况下,当一个CPU需要读取主存时,它会将主存的部分读到CPU缓存中。它甚至可能将缓存中的部分内容读到它的内部寄存器中,然后在寄存器中执行操作。当CPU需要将结果写回到主存中去时,它会将内部寄存器的值刷新到缓存中,然后在某个时间点将值刷新回主存。
当代计算机硬件内存架构:
(2)缓存一致性问题
基于高速缓存的存储系统交互很好的解决了处理器与内存速度的矛盾,但是也为计算机系统带来更高的复杂度,因为引入了一个新问题:缓存一致性。在多处理器的系统中(或者单处理器多核的系统),每个处理器(每个核)都有自己的高速缓存,而它们有共享同一主内存(Main Memory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。为此,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,来维护缓存的一致性
。
多核CPU多级缓存一致性协议MESI:
多核CPU的情况下有多个一级缓存,如何保证缓存内部数据的一致,不让系统数据混乱。这里就引出了一个一致性的协议MESI。
(3)代码乱序执行优化问题
为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。
乱序执行技术是处理器为提高运算速度而做出违背代码原有顺序的优化。在单核时代,处理器保证做出的优化不会导致执行结果远离预期目标,但在多核环境下却并非如此。在多核环境下, 如果存在一个计算任务依赖另一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有类似的指令重排序(Instruction Reorder)优化。
而且对相关数据读写没做任何防护措施,那么其顺序性并不能靠代码的先后顺序来保证,处理器最终得出的结果和我们逻辑得到的结果可能会大不相同。
重排序分三类:
●
编译器优化的重排序
。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
●指令级并行的重排序
。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
●内存系统的重排序
。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
Java内存模型(JMM)
Java内存模型(Java Memory Model,JMM)就是一种符合内存模型规范的,
用于屏蔽各种硬件和操作系统的访问差异,保证 Java 程序在各种平台下对内存的访问都能保证效果一致的机制及规范
。
Java 内存模型提出目标在于:定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节
。此处的变量(Variables)与 Java 编程中所说的变量有所区别,它包括实例字段、静态字段和构成数值对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的
。
一、 Java 内存模型的组成
JMM 内存模型主内存和工作内存组成。Java内存模型中规定了
所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量
。
1、主内存
Java 内存模型规定了所有变量都存储在主内存(Main Memory)中。
主内存是所有线程共享的,一般是实例对象、静态字段、数组对象等存储在堆内存中的变量
。
2、工作内存
每条线程都有自己的工作内存(Working Memory,又称本地内存),
线程的工作内存中保存了该线程使用到主内存中的变量拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,Java 线程间的通信(变量值的传递)采用的是共享内存方式
。工作内存是 JMM 的一个抽象概念,并不真实存在,它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
二、JMM 模型下的线程间通信
线程间通信必须要经过主内存。
如下,如果线程A与线程B之间要通信的话,必须要经历下面2个步骤:
1)线程A把本地内存A中更新过的共享变量刷新到主内存中去
。
2)线程B到主内存中去读取线程A之前已更新过的共享变量
。
内存间交互操作
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:
● lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
● unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
● read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
● load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
● use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
● assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
● store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
● write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
● 如果要把一个变量从主内存中复制到工作内存,就需要按顺序地执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行;
● 不允许 read 和 load、store 和 write 操作之一单独出现;
● 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中。
● 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中;
● 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化( load 或 assign )的变量。即就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作;
● 一个变量在同一时刻只允许一条线程对其进行 lock 操作,但lock操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。lock 和 unlock 必须成对出现;
● 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值;
● 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量;
● 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。
三、Java 内存模型解决的问题
为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。
1、可见性
可见性(共享对象可见性):线程对共享变量修改的可见性。
当一个线程修改了共享变量的值,其他线程能够立刻得知这个修改
。
如果两个或者更多的线程在没有正确的使用volatile声明或者同步的情况下共享一个对象,一个线程更新这个共享对象可能对其它线程来说是不可见的:共享对象被初始化在主存中。跑在CPU上的一个线程将这个共享对象读到CPU缓存中,然后修改了这个对象。只要CPU缓存没有被刷新会主存,对象修改后的版本对跑在其它CPU上的线程都是不可见的。这种方式可能导致每个线程拥有这个共享对象的私有拷贝,每个拷贝停留在不同的CPU缓存中。
解决这个内存可见性问题你可以使用 Java 中的 volatile 、 synchronized 和 final 关键字
。
● Java 中的 volatile 关键字:
volatile 关键字可以保证直接从主存中读取一个变量,如果这个变量被修改后,总是会被写回到主存中去,以及每个线程在每次使用 volatile 变量前都立即从主内存刷新
。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile 变量都是如此,普通变量与volatile变量的区别是:volatile的特殊规则保证了新值能立即同步到主内存,以及每个线程在每次使用 volatile 变量前都立即从主内存刷新。因此我们可以说 volatile 保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
● Java 中的 synchronized 关键字:同步块的可见性是由“如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值”、“对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store 和 write 操作)”这两条规则获得的
。
● Java 中的 final 关键字:final 关键字的可见性是指,被 final 修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去
(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程就能看见final字段的值(无须同步)
。
2、有序性
有序性规则表现在以下两种场景:
线程内:从某个线程的角度看方法的执行,指令会按照一种叫“串行”(as-if-serial)的方式执行,此种方式已经应用于顺序编程语言。
线程间:这个线程“观察”到其他线程并发地执行非同步的代码时,由于指令重排序优化,任何代码都有可能交叉执行。
Java程序中天然的有序性可以总结为一句话:如果在本地线程内观察,所有操作都是有序的(“线程内表现为串行”(Within-Thread As-If-Serial Semantics));如果在一个线程中观察另一个线程,所有操作都是无序的(“指令重排序”现象和“线程工作内存与主内存同步延迟”现象)。
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
● 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
● 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
● 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
在Java 中,可以使用 synchronized 和 volatile 来保证多线程之间操作的有序性
。实现方式有所区别:
● volatile关键字本身就包含了禁止指令重排序的语义;
● synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入;
有序性可以通过 volatile 和 synchronized 来实现,但是我们不可能所有的代码都靠这两个关键字。实际上,Java 语言已对重排序或者说有序性做了规定,这些规定在虚拟机优化的时候是不能违背的,这就是”先行发生“( Happen-Before )原则。先行发生原则是判断是否存在竞争,线程是否安全的非常有效的手段。依赖这个原则,我们可以通过几条简单原则一揽子解决并发环境下两个操作之间是否可能存在冲突的所有问题
,而不需要陷入Java内存模型苦涩难懂的定义之中。
Happen-Before 原则如下:
● 程序次序原则:一个线程内,按照程序代码顺序,书写在前面的操作先发生于书写在后面的操作。
● 管程锁定原则:一个 lock 操作先行发生于后面对同一个锁的 lock 操作。必须同一个锁,时间上先后。
● volatile 变量规则:volatile 变量的写,在时间上先发生于读,这保证了 volatile 变量的可见性。
● 锁规则:解锁(unlock) 必然发生在随后的加锁(lock)前。
● 传递性:A先于B,B先于C,那么A必然先于C。
● 线程启动原则: 线程的 start 方法先于他的每一个动作。
● 线程终止原则: 线程的所有操作先于线程的终结。
● 线程中断原则: 线程的中断(interrupt())先于被中断的代码。
● 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。
3、原子性
多线程竞争(Race Conditions)问题:当读、写和检查共享变量时出现 race conditions 。如果两个或者更多的线程共享一个对象,多个线程在这个共享对象上更新变量,就有可能发生race conditions。
解决这个问题可以使用 Java 同步块。一个同步块可以保证在同一时刻仅有一个线程可以进入代码的临界区。同步块还可以保证代码块中所有被访问的变量将会从主存中读入,当线程退出同步代码块时,所有被更新的变量都会被刷新回主存中去,不管这个变量是否被声明为 volatile。
原子性:指一个操作是按原子的方式执行的。要么该操作不被执行;要么以原子方式执行,即执行过程中不会被其它线程中断
。
由 Java 内存模型来直接保证的原子性变量操作包括read、load、assign、use、store、write,我们大致可以认为基本数据类型变量、引用类型变量、声明为 volatile 的任何类型变量的访问读写是具备原子性的(long和double的非原子性协定:对于64位的数据,如long和double,Java内存模型规范允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这四个操作的原子性,即如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既非原值,也不是其他线程修改值的代表了“半个变量”的数值。但由于目前各种平台下的商用虚拟机几乎都选择把64位数据的读写操作作为原子操作来对待,因此在编写代码时一般也不需要将用到的long和double变量专门声明为volatile)。这些类型变量的读、写天然具有原子性,但类似于 “基本变量++” / “volatile++” 这种复合操作并没有原子性。
使用原子性保证多线程写同步问题:在Java中可以使用 synchronized 来保证方法和代码块内的操作是原子性的
。
如果应用场景需要原子性保证,需要使用同步块技术。Java内存模型提供了 lock 和 unlock 操作来满足这种需求。虚拟机提供了字节码指令 monitorenter 和 monitorexist 来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块 —— synchronized关键字。
四、JVM对Java内存模型的实现
在JVM内部,Java内存模型把内存分成了两部分:线程栈区和堆区,下图展示了Java内存模型在JVM中的逻辑视图:
JVM 中运行的每个线程都拥有自己的线程栈,线程栈包含了当前线程执行的方法调用相关信息,我们也把它称作调用栈
。随着代码的不断执行,调用栈会不断变化。
线程栈还包含了当前方法的所有本地变量信息。一个线程只能读取自己的线程栈,也就是说线程中的本地变量对其它线程是不可见的
。即使两个线程执行的是同一段代码,它们也会各自在自己的线程栈中创建本地变量,因此,每个线程中的本地变量都会有自己的版本。
所有基本类型(boolean、byte、short、char、int、long、float、double)的本地变量都直接保存在线程栈当中,对于它们的值各个线程之间都是独立的
。对于原始类型的本地变量,一个线程可以传递一个副本给另一个线程,但它们之间是无法共享的。
堆区包含了 Java 应用创建的所有对象信息,不管对象是哪个线程创建的,其中的对象包括原始类型的封装类(如Byte、Integer、Long等等)
。不管对象是属于一个成员变量还是方法中的本地变量,它都会被存储在堆区。
●
如果一个本地变量是基本类型,那么它会被完全存储到栈区
。
●一个本地变量是一个对象的引用,这个本地引用会被存储到栈中,但是对象本身仍然存储在堆区
。
●Static 类型的变量以及类本身相关信息都会随着类本身存储在堆区
。
●堆中的对象可以被多线程共享
。如果一个线程获得一个对象的应用,它便可访问这个对象的成员变量。如果两个线程同时调用了同一个对象的同一个方法,那么这两个线程便可同时访问这个对象的成员变量,但是对于本地变量,每个线程都会拷贝一份到自己的线程栈中
。
Java 内存模型和硬件架构之间的桥接
正如上面讲到的,Java内存模型和硬件内存架构并不一致。硬件内存架构中并没有区分栈和堆,从硬件上看,不管是栈还是堆,大部分数据都会存到主存中,当然一部分栈和堆的数据也有可能会存到CPU寄存器中
,如下图所示,Java内存模型和计算机硬件内存架构是一个交叉关系: