Java 内存模型
并发编程模型的两个关键问题
在并发编程中,需要处理两个关键问题:
-
线程之间如何通信;
通信是指线程之间以何种机制来交换信息,在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。
-
在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。
-
在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。
-
-
线程之间如何同步(这里的线程是指并发执行的活动实体)。
同步是指程序中用于控制不同线程间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。
Java 内存模型
共享变量
Java 运行时数据区:
对于每一个线程来说,栈都是私有的,而堆是共有的。
也就是说,在栈中的变量(局部变量、方法定义的参数、异常处理的参数)不会在线程之间共享,也就不会有内存可见性的问题,也不受内存模型的影响。而在堆中的变量是共享的,一般称之为共享变量。
所以,内存可见性针对的是堆中的共享变量。
Java 内存模型的抽象结构
在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享,后续用“共享变量”这个术语代指实例域、静态域和数组元素。
局部变量(Local Variables)、方法定义参数(Formal Method Parameters)和异常处理器参数(Exception Handler Parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
Java 线程之间的通信由 Java 内存模型(Java Memory Model,JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。
从抽象的角度来看,JMM 定义了线程和主内存(Main Memory)之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。
本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意,如下图所示:
从图中可以看出:
-
所有的共享变量都存在主存中;
-
每个线程都保存了一份该线程使用到的共享变量的副本;
-
如果线程 A 与线程 B 之间要通信的话,必须经历下面 2 个步骤:
-
线程 A 将本地内存 A 中更新过的共享变量刷新到主存中去;
-
线程 B 到主存中去读取线程 A 之前已经更新过的共享变量。
-
所以,线程 A 无法直接访问线程 B 的工作内存,线程间通信必须经过主内存。
注意,根据 JMM 的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主存中读取。
-
主内存:Java 堆中对象实例数据部分,对应于物理硬件的内存;
-
工作内存:Java 栈中的部分区域,优先存储于寄存器和高速缓存。
所以,线程 B 并不是直接去主存中读取共享变量的值,而是先在本地内存 B 中找到这个共享变量,发现这个共享变量已经被更新了,然后本地内存 B 去主存中读取这个共享变量的新值,并拷贝到本地内存 B 中,最后线程 B 再读取本地内存 B 中的新值。
volatile 关键字
如何保证变量的可见性?
在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
如何禁止指令重排序?
在 Java 中,volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。
如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。
下面,我们以双重检验锁方式实现单例模式的示例来介绍 volatile:
示例:
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
分析:
uniqueInstance 采用 volatile 关键字修饰也是很有必要的, 因为,uniqueInstance = new Singleton();
这段代码其实是分为三步执行:
-
为 uniqueInstance 分配内存空间;
-
初始化 uniqueInstance;
-
将 uniqueInstance 指向分配的内存地址。
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。
指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。
注意,volatile 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。对于复合操作,还是需要通过 synchronized、Lock 或者 AtomicInteger 来保证操作的原子性。
JMM 与 Java 运行时内存区域的区别
前面提到了 JMM 和 Java 运行时内存区域的划分,这两者既有差别又有联系:
区别
两者是不同的概念。
-
JMM 是抽象的,他是用来描述一组规则,通过这个规则来控制各个变量的访问方式,围绕原子性、有序性、可见性等展开。
-
Java 运行时内存的划分是具体的,是 JVM 运行 Java 程序时必要的内存划分。
联系
都存在私有数据区域和共享数据区域。
一般来说:
-
JMM 中的主存属于共享数据区域,包含了堆和方法区;
-
同样,JMM 中的本地内存属于私有数据区域,包含了程序计数器、本地方法栈、虚拟机栈。
总结
Java 运行时内存区域描述的是在 JVM 运行时,如何将内存划分为不同的区域,并且每个区域的功能和工作机制。主要包括以下几个部分:
-
方法区:存储了每一个类的结构信息,如运行时常量池、字段和方法数据、构造方法和普通方法的字节码内容。
-
堆:几乎所有的对象实例以及数组都在这里分配内存。这是 Java 内存管理的主要区域。
-
栈:每一个线程有一个私有的栈,每一次方法调用都会创建一个新的栈帧,用于存储局部变量、操作数栈、动态链接、方法出口等信息。所有的栈帧都是在方法调用和方法执行完成之后创建和销毁的。
-
本地方法栈:与栈类似,不过本地方法栈为 JVM 使用到的 native 方法服务。
-
程序计数器:每个线程都有一个独立的程序计数器,用于指示当前线程执行到了字节码的哪一行。
Java 内存模型 (JMM) 主要针对的是多线程环境下,如何在主内存与工作内存之间安全地执行操作。它涵盖的主题包括变量的可见性、指令重排、原子操作等,旨在解决由于多线程并发编程带来的一些问题:
-
可见性:当一个线程修改了共享变量的值,这个新值对于其他线程来说可以立即知道。
-
原子性:一个或多个操作在整个过程中,不会被其他的线程或者操作所打断,这些操作是一个整体,要么都执行,要么都不执行。
-
有序性:程序执行的顺序按照代码的先后顺序执行的。
JMM 与重排序
为什么指令重排序可以提高性能?
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。
简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,流水线技术产生了,它的原理是指令 1 还没有执行完,就可以开始执行指令 2,而不用等到指令 1 执行结束后再执行指令 2,这样就大大提高了效率。
但是,流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。
我们分析一下下面这段代码的执行情况:
a = b + c;
d = e - f ;
先加载 b、c(注意,有可能先加载 b,也有可能先加载 c),但是在执行 add(b,c) 的时候,需要等待 b、c 装载结束才能继续执行,也就是需要增加停顿,那么后面的指令(加载 e 和 f)也会有停顿,这就降低了计算机的执行效率。
为了减少停顿,我们可以在加载完 b 和 c 后把 e 和 f 也加载了,然后再去执行 add(b,c),这样做对程序(串行)是没有影响的,但却减少了停顿。
换句话说,既然 add(b,c) 需要停顿,那还不如去做一些有意义的事情(加载 e 和 f)。
综上所述,指令重排对于提高 CPU 性能十分必要,但也带来了乱序的问题。
重排序有哪几种?
指令重排一般分为以下三种:
-
编译器优化重排
编译器在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
-
指令并行重排
现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
-
内存系统重排
由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。
JMM 与顺序一致性模型
当程序未正确同步的时候,就可能存在数据竞争。
数据竞争:在一个线程中写一个变量,在另一个线程读同一个变量,并且写和读没有通过同步来排序。
如果程序中包含了数据竞争,那么运行的结果往往充满了不确定性,比如读发生在了写之前,可能就会读到错误的值,如果一个线程能够正确同步,那么就不存在数据竞争。
Java 内存模型(JMM)对于正确同步多线程程序的内存一致性做了以下保证:如果程序是正确同步的,程序的执行将具有顺序一致性。即程序的执行结果和该程序在顺序一致性模型中执行的结果相同。
这里的同步包括使用 volatile
、final
、synchronized
等关键字实现的同步。
什么是顺序一致性模型?
顺序一致性模型是一个理想化的理论参考模型,它为程序提供了极强的内存可见性保证。顺序一致性模型有两大特性:
-
一个线程中的所有操作必须按照程序的顺序(即 Java 代码的顺序)来执行。
-
不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序。即在顺序一致性模型中,每个操作必须是原子性的,且立刻对所有线程可见。
为了理解这两个特性,我们举个例子,假设有两个线程 A 和 B 并发执行:
-
线程 A 有 3 个操作,它们在程序中的顺序是 A1->A2->A3;
-
线程 B 也有 3 个操作,B1->B2->B3。
假设正确使用了同步,A 线程的 3 个操作执行后释放锁,B 线程获取同一个锁。那么在顺序一致性模型中的执行效果如下所示:
操作的执行整体上有序,并且两个线程都只能看到这个执行顺序。
JMM 为什么不保证顺序一致性?
假设没有使用同步,那么在顺序一致性模型中的执行效果如下所示:
操作的执行整体上无序,但是两个线程都只能看到这个执行顺序。之所以可以得到这个保证,是因为顺序一致性模型中的每个操作必须立即对任意线程可见。
但是 JMM 没有这样的保证。
比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,这个写操作根本没有被当前线程所执行。
只有当前线程把本地内存中写过的数据刷新到主存之后,这个写操作才对其他线程可见。在这种情况下,当前线程和其他线程看到的执行顺序是不一样的。
在顺序一致性模型中,所有操作完全按照程序的顺序串行执行。但是 JMM 中,临界区内(同步块或同步方法中)的代码可以发生重排序(但不允许临界区内的代码“逃逸”到临界区之外,因为会破坏锁的内存语义)。
虽然线程 A 在临界区做了重排序,但是因为锁的特性,线程 B 无法观察到线程 A 在临界区的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
同时,JMM 会在退出临界区和进入临界区做特殊的处理,使得在临界区内程序获得与顺序一致性模型相同的内存视图。
由此可见,JMM 的具体实现方针是:在不改变(正确同步的)程序执行结果的前提下,尽量为编译期和处理器的优化打开方便之门。
对于未同步的多线程,JMM 只提供最小安全性:线程读取到的值,要么是之前某个线程写入的值,要么是默认值,不会无中生有。
为了实现这个安全性,JVM 在堆上分配对象时,首先会对内存空间清零,然后才会在上面分配对象(这两个操作是同步的)。
JMM 没有保证未同步程序的执行结果与该程序在顺序一致性中执行结果一致。因为如果要保证执行结果一致,那么 JMM 需要禁止大量的优化,对程序的执行性能会产生很大的影响。
未同步程序在 JMM 和顺序一致性内存模型中的执行特性有如下差异:
-
顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行。
因为会发生重排序,但是 JMM 保证单线程下的重排序不影响执行结果。
-
顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。
因为 JMM 不保证所有操作立即可见。
-
顺序一致性模型保证对所有的内存读写操作都具有原子性,而 JMM 不保证对 64 位的 long 型和 double 型变量的写操作具有原子性。
JMM 与 happens-before
一方面,我们开发者需要 JMM 提供一个强大的内存模型来编写代码;另一方面,编译器和处理器希望 JMM 对它们的束缚越少越好,这样它们就可以尽可能多的做优化来提高性能,希望的是一个弱的内存模型。
JMM 考虑了这两种需求,并且找到了平衡点,对编译器和处理器来说,只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都行。
对于我们开发者来说,JMM 提供了 happens-before 规则(JSR-133 规范),满足了我们的诉求——简单易懂,并且提供了足够强的内存可见性保证。 换言之,我们开发者只要遵循 happens-before 规则,那么我们写的程序就能保证在 JMM 中具有强的内存可见性。
JMM 使用 happens-before 的概念来定制两个操作之间的执行顺序。这两个操作可以在一个线程内,也可以是不同的线程种。
happens-before 关系的定义如下:
-
如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
-
两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。
happens-before 关系本质上和 as-if-serial 语义是一回事。
as-if-serial 语义保证单线程内重排序后的执行结果和程序代码本身应有的结果是一致的,happens-before 关系保证正确同步的多线程程序的执行结果不被重排序改变。
总之,如果操作 A happens-before 操作 B,那么操作 A 在内存上所做的操作对操作 B 都是可见的,不管它们在不在一个线程。
happens-before 关系有哪些?
在 Java 中,有以下天然的 happens-before 关系:
-
程序顺序规则
一个线程中的每一个操作,happens-before 于该线程中的任意后续操作。
-
监视器锁规则
对一个锁的解锁,happens-before 于随后对这个锁的加锁。
-
volatile
变量规则对一个
volatile
域的写,happens-before 于任意后续对这个volatile
域的读。 -
传递性
如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
-
start 规则
如果线程 A 执行操作
ThreadB.start()
启动线程 B,那么 A 线程的ThreadB.start()
操作 happens-before 于线程 B 中的任意操作。 -
join 规则
如果线程 A 执行操作
ThreadB.join()
并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从ThreadB.join()
操作成功返回。
举例:
int a = 1; // A操作
int b = 2; // B操作
int sum = a + b;// C 操作
System.out.println(sum);
根据以上介绍的 happens-before 规则,假如只有一个线程,那么不难得出:
1> A happens-before B
2> B happens-before C
3> A happens-before C
注意,真正在执行指令的时候,其实 JVM 有可能对操作 A & B 进行重排序,因为无论先执行 A 还是 B,他们都对对方是可见的,并且不影响执行结果。
如果这里发生了重排序,这在视觉上违背了 happens-before 原则,但是 JMM 是允许这样的重排序的。
所以,我们只关心 happens-before 规则,不用关心 JVM 到底是怎样执行的。只要确定操作 A happens-before 操作 B 就行了。
重排序有两类,JMM 对这两类重排序有不同的策略:
-
会改变程序执行结果的重排序,比如 A -> C,JMM 要求编译器和处理器都禁止这种重排序。
-
不会改变程序执行结果的重排序,比如 A -> B,JMM 对编译器和处理器不做要求,允许这种重排序。
小结
JMM 定义了 Java 程序中的变量、线程如何和主存以及工作内存进行交互的规则。它主要涉及到多线程环境下的共享变量可见性、指令重排等问题,是理解并发编程中的关键概念。
JMM 主要针对的是多线程环境下,如何在主内存与工作内存之间安全地执行操作。
Java 运行时内存区域描述的是在 JVM 运行时,如何将内存划分为不同的区域,并且每个区域的功能和工作机制。主要包括方法区、堆、栈、本地方法栈、程序计数器。
指令重排是为了提高 CPU 性能,但是可能会导致一些问题,比如多线程环境下的内存可见性问题。
happens-before 规则是 JMM 提供的强大的内存可见性保证,只要遵循 happens-before 规则,那么我们写的程序就能保证在 JMM 中具有强的内存可见性。
参考: