Java Memory Model,Java 内存模型,以下简称为 JMM .原文作者:Jeremy Manson/ Brian Goetz.本文章仅节选其核心段落翻译.

到底什么是内存模型?

在多核心处理器系统中,处理器通常有多层缓存,这提高了数据访问速度并降低了共享内存总线流量.这在提升性能的同时也带来一系列挑战,比如:两个处理器同一时间造访同一内存位置会发生什么?在哪种条件下他们才能获取到相同的值?

在处理器层面,内存模型定义必要充分条件以达成共识:其他处理器在内存的写对当前处理器是可见的,当前处理器的内存写对其他处理器同样是可见.强内存模型处理器给定的内存位置在任何时间所有处理器都只会读到相同值.弱内存模型的处理器需要使用名为内存栅栏的特殊指令将处理器本地缓存 flush 或废弃,以便能获取到其他处理器的写或让自己的写被其他处理器所获取.通常在锁操作时执行这些内存栅栏,在高级语言层面编码可见.

对内存栅栏需求的减少可以更容易编写强内存模型程序.但即使在强度最高的内存模型中,内存栅栏常常也是必要的.最近处理器设计趋势是更弱的内存模型,因为对缓存一致性更低的要求让处理器间更具伸缩性与更大的内存.

跨线程间的写可见问题也受编译器指令重排的影响.比如:编译器认为将程序中写操作移动到更后的位置可以提升性能时,只要这次移动不会改变程序主义,便可随意移动.编译器更偏向一个操作,另一线程在其执行前不会看到重排,这也影响着缓存效果.

此外,内存写命令在程序很容易地被重排,在这种情况下,其他线程可能会在程序中看到并认为一个并未执行的写已经执行过.所有的来自编译器/运行时/硬件的灵活性设计将以最优顺序执行,在内存模型的范围实现最优性能.

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
Class Reordering {
int x = 0, y = 0;
public void writer() {
x = 1;
y = 2;
}

public void reader() {
int r1 = y;
int r2 = x;
}
}

假设两个线程并发执行以上代码,其他一线程看读取 y 值为 2.因为 y 的写操作在 x 后,编码者会假设这个时候 x 已经被写为 1 了.但写操作可能已经被指令重排了.指令重排的情况下,执行顺序可能变为: y 被写为 2 ,然后读取两个变量 x y,最后再写 x=1.这样的读取的结果就是 r1 的值是 2, 而 r2 的值是 0.

JMM(Java 内存模型)描述了多线程编程中哪些行为是合法与线程如何通过内存交互.它描述了一个程序中变量间的关系与在一个真实计算机系统的内存或寄存器中存储与获取的低级别细节.这都要求在使用多类别的硬件与多类别编译器优化下能正确实现.

Java 的几种语言结构包括: volatile/final/synchronized,这些都用以对编译器描述对程序的并发需求.Java 内存模型定义了 volatile 与 synchronized 的行为,更重要的是保证一个正确同步的 Java 程序在所有多处理器架构中正确运行.

其他语言(如:C++)有内存模型吗?

其他大部分编程语言,诸如:C / C++ ,并未直接支持多线程.这些语言针对发生在编译器与架构的重排序的保护依赖于使用的线程库(如:pthreads)、所使用的编译器和代码运行的平台.

Synchronization 同步到底做了什么?

  • Synchronization 同步存在多方面含义。最为人所知的是恒定排他性:一旦某个线程获取到 monitor ,monitor 上的同步意味着一个线程进入了 monitor 所保护的同步块,在此线程退出此同步块前没有其他线程能够再进入。
  • 同步同时保证一个线程在进入同步块前与中的写对于其他在相同 monitor 的线程保持可预测的可见性。当退出同步块,会释放 monitor ,这会将缓存中的数据 flush 到主存中,让此线程的写对其他线程可见。在进入同步块前,我们需要获取 monitor ,这带来的内存效应是将处理器 processor 的缓存置为无效,这样变量就必须从主存中重载。这样我们看到的效果就是前一次释放的所有写都可见。
  • 讨论缓存时并不意味问题只发生在多核心机器中,单核心机器中轻易地产生重排序。比如:编译器不可能在在获取前或释放后移动代码。我们在讨论获取与释放缓存时是省了很多可能的效果。
  • 新 JMM 语义创建了内存操作(read field, write field, lock, unlock)与其他线程的操作(start, join)的局部顺序,在此语义下,一些操作 happens-before 其他操作。当一个操作 happens-before 另一个操作,第一个操作保证排在第二个操作前,且对于第二个操作来说,第一个的所有操作对第二个可见。其中规则包括:
    • 程序命令指定下,一个线程所有操作都发生在后一个线程所有操作前;
    • 一个 monitor 的解锁 unlock 发生在此 monitor 后来的锁 lock 之前;
    • 同一个 volatile 下,写发生在后来的读之前;
    • 一个线程的 start() 方法调用执行发生成这个线程的所有行为(action)之前;
    • 一个线程的所有行为 action 发生在其 join() 方法调用成功返回的线程之前。
  • 这就意味着:同一个 monitor 保护下,一个退出同步块之前的线程可见的所有内存操作对任何进入同步块后线程都是可见的。因为所有的内存操作在 release 是发生,而所有的 release 又在 acquire 前发生。

**Important Note:**为保证构建正确的 happens-before 关系,两个线程需要在同一个 monitor 上进行同步。线程A同步在X上的可见对同步在Y上的线程B并不可见。release 与 acquire 需要匹配在同一个 monitor 上才能执行正确的语义。

新版 JMM 中 final 字段如何工作?

  • 对象的常量字段都在其构造器内赋值。
  • 一旦构造完成,即使没有添加 synchronization,常量字段的数据可以被其他所有线程可见。此外,常量字段所引用的对象或数组的可见值将被更新到与常量字段一样保持最新。
  • 一个对象正确的构造意味着:在构造期间对象的引用没有“逃逸”。换句话说:不要将被构造对象的引用置于任何其他线程可见的位置,不要将其指向静态字段,不要将其注册为其他任何对象的监听器,等等。这些操作应该在对象构造完成之后进行,而不是在构造期间进行。
  • 正确构造同样保证了引用常量字段所指向的对象或数组值在构造后依然是最新的值,所以可以让常量的指针指向对象或数组,而不用担心其他线程看到正确的引用看不到引用的值。但在这里最新的值仅仅是指构造完成后,不是所有的阶段。
  • 对于一个不可变对象(所有字段都是常量)被一个线程构造完成后,如果其他所有线程想要正确可见,仍然需要使用 synchronization 。并没有其他途径可以保证,这就要求程序获取常量字段代码对于并发的管理理解深入而细致,
  • JMM 对于使用 JNI 修改常量字段的行为并没有定义。

volatile 做了什么?

  • volatile 字段用于线程间状态交流。对于 volatile 字段的读取都会获取到其他程的最后一次写。volatile 被设计用于不接受因缓存(cache)或指令重排(reordering)导致的 stale 值的字段。
  • volatile 维持半同步,用于标识字段以让在 processor 缓存中,被一个线程修改后立即被 flush 到主存中,编译器与运行时被禁止将 volatile 字段值置于 processor 寄存器中,从而保证其他线程对其修改可见。
  • 指令重排限制:
    • 老版 JMM 不允许对 volatile 字段进行 reordering ,但可以对非 volatile 字段进行重排。这让 volatile 字段成了一个线程间信号条件的方式。
    • 新版 JMM 除会限制字段不能被指令重排(reordering),同时要求 volatile 字段周围的字段都不能轻易被 reordering 。
    • volatile 字段在修改与释放 monitor 内存效果一致(将 processor 缓存数据 flush 到主存中,从而其他线程可见),在读取与获取 monitor 内存效果一致(将本地处理器缓存中数据置为无效,变量值不得不从主存中读取)。

**Important Note:**多线程访问 volatile 变量都是为了合适地设置 happens-before 关系。并不是线程A在写 volatile field f 时所有可见在线程B访问 volatile field g 后都可见。释放与获取锁需要匹配到相同的 volatile 字段才能保证语义正确。

新版 JMM 是否修复了双检锁问题?

  • 单例模式中很多人喜欢使用双检锁模式,认为其可以降低线程阻塞概率。
1
2
3
4
5
6
7
8
9
10
11
12
//double-checked locking, Don't do like this!
private static Ins ins = null;
public Ins getIns(){
if(ins == null){
synchronized(this){
if(ins == null){
ins = new Ins();
}
}
}
return ins;
}
  • 上段代码的问题在于:在 synchronized 代码块中, ins 的初始化与赋值指令可能会被编译器或缓存重排,从而导致 ins 在某一时期内依然是个半初始化的对象,在这期间 synchronized 块外部其他读取 ins 的线程就会读取到这个半初始化的 ins 对象,就会产生使用未初始化完成的 ins 错误。
  • 在老版JMM 中添加 volatile 关键字到 ins 字段前并不能解决问题,在 JVM1.5 新版 JMM 的 volatile 可以解决问题。在使用 volatile 修辞后的 ins 并不会出现指令重排,也构成了内部线程初始化与外部线程读取的 happens-before 关系(一旦 ins 开始初始化,其他线程必须对其结果可见,也就是需要在其初始化完成前才读取到)。