daicy
发布于 2019-04-17 / 1275 阅读
0
0

[翻译]JSR 133 (Java Memory Model) FAQ

目录

原文地址:
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#whatismm
Jeremy Manson and Brian Goetz, February 2004

到底什么是内存模型?

在一个多处理器系统中,处理器通常拥有多层缓存。这些多层缓存能够高速获取数据(因为缓存中的数据与处理器更近,cpu—>寄存器—>缓存—>主存)和减少主存的访问频率以降低总线的繁忙程度(因为很多操作可以通过缓存的数据自满足,不需要在主存交互)来提高cpu整体的运行速度。

虽然缓存可以明显的优化性能,但是缓存技术的引入也带来了很多调整。比如:当两个处理器同时访问同一个内存地址时会发生什么?在什么情况下这两个处理器会看到相同的值?缓存的引入会造成数据不一致问题?

在处理器层面,一个内存模型中需要定义必要的、充足的条件限制,这些条件保证了一个处理器写入主存的数据对其他处理器的可见性,并且其他处理器的写入操作也可以对当前处理器可见。

一些处理器设计所展现出的是一个强内存模型:所有处理器读到的同一内存地址的数据都是一致的。还有些处理器设计展现出一个较弱的内存模型:需要一些内存栅栏去将当前处理器缓存的数据刷入主存,或则将当前处理器缓存置为无效。通过这些内存栅栏去保证处理器读写的数据一致性。这些内存栅栏通常在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的值为1后,线程A才开始执行x=1的代码指令。最终线程B看到的就是 r12;r20。

JMM描述的是多线程代码中什么样的行为是合法的,线程间如何通过主存相互通信。JMM描述的是一个程序中变量之间的相互影响;以及一个真实的计算机中,变量在主存中或者寄存器中的读写访问细节。并且JMM的正确实现可以不受各种各样的硬件限制、可以不受 各种各样的编译器优化策略的限制,(跨平台),实现统一的内存模型。

java中的volatile、synchronized、final关键字是为了帮助程序员在java语言层面向编译器提出并发性的需求。JMM规定了 volatile、synchronized的行为,更重要的是确保一个被正确同步的java程序可以畅通无阻的在各种各样的处理器上正确执行

这个问题的总结(非原文):
1.缓存提升cpu整体效率,它的两个优势:

  • 缓存比主存的使用速度快效率高;
  • 缓存通过部门替代主存的使用,减少总线阻塞;
    2.缓存虽然能明显提高cpu整体新能,但会存在缓存一致性问题。
    3.cpu层面,JMM通常必须定义一些约束,以保证一个cpu对主存的写操作,能够被其他cpu可见。JMM有强内存模型、弱内存模型,这里的强指的是在这个模型中,一个cpu(或一个线程)写入内存的数据,对其他cpu(或其他线程)的可见性更强。强、弱内存模型都会使用内存屏障技术保障cache与主存的及时同步,但是强内存模型中使用的内存屏障比较少。弱内存模型会更易于接受、有更好的扩展性
    4.缓存、编译器为了提高效率,会对指令重排序。
    5.JMM描述了多线程代码中什么样的行为是合法的,线程之间如何通过主存相互通信;程序中变量之间的相互关系(happen-before),以及这些变量在主存或寄存器的存取底层细节。并且不因平台的改变而受到影响

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

除java外的其他大多数语言(C、C++)的设计都不是直接支持并发的。对于限制处理器、编译器的重排序的这种必要保护,主要是依赖于第三方线程处理类库(比如 pthreads)完成。

JSR133是什么?

1997年之后,当时的JMM被发现了多个严重的缺陷。这些缺陷会产生一些令人困惑的行为,比如final 字段可以改变自己的值,另外,这些缺陷还包括JMM会不知不觉的破坏编译器通用的优化能力。

JMM在当时是一个很有雄心的尝试,是计算机史上第一次出现的一个编程语言的定义中包含一个JMM的定义,并且这个JMM是一个能够为并发提供一致性语义的内存模型(跨平台的)。不幸的是,定义一个兼备一致性保证和符合直觉(就是能够被理解的符合描述的)的内存模型非常困难,困难程度超出了想象。JSR133中修复了早起JMM中的缺陷,定以了一个新的JMM。为了修复这些缺陷,新的JMM中修改了原有final和volatile的语义。

完整的语义可以在这个链接中查看 http://www.cs.umd.edu/users/pugh/java/memoryModel ,但是需要提醒的是胆小者勿入。这些语义惊人的、清醒的展示了一些看似简单的概念却是十分复杂的,比如 synchronization。幸运的是,你不需要理解这些正式语义的细节描述。

JSR133的目标是创建一系列正式的语义,这些语义提供一个易于理解的框架,你可以清楚的明白 volatile, synchronized,final是如何生效的。

JSR133的目标包括:

  • 保留现存的安全保障(例如类型安全检查),同时加强其他的部分。例如,保证 变量的值不能够凭空出现;保证 每个可以被其他线程可见的变量以及值,必须是可以被其他线程合理替换的值;
  • "正确同步 "的语义应该尽可能简单易懂、尽可能符合直接
  • "不正确同步"或者“未完整同步”这些术语的语义应该被明确定义出来,这样可以尽可能的减少安全性被破坏的潜在风险。
  • 程序员应该能够自信的解释多线程程序中线程间如何通过内存交互;
  • 应该尽可能的去正确设计一个高性能、跨平台的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上下文中谈到“未正确同步”的代码,我们是指这样的一些代码:

1.线程A执行一个变量的写操作;
2.线程B执行这个变量的读操作;
3.这两个操作没有被同步处理,也就是说他们执行的先后顺序是不确定的。

当出现了以上类似情况我们通常称在这个变量上存在“数据竞争”一个存在数据竞争的程序都是没有正确同步过的程序。

(synchronization)同步具体会做什么?

同步有几个方面,最为人熟知的就是“互斥访问"。同一时刻只有一个线程可以获得一个monitor(可以理解为对象锁)**,所以在一个monitor上的同步块只允许获得这个monitor的线程进入同步块,其他线程都无法获得这个monitor,当然也无法进入同步块,必须等到当前同步块中的线程退出同步块,并释放这个monitor后才可尝试进入。

但是同步并不仅仅只是互斥执行,同步确保了一个线程在进入同步块中(或进入同步块之前)的写操作会以一种可预期的方式对同样在这个monitor上同步(也就是说多个线程在一个对象锁上存在同步)的其他线程可见。
当一个线程在退出synchronized同步块时,同时释放对象锁(monitor),同步机制会保证当前线程的缓存数据被刷入主内存,所以这个线程在退出同步块之前的写操作对其他线程可见。

在一个线程进入synchronized 块之前,首先要尝试获取对象锁(monitor)。这个线程获取对象锁成功,同时也会使得当前cpu缓存数据失效,那么就会重新从系统主存中填充本地缓存。可以看到monitor对象锁的释放和获取都会导致缓存数据刷入主存、缓存数据被重新从主存更新,那么缓存数据都会被即使更新并同步主存,很明显消除了可见性问题。

从缓存的角度来讨论这个问题,似乎这些问题只会影响到多处理器的机器。然而,重排序的影响在单处理器机器中也很容易被发现。
It is not possible, for example, for the compiler to move your code before an acquire or after a release. When we say that acquires and releases act on caches, we are using shorthand for a number of possible effects.

这个新内存模型的语义实际上展现出来的是一系列内存操作(read 、write、lock、unlock)和其他多线程操作(start、join)的部分重排序。
这种“部分的重排序”也被称为 happen-before规则。

如果说A happen before B,那么就保证A会在B之前执行,并且A操作对B可见。具体的happen-before规则如下:

* 同一个线程中的操作,都是按照代码编写的顺序执行。
* 一个对象锁的释放 一定会发生在 这个锁接下来被获取的操作 之前
* 对一个volatile变量的写操作 一定会发生在 随后的这个volatile变量的读操作 之前
* 对一个线程的start()方法的调用一定会发生在 这个线程被启动后执行的任何动作 之前
* 一个线程中的所有操作 一定会发生在 其他线程成功的从这个线程的join方法返回 之前

倘若所有的内存操作都在monitor对象锁释放之前发生,并且monitor对象锁的释放都发生在对象锁的获取之前,那么这就是说所有在退出阻塞块之前(释放锁之前)的内存操作,都是对其他同样获取了这个monitor锁并进入了阻塞块的线程可见。

以下的代码模式尝试插入内存栅栏,但是毫无用处:
synchronized (new Object()) {}

这个实际是一个空操作,并且你的编译器将会完全移除上边的代码。因为编译器知道不会有第二个线程在同一个对象锁上阻塞(同步)。所以,这个同步毫无用处。

值得注意的是:
正确的使多个线程在某些代码上同步,需要让这线程都阻塞(同步)在一个相同的对象锁上这样才可以正确的建立happen-before规则
对线程a(假设线程A在object X 上同步)可见的操作将会 对线程B可见(假设线程B在objectY上同步)?然后并不是!
对象锁的获取与对象锁的释放必须要匹配,才能保证正确的语义(比如,获取、释放 的是同一个对象锁),否则就是“没有正确同步”(或者称为存在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.禁止重排序,happen-before)
JMM禁止编译器以及运行时环境将volatile变量分配在寄存器中(通信顺序:cpu—>register—>cache—>main memroy)。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的语义在JSR133中被充分地增强了,几乎达到了synchronization的程度。对volatile变量的读\写等价于充当了一“半”同步的作用——可见性。

值得注意的是:为了使用volatile的语义建立happen-before关系,要注意两个线程必须是获取同一个volatile变量。

并不是当threadA对一个volatile变量 f 执行了写操作,紧接着threadB对volatile变量 g 执行了读操作,
那么 f 就对threadB可见, g.就都对threadA可见。读写必须要匹配以便保证正确的语义(也就是说读写必须要在同一个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关键字将会修复这个问题。在JVM1.5版本以前,volatile关键字并无法保证修复这个问题。但是在新的JMM中,用volatile 修饰 instance 变量就会修复这个问题,因为一个线程A初始化 Something 与另一个读取并返回这个对象引用的线程B 之间建立的 happen-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理解并不重要,所以略去。


评论