[翻译]JSR 133 (Java Memory Model) FAQ
目录
[TOC]
原文地址:
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#whatismm
作者: Jeremy Manson and Brian Goetz
时间: February 2004
到底什么是内存模型?
在一个多处理器系统中,处理器通常拥有多层缓存。这些缓存能够高速获取数据(因为缓存中的数据离处理器更近,访问路径为:CPU -> 寄存器 -> 缓存 -> 主存),并能减少主存的访问频率以降低总线的繁忙程度(因为很多操作可以通过缓存数据自满足,不需要与主存交互),从而提高 CPU 整体的运行速度。
虽然缓存可以明显优化性能,但缓存技术的引入也带来了很多挑战。例如:当两个处理器同时访问同一个内存地址时会发生什么?在什么情况下这两个处理器会看到相同的值?缓存的引入是否会造成数据不一致问题?
在处理器层面,内存模型需要定义必要且充足的条件限制,以保证一个处理器写入主存的数据对其他处理器的可见性,并且其他处理器的写入操作也可以对当前处理器可见。
一些处理器设计展现出的是强内存模型:所有处理器读到的同一内存地址的数据都是一致的。还有些处理器设计展现出的是弱内存模型:需要一些内存栅栏(Memory Barrier)去将当前处理器缓存的数据刷入主存,或将当前处理器缓存置为无效。通过这些内存栅栏来保证处理器读写的数据一致性。这些内存栅栏通常在 lock/unlock 指令发生时被执行;另外,在语言层面这些内存栅栏通常不会被程序员直接感知。
在强内存模型中,通常写程序会比较简单,因为对内存屏障的使用需求较少。然而,有时在一些非常强一致的内存模型中,使用内存栅栏也是非常必要的;通常这些内存栅栏的插入位置也是违反直觉的,令程序员摸不着头脑。
最近处理器设计的趋势是鼓励弱内存模型的,因为这种缓存一致性的“弱化”在多处理器、大内存容量场景下带来了更强的扩展性。
编译器的重排序使得线程间可见性问题更加复杂。例如:编译器可能为了提高效率会延后执行一个程序中的写操作,当然这种重排序只要不影响程序的整体语义就是完全允许的。那么一旦编译器将一个操作重排序,使得这个操作较后执行,那么这个被重排序的操作执行之前,其他线程是无法看到的。同样的情况也会出现在缓存中。
此外,写入主存操作也可以向前重排序,提前执行。那么对于其他线程来说,会看见一个被提前执行了的写入操作。所有这些允许编译器、运行时、硬件重排序的灵活性,可以使机器代码在一个最优的顺序里执行,以获取最优性能。当然这些重排序优化,都是在当前内存模型的边界范围内。
以下代码可以展示一个重排序的简单示例:
class Reordering {
int x = 0, y = 0;
public void writer() {
x = 1;
y = 2;
}
public void reader() {
int r1 = y;
int r2 = x;
}
}当以上代码被两个线程并发执行,对 y 变量的读取会看到 y 的值为 2,因为 y=2 这行代码在 x=1 之后,所以程序员就会认为读取 y 的值发现是 2,那么 x 的值肯定是 1。然而,并不一定。writer() 中的代码也许已经被重排序后执行了。实际上可能会是这样的执行情况:线程 A 执行 writer() 方法,先执行了 y=2(被排序到首行执行),接着另一个线程 B 开始执行 reader() 方法,读取到 y 的值为 2,x 的值为 0 后,线程 A 才开始执行 x=1 的代码指令。最终线程 B 看到的就是 r1=2; r2=0。
JMM(Java Memory Model)描述的是多线程代码中什么样的行为是合法的,线程间如何通过主存相互通信。JMM 描述的是一个程序中变量之间的相互影响;以及一个真实的计算机中,变量在主存中或者寄存器中的读写访问细节。并且 JMM 的正确实现可以不受各种各样的硬件限制、可以不受各种各样的编译器优化策略的限制,(跨平台)实现统一的内存模型。
Java 中的 volatile、synchronized、final 关键字是为了帮助程序员在 Java 语言层面向编译器提出并发性的需求。JMM 规定了 volatile、synchronized 的行为,更重要的是确保一个被正确同步的 Java 程序可以畅通无阻的在各种各样的处理器上正确执行。
这个问题总结(非原文):
缓存提升 CPU 整体效率,它的两个优势:
- 缓存比主存的使用速度快、效率高;
- 缓存通过部分替代主存的使用,减少总线阻塞。
- 缓存虽然能明显提高 CPU 整体性能,但会存在缓存一致性问题。
- CPU 层面,JMM 通常必须定义一些约束,以保证一个 CPU 对主存的写操作,能够被其他 CPU 可见。 JMM 有强内存模型、弱内存模型,这里的强指的是在这个模型中,一个 CPU(或一个线程)写入内存的数据,对其他 CPU(或其他线程)的可见性更强。强、弱内存模型都会使用内存屏障技术保障 Cache 与主存的及时同步,但是强内存模型中使用的内存屏障比较少。弱内存模型会更易于接受、有更好的扩展性。
- 缓存、编译器为了提高效率,会对指令重排序。
- JMM 描述了多线程代码中什么样的行为是合法的,线程之间如何通过主存相互通信;程序中变量之间的相互关系(happens-before),以及这些变量在主存或寄存器的存取底层细节。并且不因平台的改变而受到影响。
其他语言(例如 C++)有内存模型吗?
除 Java 外的其他大多数语言(C、C++)的设计都不是直接支持并发的。对于限制处理器、编译器的重排序的这种必要保护,主要是依赖于第三方线程处理类库(比如 pthreads)完成。
JSR 133 是什么?
1997 年之后,当时的 JMM 被发现了多个严重的缺陷。这些缺陷会产生一些令人困惑的行为,比如 final 字段可以改变自己的值。另外,这些缺陷还包括 JMM 会不知不觉地破坏编译器通用的优化能力。
JMM 在当时是一个很有雄心的尝试,是计算机史上第一次出现的一个编程语言的定义中包含一个 JMM 的定义,并且这个 JMM 是一个能够为并发提供一致性语义的内存模型(跨平台的)。不幸的是,定义一个兼备一致性保证和符合直觉(即能够被理解且符合描述的)的内存模型非常困难,困难程度超出了想象。JSR 133 中修复了早期 JMM 中的缺陷,定义了一个新的 JMM。为了修复这些缺陷,新的 JMM 中修改了原有 final 和 volatile 的语义。
完整的语义可以在这个链接中查看 http://www.cs.umd.edu/users/pugh/java/memoryModel,但是需要提醒的是内容较为深奥,初学者慎入。这些语义惊人且清晰地展示了一些看似简单的概念却是十分复杂的,比如 synchronization。幸运的是,你不需要理解这些正式语义的细节描述。
JSR 133 的目标是创建一系列正式的语义,这些语义提供一个易于理解的框架,你可以清楚地明白 volatile, synchronized, final 是如何生效的。
JSR 133 的目标包括:
- 保留现存的安全保障(例如类型安全检查),同时加强其他的部分。例如,保证变量的值不能够凭空出现;保证每个可以被其他线程可见的变量以及值,必须是可以被其他线程合理替换的值;
- “正确同步”的语义应该尽可能简单易懂、尽可能符合直觉;
- “不正确同步”或者“未完整同步”这些术语的语义应该被明确定义出来,这样可以尽可能的减少安全性被破坏的潜在风险;
- 程序员应该能够自信地解释多线程程序中线程间如何通过内存交互;
- 应该尽可能地去正确设计一个高性能、跨平台的 JVM 实现;
- 应该提供一个安全初始化的新保证(规定)。一旦一个对象正确地构建完成(正确构建完成,就是指当前对象的引用没有在构造未完成时,被其他线程看到),那么其他可以看到这个对象引用的线程,都可以看到这个对象的
final变量值(一般final变量在构造器中初始化),并且不需要额外的同步动作; - 应该尽可能减少对现有 JDK 代码的使用影响。
什么是重排序?
对程序中变量的访问也许会出现实际指令执行顺序与程序员在代码中编写的顺序不一致的情况。
- 编译器为了优化程序的执行效率,可以自由的改变指令的实际执行顺序。
- 处理器也会在某些情况下出现重排序执行指令的现象。
数据在寄存器、处理器缓存、主存中的移动顺序有时会与程序代码中实际编写顺序不一致。
例如,一个线程对变量 a 执行写入操作,然后对变量 b 执行写入操作,并且 a,b 这两个变量的值没有依赖关系,那么编译器可以自由的重排序这些指令,并且缓存可以不受约束的先把 b 的值刷入主存、然后再把 a 的值刷入主存。
编译器、JIT、缓存都可以重排序指令。
似乎应该在编译器、Runtime 和硬件的共同协作下,展现出 as-if-serial 语义。这个 as-if-serial 语义是指:在单线程程序中,程序应该是按照程序员的代码编写顺序执行,观察不到重排序的影响。
然而,重排序会在没有正确同步的多线程程序中展现。在这种未正确同步的多线程程序中,一个线程可能会看到其他线程对程序的影响,可能会看到对变量的访问顺序发生了变化,比如读操作会提前读到它随后的一个写操作的数据。
大多数情况下,一个线程不会在意其他线程的工作。但是一旦需要关注多个线程的活动时(并发编程),这个时候就需要考虑“正确同步”问题了!
旧内存模型的缺陷是什么?
旧 JMM 有很多缺陷。旧 JMM 很难让人理解,所以人们总是不经意间违反 JMM 的要求。比如,在大多数情况下,旧 JMM 不允许重排序在 JVM 中发生。
正是因为旧 JMM 的这些缺陷,促使 JSR-133 中定义了新的 JMM。
有一个这样的共识,如果使用了 final 修饰字段,那么就不需要在线程间额外去做同步工作了,final 可以保证被修饰字段的可见性。
但是在旧 JMM 中,这个合理的假设、合理的行为并不是按照我们的想法工作的。在旧 JMM 中,final 字段与普通字段并无区别,这意味着 final 字段与普通字段一样,在多线程中必须考虑正确的同步。
总之,在旧 JMM 中,我们声明一个 final 字段,并在当前对象的构造器中设置初始值的代码中,在多线程下另外一个线程可能看到这个变量的未初始化的默认值,也可能看到这个变量在构造器中初始化的值。
这就意味着,不可变对象比如 String 对象,可以出现值被改变的情况,这显然是不可接受的。
旧 JMM 允许 volatile 变量的写操作可以与普通变量的读操作、写操作不受约束任意重排序。这是与大多数程序员对 volatile 变量的理解大相径庭的,因此很是让人困惑。
最终,旧 JMM 中程序员总是无法正确判断未正确同步的程序会出现什么样的情况。
JSR-133 的一个目标就是让人们注意这个事实。
“没有正确同步”是什么意思?
“没有正确同步的代码”含义因人而异。当我们在 JMM 上下文中谈到“未正确同步”的代码,我们是指这样的一些代码:
- 线程 A 执行一个变量的写操作;
- 线程 B 执行这个变量的读操作;
- 这两个操作没有被同步处理,也就是说他们执行的先后顺序是不确定的。
当出现了以上类似情况我们通常称在这个变量上存在“数据竞争”(Data Race)。一个存在数据竞争的程序都是没有正确同步过的程序。
(Synchronization)同步具体会做什么?
同步有几个方面,最为人熟知的就是“互斥访问”。同一时刻只有一个线程可以获得一个 Monitor(可以理解为对象锁),所以在一个 Monitor 上的同步块只允许获得这个 Monitor 的线程进入同步块,其他线程都无法获得这个 Monitor,当然也无法进入同步块,必须等到当前同步块中的线程退出同步块,并释放这个 Monitor 后才可尝试进入。
但是同步并不仅仅只是互斥执行,同步确保了一个线程在进入同步块中(或进入同步块之前)的写操作会以一种可预期的方式对同样在这个 Monitor 上同步(也就是说多个线程在一个对象锁上存在同步)的其他线程可见。
当一个线程在退出 synchronized 同步块时,同时释放对象锁(Monitor),同步机制会保证当前线程的缓存数据被刷入主内存,所以这个线程在退出同步块之前的写操作对其他线程可见。
在一个线程进入 synchronized 块之前,首先要尝试获取对象锁(Monitor)。这个线程获取对象锁成功,同时也会使得当前 CPU 缓存数据失效,那么就会重新从系统主存中填充本地缓存。可以看到 Monitor 对象锁的释放和获取都会导致缓存数据刷入主存、缓存数据被重新从主存更新,那么缓存数据都会被及时更新并同步主存,很明显消除了可见性问题。
从缓存的角度来讨论这个问题,似乎这些问题只会影响到多处理器的机器。然而,重排序的影响在单处理器机器中也很容易被发现。
例如,编译器不可能将你的代码移动到获取锁(acquire)之前或释放锁(release)之后。当我们说获取锁和释放锁作用于缓存时,我们是在简略地描述一系列可能的效果。
这个新内存模型的语义实际上展现出来的是一系列内存操作(read、write、lock、unlock)和其他多线程操作(start、join)的部分重排序限制。
这种“部分的重排序”也被称为 happens-before 规则。
如果说 A happens-before B,那么就保证 A 会在 B 之前执行,并且 A 操作对 B 可见。具体的 happens-before 规则如下:
- 同一个线程中的操作,都是按照代码编写的顺序执行。
- 一个对象锁的释放一定会发生在这个锁接下来被获取的操作之前。
- 对一个
volatile变量的写操作一定会发生在随后的这个volatile变量的读操作之前。 - 对一个线程的
start()方法的调用一定会发生在这个线程被启动后执行的任何动作之前。 - 一个线程中的所有操作一定会发生在其他线程成功的从这个线程的
join方法返回之前。
倘若所有的内存操作都在 Monitor 对象锁释放之前发生,并且 Monitor 对象锁的释放都发生在对象锁的获取之前,那么这就是说所有在退出阻塞块之前(释放锁之前)的内存操作,都是对其他同样获取了这个 Monitor 锁并进入了阻塞块的线程可见。
以下的代码模式尝试插入内存栅栏,但是毫无用处:
synchronized (new Object()) {}这个实际是一个空操作,并且你的编译器将会完全移除上边的代码。因为编译器知道不会有第二个线程在同一个对象锁上阻塞(同步)。所以,这个同步毫无用处。
值得注意的是:
正确的使多个线程在某些代码上同步,需要让这些线程都阻塞(同步)在一个相同的对象锁上,这样才可以正确的建立 happens-before 规则。
对线程 A(假设线程 A 在 Object X 上同步)可见的操作将会对线程 B 可见(假设线程 B 在 Object Y 上同步)?当然并不是!
对象锁的获取与对象锁的释放必须要匹配,才能保证正确的语义(比如,获取、释放的是同一个对象锁),否则就是“没有正确同步”(或者称为存在 data race)。
final 字段在新的 JMM 中如何工作?
现在有这么一个对象类,它的 final 字段在这个对象的构造器中完成初始化。假设这个对象被正确地构建(构造器正确完成执行),一旦这个对象被构造完成,不需要额外的任何同步手段,这个对象的所有 final 字段(假设这些 final 字段在构造器中初始化设值)将会对其他线程可见(也就是说其他对象会见到这些 final 字段在构造器中设置的初始值)。
另外,引用这些 final 字段的对象或数组都将会看到 final 字段的最新值。
什么是“对象被正确地构建”?就是被构建对象的引用在构造器执行期间不会被其他线程访问到,就是说构造器执行期间,其他线程无法拿到 this!
换句话说,不要在一个对象正在被构建时,把这个对象的 this 引用暴露给其他线程;不要把 this 赋给一个 static 变量;不要把 this 注册为一个 listener 等等。
总之,所有可能暴露 this 的动作都需要在构造器完成之后进行!
(See Safe Construction Techniques for examples.)
class FinalFieldExample {
final int x;
int y;
static FinalFieldExample f;
public FinalFieldExample() {
x = 3;
y = 4;
}
static void writer() {
f = new FinalFieldExample();
}
static void reader() {
if (f != null) {
int i = f.x;
int j = f.y;
}
}
}上边的这段代码是一个正确使用 final 字段的示例。一个执行 reader 的线程一定能看到且只能看到 f.x 的值为 3,因为 f.x 被 final 修饰过了。但是无法保证 f.y 的值一定是 4,也可能是 0,因为它不是 final 的。
public FinalFieldExample() { // bad!
x = 3;
y = 4;
// bad construction - allowing this to escape
global.obj = this;
}上边这段代码是一个 this 逃逸的示例,错误的初始化 final 字段。在当前线程构造对象期间,其他线程可以访问 this(通过 global.obj),因此无法保证 x 的 final 语义(也就是说其他线程可能看到 x 值为 0)!
通过正确初始化含 final 字段的对象,可以正确的看到 final 变量的值(这里指的变量是基本类型)是非常好的。但是如果这个 final 变量是一个引用类型,并且你希望这个引用指向的最新的对象(或数组)及时可见怎么办?
你还是希望你的代码能够看到引用所指向的这个对象(或者数组)的最新值。如果你的字段是 final 字段,那么这是能够保证的。因此,当一个 final 指针指向一个数组,你不需要担心线程能够看到引用的最新值却看不到引用所指向的数组的最新值。重复一下,这儿的“正确的”意思是“对象构造方法结尾的最新的值”而不是“最新可用的值”。
现在,在讲了如上的这段之后,如果在一个线程构造了一个不可变对象之后(对象仅包含 final 字段),你希望保证这个对象被其他线程正确的查看,你仍然需要使用同步才行。例如,没有其他的方式可以保证不可变对象的引用将被第二个线程看到。使用 final 字段的程序应该仔细的调试,这需要深入而且仔细的理解并发在你的代码中是如何被管理的。
如果你使用 JNI 来改变你的 final 字段,这方面的行为是没有定义的。
volatile 做了什么?
volatile 字段是被用来在线程间交流(通信)状态的字段。每个 volatile 的读操作都可以看到任何其他线程对这个变量的上一次(最新的)写操作的结果;(1. 可见性)
实际上,volatile 字段的用处是杜绝读取到缓存值或者发生关于这个字段的重排序操作。(2. 禁止重排序,happens-before)
JMM 禁止编译器以及运行时环境将 volatile 变量分配在寄存器中(通信顺序:CPU -> register -> cache -> main memory)。volatile 字段会在被写入后,立即将缓存同步到主存中去,那么这些变量因此会立即对其他线程可见。
与此类似,在一个 volatile 变量被读取之前,本地处理器缓存会被置为失效,那么会直接从主存中读数据。
对于对 volatile 变量的访问,还有些其他的约束。
在旧 JMM 中,对于 volatile 变量的各种访问操作不能够相互重排序。但是,volatile 变量的访问却可以与非 volatile 变量的访问重排序。
(也就是说,...; volatile_a=n; volatile_b=x; ...; 可以被重排序为:volatile_a=n; nonvolatile_c=f; y=nonvolatile_d; volatile_b=x;)。
这也就是说,volatile 变量作为线程间状态通知的作用被破坏了。
在新的 JMM 中,对于 volatile 变量的各种访问操作依旧不能够相互重排序。然而与旧 JMM 不同之处在于,对于普通变量的访问操作被重排序到 volatile 变量访问操作之前的这种重排序被更严格的限制了。
对 volatile 变量的写操作类似于 Monitor 对象锁的释放效果,对 volatile 变量的读操作与 Monitor 对象锁的获取有同样的效果。
实际上,这些都是因为新 JMM 在 volatile 访问与非 volatile 访问的重排序问题上加入了更严格的限制。
这里的示例展示了 volatile 变量如何被使用:
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// uses x - guaranteed to see 42.
}
}
}假设线程 A 正在执行 writer 方法,线程 B 正在执行 reader 方法。在 writer 中对 v 的写操作会使得对 x 的写操作被刷入主存中,并且 v 在 reader 中的读操作会直接从主存中访问最新的数据。
那么,如果 reader 执行时 v == true 成立,那么肯定能保证的是在 v 被赋值为 true 的操作之前,x=42 操作肯定被执行了。当然在旧 JMM 中就未必如此了。
如果 v 不是 volatile 变量,那么编译器就可以在 writer 方法中进行重排序,那么 reader 方法中对 x 变量的读取就可能看到是 0。
实际上,volatile 的语义在 JSR 133 中被充分地增强了,几乎达到了 synchronization 的程度。对 volatile 变量的读/写等价于充当了一“半”同步的作用——可见性。
值得注意的是:为了使用 volatile 的语义建立 happens-before 关系,要注意两个线程必须是获取同一个 volatile 变量。
并不是当 Thread A 对一个 volatile 变量 f 执行了写操作,紧接着 Thread B 对 volatile 变量 g 执行了读操作,那么 f 就对 Thread B 可见,g 就都对 Thread A 可见。读写必须要匹配以便保证正确的语义(也就是说读写必须要在同一个 volatile 变量上才行)。
新 JMM 是否修复了“双重检查锁”问题?
声名狼藉的 double-check locking 模式(也是一种多线程的单例发布模式)是个取巧的设计,这个设计为了支持懒加载同时想避免使用 synchronized 造成的开销。
在早期的 JVM,synchronization 是非常慢的,因此程序员都排斥它。所以会有 double-check locking 模式中避免使用 synchronized 的做法:
// double-checked-locking - don't do this!
private static Something instance = null;
public Something getInstance() {
if (instance == null) {
synchronized (this) {
if (instance == null)
instance = new Something();
}
}
return instance;
}看起来似乎非常聪明,尽可能的在公共代码上缩小同步块。但是,它是错误的、无效的。为什么?instance = new Something(); 这行代码是会被编译器或者缓存重排序的,因此会导致它最终返回一个被部分构造的 Something 对象(没有完全初始化的)。结果就是我们获取了一个未初始化成功的对象。
当然这个模式还存在其他问题,并且这个模式的算法修正版本也是错误的。使用旧 JMM,是无法修复这个问题的!
更多细节可参考 Double-checked locking: Clever, but broken;The"Double Checked Locking is broken"declaration。
很多人以为使用 volatile 关键字将会修复这个问题。在 JVM 1.5 版本以前,volatile 关键字并无法保证修复这个问题。但是在新的 JMM 中,用 volatile 修饰 instance 变量就会修复这个问题,因为一个线程 A 初始化 Something 与另一个读取并返回这个对象引用的线程 B 之间建立的 happens-before 关系。
然而使用需求持有者(Demand Holder)模式更好,它不仅线程安全而且非常容易理解。
(通过调用静态工厂方法,去触发一个 static 内部类的 static 变量初始化。static 变量默认的 JVM 阻塞式初始化,并且 static 变量有且仅有一例)
private static class LazySomethingHolder {
public static Something something = new Something();
}
public static Something getInstance() {
return LazySomethingHolder.something;
}这些跟程序员有什么关系?
与你肯定有关系,并发 bug 是非常难于调试解决的。这些并发bug 通常不会再测试阶段暴露,往往在系统高压下暴露出来,并且这些 bug 也难于重新并定位。
你最好花些额外的精力去确保你的程序是正确同步的;然而这些并不容易,但总是要比问题出现再去定位这个未正确同步的程序容易些。也就是说预防总是要比定位解决容易些。
还有少量内容,本人认为对于 JMM 理解并不重要,所以略去。
说明: 本文基于 2004 年 JSR-133 规范(对应 Java 5 时代)翻译整理。虽然 Java 版本已迭代多次,但 JMM 的核心语义(happens-before、volatile、final 语义等)至今仍保持兼容。现代开发中建议优先使用 java.util.concurrent 包提供的高级并发工具。
版权声明:本文为原创文章,版权归 戴老师的博客 所有,转载请联系博主获得授权。
本文地址:https://1diff.fun/archives/fan-yi-jsr-133-java-memory-model-faq.html
如果对本文有什么问题或疑问都可以在评论区留言,我看到后会尽量解答。