Skip to content

Instantly share code, notes, and snippets.

@yulewei
Last active August 23, 2023 11:20
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save yulewei/400f76e9cdcc9921470a8967d068319d to your computer and use it in GitHub Desktop.
Java内存模型FAQ

转载自:http://ifeve.com/jmm-faq/

原文:http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html

作者:Jeremy Manson, Brian Goetz, 2004.02

译者:Alex,方腾飞

目录

什么是内存模型?

在多核系统中,处理器一般有一层或者多层的缓存,这些的缓存通过加速数据访问(因为数据距离处理器更近)和降低共享内存在总线上的通讯(因为本地缓存能够满足许多内存操作)来提高 CPU 性能。缓存能够大大提升性能,但是它们也带来了许多挑战。例如,当两个 CPU 同时检查相同的内存地址时会发生什么?在什么样的条件下它们会看到相同的值?

在处理器层面上,内存模型定义了一个充要条件,“让当前的处理器可以看到其他处理器写入到内存的数据”以及“其他处理器可以看到当前处理器写入到内存的数据”。有些处理器有很强的内存模型(strong memory model),能够让所有的处理器在任何时候任何指定的内存地址上都可以看到完全相同的值。而另外一些处理器则有较弱的内存模型(weaker memory model),在这种处理器中,必须使用内存屏障(一种特殊的指令)来刷新本地处理器缓存并使本地处理器缓存无效,目的是为了让当前处理器能够看到其他处理器的写操作或者让其他处理器能看到当前处理器的写操作。这些内存屏障通常在 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变量将会得到 2 这个值。因为这个写入比写到 x 变量更晚一些,程序员可能认为读取 x 变量将肯定会得到 1。但是,写入操作可能被重排序过。如果重排序发生了,那么,就能发生对 y 变量的写入操作,读取两个变量的操作紧随其后,而且写入到 x 这个操作能发生。程序的结果可能是 r1 变量的值是 2,但是 r2 变量的值为 0

Java 内存模型描述了在多线程代码中哪些行为是合法的,以及线程如何通过内存进行交互。它描述了“程序中的变量“和”从内存或者寄存器获取或存储它们的底层细节”之间的关系。Java 内存模型通过使用各种各样的硬件和编译器的优化来正确实现以上事情。

Java 包含了几个语言级别的关键字,包括:volatile, final 以及 synchronized,目的是为了帮助程序员向编译器描述一个程序的并发需求。Java 内存模型定义了 volatilesynchronized 的行为,更重要的是保证了同步的 Java 程序在所有的处理器架构下面都能正确的运行。

其他语言,像 C++,也有内存模型吗?

大部分其他的语言,像 C 和 C++,都没有被设计成直接支持多线程。这些语言对于发生在编译器和处理器平台架构的重排序行为的保护机制会严重的依赖于程序中所使用的线程库(例如 pthreads),编译器,以及代码所运行的平台所提供的保障。

JSR 133 是什么?

从 1997 年以来,人们不断发现 Java 语言规范的 17 章定义的 Java 内存模型中的一些严重的缺陷。这些缺陷会导致一些使人迷惑的行为(例如 final 字段会被观察到值的改变)和破坏编译器常见的优化能力。

Java 内存模型是一个雄心勃勃的计划,它是编程语言规范第一次尝试合并一个能够在各种处理器架构中为并发提供一致语义的内存模型。不过,定义一个既一致又直观的内存模型远比想象要更难。JSR 133 为 Java 语言定义了一个新的内存模型,它修复了早期内存模型中的缺陷。为了实现 JSR 133,finalvolatile 的语义需要重新定义。

完整的语义见:http://www.cs.umd.edu/users/pugh/java/memoryModel,但是正式的语义不是小心翼翼的,它是令人惊讶和清醒的,目的是让人意识到一些看似简单的概念(如同步)其实有多复杂。幸运的是,你不需要懂得这些正式语义的细节—— JSR 133 的目的是创建一组正式语义,这些正式语义提供了 volatilesynchronziedfinal 如何工作的直观框架。

JSR 133 的目标包含了:

  • 保留已经存在的安全保证(像类型安全)以及强化其他的安全保证。例如,变量值不能凭空创建:线程观察到的每个变量的值必须是被其他线程合理的设置的。
  • 正确同步的程序的语义应该尽量简单和直观。
  • 应该定义未完成或者未正确同步的程序的语义,主要是为了把潜在的安全危害降到最低。
  • 程序员应该能够自信的推断多线程程序如何同内存进行交互的。
  • 能够在现在许多流行的硬件架构中设计正确以及高性能的 JVM 实现。
  • 应该能提供安全地初始化的保证。如果一个对象正确的构建了(意思是它的引用没有在构建的时候逸出,那么所有能够看到这个对象的引用的线程,在不进行同步的情况下,也将能看到在构造方法中中设置的 final 字段的值。
  • 应该尽量不影响现有的代码。

重排序是什么?

在很多情况下,访问一个程序变量(对象实例字段,类静态字段和数组元素)可能会使用不同的顺序执行,而不是程序语义所指定的顺序执行。编译器能够自由的以优化的名义去改变指令顺序。在特定的环境下,处理器可能会次序颠倒的执行指令。数据可能在寄存器,处理器缓冲区和主内存中以不同的次序移动,而不是按照程序指定的顺序。

例如,如果一个线程写入值到字段 a,然后写入值到字段 b,而且 b 的值不依赖于 a 的值,那么,处理器就能够自由的调整它们的执行顺序,而且缓冲区能够在 a 之前刷新 b 的值到主内存。有许多潜在的重排序的来源,例如编译器,JIT 以及缓冲区。

编译器,运行时和硬件被期望一起协力创建好像是顺序执行的语义的假象,这意味着在单线程的程序中,程序应该是不能够观察到重排序的影响的。但是,重排序在没有正确同步了的多线程程序中开始起作用,在这些多线程程序中,一个线程能够观察到其他线程的影响,也可能检测到其他线程将会以一种不同于程序语义所规定的执行顺序来访问变量。

大部分情况下,一个线程不会关注其他线程正在做什么,但是当它需要关注的时候,这时候就需要同步了。

旧内存模型有什么错误?

旧的内存模型中有几个严重的问题。这些问题很难理解,因此被广泛的违背。例如,旧的存储模型在许多情况下,不允许 JVM 发生各种重排序行为。旧的内存模型中让人产生困惑的因素造就了 JSR-133 规范的诞生。

例如,一个被广泛认可的概念就是,如果使用 final 字段,那么就没有必要在多个线程中使用同步来保证其他线程能够看到这个字段的值。尽管这是一个合理的假设和明显的行为,也是我们所期待的结果。实际上,在旧的内存模型中,我们想让程序正确运行起来却是不行的。在旧的内存模型中,final 字段并没有同其他字段进行区别对待——这意味着同步是保证所有线程看到一个在构造方法中初始化的 final 字段的唯一方法。结果——如果没有正确同步的话,对一个线程来说,它可能看到一个字段的默认值,然后在稍后的时间里,又能够看到构造方法中设置的值。这意味着,一些不可变的对象,例如 String,能够改变它们值——这实在很让人郁闷。

旧的内存模型允许 volatile 变量的写操作和非 volaitle 变量的读写操作一起进行重排序,这和大多数的开发人员对于 volatile 变量的直观感受是不一致的,因此会造成迷惑。

最后,我们将看到的是,程序员对于程序没有被正确同步的情况下将会发生什么的直观感受通常是错误的。JSR-133 的目的之一就是要引起这方面的注意。

没有正确同步的含义是什么?

没有正确同步的代码对于不同的人来说可能会有不同的理解。在 Java 内存模型这个语义环境下,我们谈到“没有正确同步”,我们的意思是:

  1. 一个线程中有一个对变量的写操作,
  2. 另外一个线程对同一个变量有读操作,
  3. 而且写操作和读操作没有通过同步来保证顺序。

当这些规则被违反的时候,我们就说在这个变量上有一个“数据竞争”(data race)。一个有数据竞争的程序就是一个没有正确同步的程序。

同步会做些什么呢?

同步有几个方面的作用。最广为人知的就是互斥 ——一次只有一个线程能够获得一个监视器,因此,在一个监视器上面同步意味着一旦一个线程进入到监视器保护的同步块中,其他的线程都不能进入到同一个监视器保护的块中间,除非第一个线程退出了同步块。

但是同步的含义比互斥更广。同步保证了一个线程在同步块之前或者在同步块中的一个内存写入操作以可预知的方式对其他有相同监视器的线程可见。当我们退出了同步块,我们就释放了这个监视器,这个监视器有刷新缓冲区到主内存的效果,因此该线程的写入操作能够为其他线程所见。在我们进入一个同步块之前,我们需要获取监视器,监视器有使本地处理器缓存失效的功能,因此变量会从主存重新加载,于是其它线程对共享变量的修改对当前线程来说就变得可见了。

依据缓存来讨论同步,可能听起来这些观点仅仅会影响到多处理器的系统。但是,重排序效果能够在单一处理器上面很容易见到。对编译器来说,在获取之前或者释放之后移动你的代码是不可能的。当我们谈到在缓冲区上面进行的获取和释放操作,我们使用了简述的方式来描述大量可能的影响。

新的内存模型语义在内存操作(读取字段,写入字段,锁,解锁)以及其他线程的操作(start 和 join)中创建了一个部分排序,在这些操作中,一些操作被称为 happen before 其他操作。当一个操作在另外一个操作之前发生,第一个操作保证能够排到前面并且对第二个操作可见。这些排序的规则如下:

  • 线程中的每个操作 happens before 该线程中在程序顺序上后续的每个操作。
  • 解锁一个监视器的操作 happens before 随后对相同监视器进行锁的操作。
  • volatile 字段的写操作 happens before 后续对相同 volatile 字段的读取操作。
  • 线程上调用 start() 方法 happens before 这个线程启动后的任何操作。
  • 一个线程中所有的操作都 happens before 从这个线程 join() 方法成功返回的任何其他线程。(注意思是其他线程等待一个线程的jion() 方法完成,那么,这个线程中的所有操作 happens before 其他线程中的所有操作)

这意味着:任何内存操作,这个内存操作在退出一个同步块前对一个线程是可见的,对任何线程在它进入一个被相同的监视器保护的同步块后都是可见的,因为所有内存操作 happens before 释放监视器以及释放监视器 happens before 获取监视器。

其他如下模式的实现被一些人用来强迫实现一个内存屏障的,不会生效:

synchronized (new Object()) {}

这段代码其实不会执行任何操作,你的编译器会把它完全移除掉,因为编译器知道没有其他的线程会使用相同的监视器进行同步。要看到其他线程的结果,你必须为一个线程建立 happens before 关系。

重点注意:对两个线程来说,为了正确建立 happens before 关系而在相同监视器上面进行同步是非常重要的。以下观点是错误的:当线程 A 在对象 X 上面同步的时候,所有东西对线程 A 可见,线程 B 在对象 Y 上面进行同步的时候,所有东西对线程 B 也是可见的。释放监视器和获取监视器必须匹配(也就是说要在相同的监视器上面完成这两个操作),否则,代码就会存在“数据竞争”。

final 字段如何改变它们的值?

我们可以通过分析 String 类的实现具体细节来展示一个 final 变量是如何可以改变的。

String 对象包含了三个字段:一个 character 数组,一个数组的 offset 和一个 length。实现 String 类的基本原理为:它不仅仅拥有 character 数组,而且为了避免多余的对象分配和拷贝,多个 StringStringBuffer 对象都会共享相同的 character 数组。因此,String.substring() 方法能够通过改变 lengthoffset,而共享原始的 character 数组来创建一个新的 String。对一个 String 来说,这些字段都是 final 型的字段。

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

字符串 s2offset 的值为 4length 的值为 4。但是,在旧的内存模型下,对其他线程来说,看到 offset 拥有默认的值 0 是可能的,而且,稍后一点时间会看到正确的值 4,好像字符串的值从 /usr 变成了 /tmp 一样。

旧的 Java 内存模型允许这些行为,部分 JVM 已经展现出这样的行为了。在新的 Java 内存模型里面,这些是非法的。

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

一个对象的 final 字段值是在它的构造方法里面设置的。假设对象被正确的构造了,一旦对象被构造,在构造方法里面设置给 final 字段的的值在没有同步的情况下对所有其他的线程都会可见。另外,引用这些 final 字段的对象或数组都将会看到 final 字段的最新值。

对一个对象来说,被正确的构造是什么意思呢?简单来说,它意味着这个正在构造的对象的引用在构造期间没有被允许逸出。(参见安全构造技术)。换句话说,不要让其他线程在其他地方能够看见一个构造期间的对象引用。不要指派给一个静态字段,不要作为一个 listener 注册给其他对象等等。这些操作应该在构造方法之后完成,而不是构造方法中来完成。

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 字段。它不保证看到 f.y 的值为 4,因为 f.y 不是 final 字段。如果 FinalFieldExample 的构造方法像这样:

public FinalFieldExample() { // bad!
  x = 3;
  y = 4;
  // bad construction - allowing this to escape
  global.obj = this;
}

那么,从 global.obj 中读取 this 的引用线程不会保证读取到的 x 的值为 3

能够看到字段的正确的构造值固然不错,但是,如果字段本身就是一个引用,那么,你还是希望你的代码能够看到引用所指向的这个对象(或者数组)的最新值。如果你的字段是 final 字段,那么这是能够保证的。因此,当一个 final 指针指向一个数组,你不需要担心线程能够看到引用的最新值却看不到引用所指向的数组的最新值。重复一下,这儿的“正确的”的意思是“对象构造方法结尾的最新的值”而不是“最新可用的值”。

现在,在讲了如上的这段之后,如果在一个线程构造了一个不可变对象之后(对象仅包含 final 字段),你希望保证这个对象被其他线程正确的查看,你仍然需要使用同步才行。例如,没有其他的方式可以保证不可变对象的引用将被第二个线程看到。使用 final 字段的程序应该仔细的调试,这需要深入而且仔细的理解并发在你的代码中是如何被管理的。

如果你使用 JNI 来改变你的 final 字段,这方面的行为是没有定义的。

volatile 是干什么用的?

volatile 字段是用于线程间通讯的特殊字段。每次读 volatile 字段都会看到其它线程写入该字段的最新值;实际上,程序员之所以要定义 volatile 字段是因为在某些情况下由于缓存和重排序所看到的陈旧的变量值是不可接受的。编译器和运行时禁止在寄存器里面分配它们。它们还必须保证,在它们写好之后,它们被从缓冲区刷新到主存中,因此,它们立即能够对其他线程可见。相同地,在读取一个 volatile 字段之前,缓冲区必须失效,因为值是存在于主存中而不是本地处理器缓冲区。在重排序访问 volatile 变量的时候还有其他的限制。

在旧的内存模型下,访问 volatile 变量不能被重排序,但是,它们可能和访问非 volatile 变量一起被重排序。这破坏了 volatile 字段从一个线程到另外一个线程作为一个信号条件的手段。

在新的内存模型下,volatile 变量仍然不能彼此重排序。和旧模型不同的时候,volatile 周围的普通字段的也不再能够随便的重排序了。写入一个 volatile 字段和释放监视器有相同的内存影响,而且读取 volatile 字段和获取监视器也有相同的内存影响。事实上,因为新的内存模型在重排序 volatile 字段访问上面和其他字段(volatile 或者非 volatile)访问上面有了更严格的约束。当线程 A 写入一个 volatile 字段 f 的时候,如果线程 B 读取 f 的话 ,那么对线程 A 可见的任何东西都变得对线程 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。对变量 v 的写操作会等到变量 x 写入到内存之后,然后读线程就可以看见 v 的值。因此,如果 reader 线程看到了 v 的值为 true,那么,它也保证能够看到在之前发生的写入 42 这个操作。而这在旧的内存模型中却未必是这样的。如果 v 不是 volatile 变量,那么,编译器可以在 writer 线程中重排序写入操作,那么 reader 线程中的读取 x 变量的操作可能会看到 0

实际上,volatile 的语义已经被加强了,已经快达到同步的级别了。为了可见性的原因,每次读取和写入一个 volatile 字段已经像一个半同步操作了

重点注意:对两个线程来说,为了正确的设置 happens-before 关系,访问相同的 volatile 变量是很重要的。以下的结论是不正确的:当线程 A 写 volatile 字段f的时候,线程 A 可见的所有东西,在线程 B 读取 volatile 的字段 g 之后,变得对线程 B 可见了。释放操作和获取操作必须匹配(也就是在同一个 volatile 字段上面完成)。

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

臭名昭著的双重锁检查(也叫多线程单例模式)是一个骗人的把戏,它用来支持 lazy 初始化,同时避免过度使用同步。在非常早的 JVM 中,同步非常慢,开发人员非常希望删掉它。双重锁检查代码如下:

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

这看起来好像非常聪明——在公用代码中避免了同步。这段代码只有一个问题 —— 它不能正常工作。为什么呢?最明显的原因是,初始化实例的写入操作和实例字段的写入操作能够被编译器或者缓冲区重排序,重排序可能会导致返回部分构造的一些东西。就是我们读取到了一个没有初始化的对象。这段代码还有很多其他的错误,以及为什么对这段代码的算法修正是错误的。在旧的 Java 内存模型下没有办法修复它。更多深入的信息可参见:Double-checkedlocking: Clever but broken 和 The “DoubleChecked Locking is broken” declaration

许多人认为使用 volatile 关键字能够消除双重锁检查模式的问题。在 1.5 的 JVM 之前,volatile 并不能保证这段代码能够正常工作(因环境而定)。在新的内存模型下,实例字段使用 volatile 可以解决双重锁检查的问题,因为在构造线程来初始化一些东西和读取线程返回它的值之间有 happens-before 关系。

然后,对于喜欢使用双重锁检查的人来说(我们真的希望没有人这样做),仍然不是好消息。双重锁检查的重点是为了避免过度使用同步导致性能问题。从 Java 1.0 开始,不仅同步会有昂贵的性能开销,而且在新的内存模型下,使用 volatile 的性能开销也有所上升,几乎达到了和同步一样的性能开销。因此,使用双重锁检查来实现单例模式仍然不是一个好的选择。(修订:在大多数平台下,volatile 性能开销还是比较低的)。

使用 IODH 来实现多线程模式下的单例会更易读:

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

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

这段代码是正确的,因为初始化是由 static 字段来保证的。如果一个字段设置在 static 初始化中,对其他访问这个类的线程来说是是能正确的保证它的可见性的。

如果我要实现一个 JVM,我需要做什么?

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

为什么要关注 JMM?

为什么你需要关注 Java 内存模型?并发程序的 bug 非常难找。它们经常不会在测试中发生,而是直到你的程序运行在高负荷的情况下才发生,非常难于重现和跟踪。你需要花费更多的努力提前保证你的程序是正确同步的。这不容易,但是它比调试一个没有正确同步的程序要容易的多。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment