JSR 133(Java 内存模型)常见问题解答

ragnar 1年前 ⋅ 240 阅读

到底什么是内存模型?

在多处理器系统中,CPU通常具有一层或多层内存缓存,这样可以加速对数据的访问(因为数据更接近CPU)和减少共享内存总线上的流量(因为许多内存操作可以通过本地缓存满足)来提高性能。内存缓存可以极大地提高性能,但它们也带来了许多新的挑战。例如,当两个处理器同时检查同一内存位置时会发生什么?在什么条件下它们会看到相同的值?

处理器级别上,内存模型定义了其他处理器对内存的写入什么时候对当前处理器可见,以及当前处理器的写入什么时候对其他处理器可见所需的必要和充分条件。一些处理器展示了强内存模型,在这种模型中,所有处理器始终看到任何给定内存位置的完全相同的值。其他处理器展示了较弱的内存模型,在这种模型中,需要特殊指令(称为内存屏障)来刷新或使本地处理器缓存失效,以便看到其他处理器进行的写入或使本处理器进行的写入对其他处理器可见。这些内存屏障通常在执行锁定和解锁操作时执行;它们对高级语言中的程序员是不可见的。

有时编写强内存模型的程序可能更容易,因为减少了对内存屏障的需求。然而,即使在一些最强的内存模型上,内存屏障也经常是必要的;它们放置的位置往往是反直觉的。处理器设计的最新趋势鼓励使用较弱的内存模型,因为它们对缓存一致性的放宽(relaxations)允许跨多个处理器和更大的内存量实现更大的可伸缩性。

编译器对代码的重新排序使得写操作何时对另一个线程可见的问题变得更加复杂。比如,编译器可能将写操作移动到程序的后面更加高效;只要这种代码移动不改变程序的语义,编译器就可以自由地这样做。如果编译器推迟了一个操作,另一个线程将不会看到它,直到它被执行;这反映了缓存的效果。

此外,写入内存的操作也可以在程序中提前执行;在这种情况下,其他线程可能会在程序中实际“发生”之前看到写入。所有这些灵活性都是有意设计的——通过给编译器、运行时或硬件执行操作的灵活性,我们可以在内存模型的范围内实现更高的性能。

在下面的代码中可以看到一个简单的示例:

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。由于这个写入在x的写入之后,程序员可能会认为x的读取必须看到值1。然而,写入可能已经被重新排序。如果发生这种情况,那么y的写入可能会发生,两个变量的读取可能会跟随,然后x的写入可能会发生。结果将是r1的值为2,但r2的值为0。

Java内存模型描述了多线程代码中哪些行为是合法的,以及线程如何通过内存进行交互。它描述了程序中变量与实际计算机系统中存储和检索它们到内存或寄存器的低级细节之间的关系。它以一种可以使用各种硬件和各种编译器优化正确实现的方式来实现这一点。

Java包括几个语言结构:volatile、final和synchronized,旨在帮助程序员向编译器描述程序的并发要求。Java内存模型定义了volatile和synchronized的行为,并且更重要的是,确保正确同步的Java程序在所有处理器架构上正确运行。

C++等其他语言是否有内存模型?

大多数其他编程语言(如C和C ++)并没有直接支持多线程。这些语言提供的保护措施,以防止编译器和架构中发生的重新排序,严重依赖于所使用的线程库(如pthreads)、编译器和代码运行的平台提供的保证。

JSR 133是什么?

自1997年以来,在Java语言规范的第17章中定义的Java内存模型中发现了几个严重的缺陷。这些缺陷导致了令人困惑的行为(例如观察到final字段更改其值),并削弱了编译器执行常见优化的能力。

Java内存模型是一项雄心勃勃的任务;这是编程语言规范首次尝试将内存模型纳入其中,以便在各种架构上提供一致的并发语义。不幸的是,定义既一致又直观的内存模型比预期的要困难得多。JSR 133为Java语言重新定义了一个新的内存模型,修复了早期内存模型的缺陷。为了做到这一点,final和volatile的语义需要改变。

JSR 133的目标是创建一组形式语义,为易失性、同步和最终工作提供直观的框架,虽然完整的语义在 http://www.cs.umd.edu/users/pugh/java/memoryModel 中可用,但正式的语义并不适合胆小的人。发现像同步这样的看似简单的概念有多么复杂是令人惊讶和警醒的。

JSR 133的目标包括:

  • 保留现有的安全保证,如类型安全,并加强其他保证。例如,变量值不能“凭空”创建:某个线程观察到的变量值必须是某个线程可以合理地放置在那里的值。

  • 正确同步程序的语义应尽可能简单和直观。

  • 应定义不完全或不正确同步程序的语义,以最小化潜在的安全风险。

  • 程序员应该能够自信地推断多线程程序与内存的交互方式。

  • 应该能够在广泛的流行硬件架构上设计正确、高性能的JVM实现。

  • 提供新的初始化安全保证。如果一个对象被正确构造(这意味着在构造期间对它的引用不会逃逸),那么所有看到该对象引用的线程也将看到在构造函数中设置的其final字段的值,无需同步。

  • 对现有代码的影响应该最小。

重新排序是什么意思?

程序变量(对象实例字段、类静态字段和数组元素)的访问可能会以与程序指定的顺序不同的顺序执行。编译器可以在优化的名义下自由地对指令排序。处理器在某些情况下可能会乱序执行指令。数据可能以与程序指定的顺序不同的顺序在寄存器、处理器缓存和主存储器之间移动。

例如,如果线程先写入字段 a,然后写入字段 b,并且 b 的值不依赖于 a 的值,则编译器可以自由地对这些操作进行重新排序,并且CPU缓存可以自由地将 b 刷新到主内存,然后再写入 a。所以,有许多潜在的重新排序源头,例如编译器、JIT 和CPU缓存。

编译器、runtime 和硬件应该共同创造“仿佛串行(as-if-serial)语义”的错觉,这意味着在单线程的程序中,程序不应该能够观察到重新排序的影响。然而,在不正确同步的多线程程序中,重新排序可能会发挥作用,其中一个线程能够观察到其他线程的影响,并且可能能够检测到变量访问以不同于程序中执行或指定的顺序变为可见的情况。

大多数时候,一个线程并不关心另一个线程在做什么。但是当它这样做时,这就是同步的用途。

旧内存模型有什么问题?

旧的内存模型存在几个严重问题。它很难理解,因此被广泛违反。例如,旧模型在许多情况下不允许发生在每个JVM中发生的重新排序。对旧模型影响的混淆是促使JSR-133形成的原因。

例如,广泛认为,如果使用了final字段,则不需要在线程之间进行同步,以保证另一个线程可以看到字段的值。虽然这是一个合理的假设和明智的行为,而且我们确实希望事情能够这样工作,但在旧的内存模型下,这简直是不真实的。旧的内存模型中没有任何东西将final字段与任何其他字段区别对待——这意味着同步是确保所有线程看到构造函数写入的final字段值的唯一方法。因此,一个线程可能会看到字段的默认值,然后在稍后的某个时间看到它的构造值。这意味着,例如,像String这样的不可变对象可能会出现改变其值的情况——这确实是一个令人不安的前景。

旧的内存模型允许将volatile字段的写入与非volatile字段的读取和写入进行重新排序,这跟大多数开发人员对volatile的直觉不一致,因此引起了混淆。

最后,正如我们将看到的那样,程序员对于当他们的程序未正确同步时可能发生的情况的直觉经常是错误的。JSR-133的目标之一是引起人们对这一事实的关注。

什么是“不正确同步”?

Java内存模型中,不正确同步的代码对不同的人意味着不同的事情。当我们谈论Java内存模型中不正确同步的代码时,我们指的是
1、任何一个线程写入变量,
2、另一个线程读取同一变量,
3、且写入和读取未经同步排序的代码。
当这些规则被违反时,我们称该变量存在数据竞争。存在数据竞争的程序是不正确同步的程序。

同步有什么作用?

同步有几个方面。最为人所知的是互斥(mutual exclusion)—— 一次只能有一个线程持有监视器(monitor),因此在监视器上同步意味着一旦一个线程进入由监视器保护的同步块,其他线程就不能进入由该监视器保护的块,直到第一个线程退出同步块。

但是同步不仅仅是互斥。同步确保线程在同步块之前或之中的内存写入以可预测的方式对其他在同一监视器上同步的线程可见。在退出同步块后,我们释放(release)监视器,这会将缓存刷新到主内存中,以便其他线程可以看到此线程所做的写入。在我们进入同步块之前,我们获取(acquire)监视器,这会使本地处理器缓存无效,以便变量将从主内存中重新加载。然后,我们将能够看到由先前发布的所有可见写入。

谈论CPU缓存问题时,可能会听起来好像这些问题只影响多处理器机器。然而,重排序效应可以很容易地在单个处理器上看到。例如,编译器无法将您的代码移动到获取之前或发布之后。当我们说获取和发布作用于CPU缓存时,我们使用简写表示可能的多种效果。

新的内存模型语义在内存操作(read字段、write字段、lock、unlock)和其他线程操作(start和join)上创建了部分排序,其中一些操作被认为是在其他操作之前发生(happen before)的。当一个操作在另一个操作之前发生时,第一个操作保证在第二个操作之前有序并可见。这种排序的规则如下:

  • 每个线程中的每个操作都发生在程序顺序中稍后发生的每个操作之前。
  • 对监视器的解锁发生在对同一监视器的每个后续锁之前。
  • 对volatile字段的写入发生在对同一volatile字段的每个后续读取之前。
  • 对线程上的start()的调用发生在启动的线程中的任何操作之前。
  • 一个线程中的所有操作都发生在任何其他线程成功从该线程的join()返回之前。

这意味着在退出同步块之前对线程可见的任何内存操作都对在进入由相同监视器保护的同步块的任何线程可见,因为所有内存操作都发生在释放之前,而释放发生在获取之前。

另一个含义是,以下模式,有些人用于强制内存屏障,不起作用:

synchronized (new Object()) {}

这实际上是一个空操作,您的编译器可以完全删除它,因为编译器知道没有其他线程会在同一监视器上同步。您必须为一个线程设置一个 happens-before 关系才能看到另一个线程的结果。

重要提示:请注意,为了正确建立happen-before的关系,两个线程都必须在同一个监视器上同步。当线程A在对象X上同步时,对线程A可见的所有内容并不会在线程B在对象Y上同步后变得对线程B可见。释放和获取必须“匹配”(即在同一个监视器上执行)才能具有正确的语义。否则,代码将存在数据竞争。

为什么final字段的值会发生变化?

如何看到final字段的值发生变化的最佳示例之一涉及 String 类的一个特定实现。

String 可以实现为具有三个字段的对象:一个字符数组、一个数组的偏移量和一个长度。以这种方式实现 String (而不是只有字符数组)的基本原理是,它允许多个 String 和 StringBuffer 对象共享相同的字符数组,并避免额外的对象分配和复制。因此,例如,String.substring() 方法可以通过创建一个新字符串来实现,该字符串与原始字符串共享相同的字符数组,只是在长度和偏移字段上有所不同。对于 String,这些字段都是最终字段。

String s1 = "/usr/tmp";
String s2 = s1.substring(4); 

字符串 s2 的偏移量为 4,长度为 4。但是,在旧模型下,另一个线程可能会看到偏移量的默认值为 0,然后看到正确的值 4,看起来好像字符串“/usr”更改为“/tmp”。

原始的 Java 内存模型允许这种行为;一些 JVM 已经表现出这种行为。新的 Java 内存模型禁止了这种行为。

在新的JMM下,final字段是如何工作的?

对象的final字段的值在其构造函数中设置。假设对象是“正确”构造的,一旦构造了对象,分配给构造函数中final字段的值将对所有其他线程可见,而无需同步。此外,这些final字段引用的任何其他对象或数组的可见值将至少与final字段一样是最新的。

正确构造对象意味着什么?它只是意味着在构造过程中不允许对正在构造的对象进行引用“逃逸”。(有关示例,请参阅 Safe Construction Techniques http://www-106.ibm.com/developerworks/java/library/j-jtp0618.html。 换言之,不要将对正在构造的对象的引用放在另一个线程可能能够看到它的任何位置;不要将其分配给静态字段,不要将其注册为任何其他对象的侦听器,等等。这些任务应在构造函数完成后完成,而不是在构造函数中完成。

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,因为它是 final 修饰的。不能保证看到 y 的值 4,因为它不是 final。如果 FinalFieldExample 的构造函数改成下面这样:

public FinalFieldExample() { // bad!
  x = 3;
  y = 4;
  // bad construction - allowing this to escape
  // 坏的构造 - 在构造中,把当前对象赋值给了一个全局的变量
  global.obj = this;
}

然后,从 global.obj 读取对这个对象的引用的线程不能保证看到 x 的 3。

能够看到字段的正确构造值是很好的,但如果字段本身是一个引用,那么你还希望你的代码能够看到它所指向的对象(或数组)的最新值。如果您的字段是final字段,这也是有保证的。因此,您可以有一个指向数组的final指针,而不必担心其他线程看到的数组引用的正确值,但数组内容的值不正确。同样,这里所说的“正确”是指“截至对象构造函数末尾的最新”,而不是“可用的最新值”。

现在,说了这么多,如果在一个线程构造一个不可变对象(即一个只包含final字段的对象)之后,你希望确保所有其他线程都能正确地看到它,你通常仍然需要使用同步。例如,没有其他方法可以确保对不可变对象的引用将被第二个线程看到。程序从final字段获得的保证应该仔细调整,并深入了解如何在代码中管理并发性。

如果要使用 JNI 更改最终字段,则没有定义的行为。

volatile是什么?

volatile字段是用于在线程之间传递状态的特殊字段。每次读取volatile字段时,任何线程都会看到对该volatile字段的最后一次写入;实际上,它们被程序员是指定的字段,由于缓存或重新排序而看到“过时(stale)”的值是绝对不可接受的。禁止编译器和运行时在寄存器中分配它们。它们还必须确保在写入后,将它们从缓存中刷新到主内存中,以便它们可以立即对其他线程可见。同样,在读取volatile字段之前,必须使缓存失效,以便主内存中的值(而不是本地处理器缓存中的值)是看到的值。对volatile变量的访问进行重新排序还有其他限制。

在旧的内存模型下,对volatile变量的访问操作不能相互之间重新排序,但可以跟没有volatile修饰的变量进行重新排序。这削弱了volatile作为将条件从一个线程传递到另一个线程的手段的有效性。

在新的内存模型下,volatile变量之间的操作仍然不能相互重新排序。不同之处在于,现在不再那么容易对它们周围的正常字段访问操作进行重新排序。写入volatile字段的操作具有与监视器的释放(release)相同的内存影响,从volatile字段读取的操作具有与监视器的获取(acquire)相同的内存影响。实际上,由于新的内存模型对volatile字段访问与其他字段访问(无论是否用volatile修饰)的重新排序施加了更严格的约束,因此线程 A 在写入volatile字段 f 时可见的任何内容在读取 f 时对线程 B 也可见。

下面是如何使用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.
    }
  }
}

假设一个线程正在调用 writer() 方法,而另一个线程正在调用 reader() 方法。writer()方法中对 v 变量的写入,会把对 x 变量的写入也一样刷回到内存中,而对 v 变量的读取则从内存中获取该值。因此,如果reader() 方法中看到 v 的值为 true,则还可以保证看到之前发生的对 42 的写入。在旧的内存模型下,情况并非如此。 如果 v 不是volatile修饰的,那么编译器可以对 writer() 方法中的写入进行重新排序,读者对 x 的读取可能会看到 0。

实际上,volatile 的语义得到了实质性的加强,几乎达到了同步的水平。为了可见性,对volatile字段的每次读取或写入都像是同步(sychronized)的“一半”。

重要说明:请注意,两个线程必须是访问相同的volatile变量,以便正确设置 happen-before 关系。并不是线程 A 在写入volatile字段 f 时可见的所有内容在读取volatile字段 g 后对线程 B 可见。释放(release)和获取(acquire)必须“匹配”(即在相同的volatile字段上执行)才能具有正确的语义。

新的内存模型是否修复了“双重检查锁定”问题?

双重检查锁定习惯用法(也称为多线程单例模式)是一种技巧,旨在支持延迟初始化,同时避免同步开销。在非常早期的 JVM 中,同步速度很慢,开发人员急于删除它——也许是太急切了。双重检查的锁定如下所示:

``java

// 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; }


这看起来非常聪明 -- 在公共代码路径上避免了同步。它只有一个问题——它不起作用。为什么呢?最明显的原因是,初始化实例的写入和对实例字段的写入可以由编译器或缓存重新排序,这将产生返回看似部分构造的 Something 的效果。结果是我们读取了一个未初始化的对象。还有很多其他原因可以解释为什么这是错误的,以及为什么算法更正是错误的。没有办法使用旧的 Java 内存模型来修复它。更深入的信息可以在[Double-checked locking: Clever, but broken 双重检查锁定:聪明,但已损坏](http://www.javaworld.com/jw-02-2001/jw-0209-double.html) 和[The "Double Checked Locking is broken" declaration “双重检查锁定已损坏”声明中找到](http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html)

许多人认为,使用volatile关键字可以消除尝试使用双重检查锁定模式时出现的问题(对象半初始化)。在 1.5 之前的 JVM 中,volatile 无法确保它正常生效(你用的版本可能会有所不同)。在新的内存模型下,使实例字段用 volatile 修饰将“修复”双重检查锁定的问题,因为这样一来,构造线程初始化 Something 与读取它的线程返回其值之间将存在 happen-before 关系。

相反,请使用 Initialization On Demand Holder 惯用语,它是线程安全的,并且更容易理解:

```java
private static class LazySomethingHolder {
  public static Something something = new Something();
}

public static Something getInstance() {
  return LazySomethingHolder.something;
}

由于静态字段的初始化保证,此代码保证是正确的;如果在静态初始值设定项中设置了字段,则可以保证该字段对访问该类的任何线程都正确可见。

如果我正在编写VM怎么办?

You should look at http://gee.cs.oswego.edu/dl/jmm/cookbook.html .

我为什么要关心?

你为什么要关心?并发 bug 很难调试。它们通常不会出现在测试中,而是等到程序在重负载下运行,并且难以重现和捕获。你最好提前花额外的努力来确保你的程序正确同步;虽然这并不容易,但比尝试调试同步不良的应用程序要容易得多。

原文:《JSR 133 (Java Memory Model) FAQ》 https://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html


全部评论: 0

    我有话说:

    目录