Java内存模型

Java内存模型(Java Memory Model,简称:JMM),下文中如果没有特殊说明,JMM即代表“Java内存模型”。
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

注: 这里说的变量包括了实例变量、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有,不会被共享,自然就不存在竞争问题。

1. 背景

1.1 缓存一致性

在并发编程模型中,需要处理两个关键的问题:线程之间以何种机制交换信息(通信)及如何控制不同线程之间操作发生相对顺序的机制(同步)
线程之间的通信机制有两种:共享内存和消息传递,每种通信机制对应不同的内存模型。
共享内存模型:线程之间通过读-写内存中的公共状态进行隐式通信。
消息传递模型:线程之间没有公共状态,线程之间必须通过发送消息来显示的进行通信。
Java并发采用的是共享内存模型
在共享的内存模型,多处理器体系架构中,每个处理器都有自己的缓存,并且周期性的与主内存协调一致。处理器架构提供了不同级别的缓存一致性(cache coherence),有些仅提供最小的保证,几乎在任何时间内,都允许不同的处理器在相同位置上看到不同的值。
举个简单的例子:在java中,执行下面这个语句:

i = 10++;

1) 执行线程必须先在自己的工作线程中对变量i所在的缓存行进行赋值操作,然后再写入主存当中。而不是直接将数值10写入主存当中。

2) 比如同时有2个线程执行这段代码,假如初始时i的值为10,那么我们希望两个线程执行完之后i的值变为12。但是事实会是这样吗?

3) 可能存在下面一种情况:初始时,两个线程分别读取i的值存入各自所在的工作内存当中,然后线程1进行加1操作,然后把i的最新值11写入到内存。此时线程2的工作内存当中i的值还是10,进行加1操作之后,i的值为11,然后线程2把i的值写入内存。

4) 最终结果i的值是11,而不是12。这就是缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。

注:
同步:指程序中用于控制不同线程间操作发生相对顺序的机制,意味着某种形式的原子性或者互斥。
共享内存的并发模型中,同步是显式,程序员必须显示的指定某个方法或某段代码需要在线程之间互斥执行。消息传递的并发模型中,消息的发送必须在消息的接收之前,因此同步是隐式的。

想要保证每个处理器能在任意时间内获知其他处理器正在处理的工作,代价很高而且很多时候这些信息都是不必要的,所以就牺牲掉存储的一致性来保证性能。为了共享数据时能得到存储协调的保证,Java提供了自己的JMM解决与底层平台存储模型的差异化。

1.2 重排序

除了上述的缓存一致性问题外,在执行程序时,为了提高程序的性能,使得处理器内部的运算单元被充分利用,编译器和处理器常常会对指令做重排序。
从Java源代码到最终执行的指令序列,会分别经历下面的三种重排序:
resort
图片来自Java并发编程艺术

  1. 编译器重排序。编译器不改变单线程语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级重排序。现代处理器采用指令级并行技术将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,使得加载和存储操作看上去可能是在乱序执行。

编译器和处理器重排序时会遵守数据依赖性,编译器、运行时和处理器都必须遵守as-if-serial语义。
注:
数据依赖性:编译器和处理器不会改变存在数据以来关系的两个操作的执行顺序。数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
as-if-serial:内部线程类似顺序化语义。不管如何的重排序,单线程程序的执行结果不能被改变。

1.2.1 重排序的影响

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Test {
int a = 0;
boolean flag = false;

public void writer(){
a = 1; // 1
flag = true; // 2
}
public void reader(){
if (flag){ // 3
int i = a * a; // 4
}
}
}

假设线程A先执行writrer方法,随后线程B执行reader方法,线程执行操作4时,不一定能看到1处的赋值。原因如下图:
resort
图片来自Java并发编程艺术
由于操作1和2没有依赖关系,所以编译器和处理器可以对这两个操作重排序,线程A首先标记了flag,随后线程B读取flag。由于flag=true,线程B可以读取a,但是此时的a并为被线程A赋值。所以这里的多线程程序的语义被破坏了。JMM提供同步机制来抑制编译器、运行时和硬件对存储操作的各种方式的重排序,保证内存的可见性。

2. JMM设计

JMM的抽象结构
JMM在内存中的抽象结构,图片来自Java并发编程艺术

JMM属于语言级别的内存模型,确保在不同的编译和不同处理器平台上,通过禁止特定类型的编译器重排序和处理器重排序,决定一个线程对共享变量的写入何时对另一个线程可见,提供内存可见性保证。
从JDK5开始,Java使用JSR-133内存模型。JSR-133使用happens-before概念保证内存的可见性。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
下面就来具体介绍下happens-before原则(先行发生原则):

  1. 程序次序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。

  2. 监视器锁规则:一个unLock操作先行发生于后面对同一个锁的lock操作。

  3. volatile变量规则:对一个volatile变量的写操作,happens-before于后续对这个volatile变量的读操作。

  4. 传递性:如果操作A happens-before操作B,而操作B happens-before操作C,则可以得出操作A happens-before操作C。

  5. start()规则:Thread对象的start()方法happens-before此线程的每个一个动作

  6. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行

  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

happens-before-design
JMM设计图,图片来自Java并发编程艺术

3. JMM的可见性保证

  • 单线程程序。单线程程序不会出现内存可见性问题。编译器、runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
  • 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。JMM通过根据happens-before原则限制编译器和处理器的重排序来为程序员提供内存可见性保证。
  • 未同步/未正确同步的多线程程序。JMM为他们提供最小的安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么时默认值(0、null、false)。

参考资料

你真的了解volatile关键字吗?

Java中Volatile关键字详解

Java并发编程艺术

深入理解Java虚拟机-JVM高级特性与最佳实践

Java并发编程实践

每个程序员都应该了解的 CPU 高速缓存