Java内存模型的若干问题

liang @ 2017年06月30日

本来是想自己总结一下JSR 133提出的新Java内存模型,后来发现了Jeremy Manson 和 Brian Goetz在2004年写的《JSR-133 (Java Memeory Model) FAQ》一文,全文以FAQ的形式,回答了为什么需要用JSR-133来实现Java新的内存模型,以及新的内存模型在内存可见性,语义重排序等几方面的改进。跟其他编程语言相比,Java从语言层面定义了自己的内存模型,而其他编程语言则需要依赖专门的线程库才能实现相关功能,这一点也许能让Java开发者产生一些优越感。另外,关于Java内存模型和并发编程的中文资料看了相当多,一个个有的把问题描述的很片面,有的把相关知识讲的非常复杂,有时候你甚至能感受到作者的恶意(故意把问题扩大化,复杂化),JM和BG的FAQ让我有种相见恨晚的感觉,作者高屋建瓴,全文言简意赅,观点针针见血,举例拳拳到肉,加上Doug Lea的《JSR-133 cookbook》一文,都对我启发很大。看了本文是根据这篇FAQ做的总结。

导语

一说起多线程编程,脑子里首先闪过的概念一般是锁,同步,AQS等,这是不全面的。多线程编程产生的土壤上有两个元素:多处理器和多级缓存。为了应对这两个元素,多线程编程使用同步工具解决多处理器并发执行的问题,用内存模型解决多级缓存造成的共享变量可见性的问题。

在本文中,CPU指包含了多处理器和多级缓存的单元。

CPU、高速缓存、主存

什么是内存模型

在多处理器系统中,每个处理器往往都有一级或多级的高速缓存,高速缓存一方面显著提高了处理器访问数据的速度,另一个方面也降低了内存数据在总线上的数据传输。高速缓存对处理器性能的提升非常显著,但也产生了大量的问题。比如,两个处理器同时访问(liang: 读取/修改)相同内存位置上的数据,需要有专门的机制来保证它们能看到相同的值。

在处理器层面,内存模型需要定义充分必要条件来保证:

  • 其他处理器对共享变量值的修改要对当前处理器可见(liang: 其他处理器写入到内存包含写入到处理器本地缓存,并刷新到主内存。对当前处理器可见包括处理器本地缓存失效,并从主内存重新加载值)。
  • 当前内存处理器对共享变量值的修改要对其他处理器可见。

有些CPU使用强一致性内存模型,所有的处理器可以同时看到给定主内存地址上的值。有些CPU则使用弱一致性内存模型,处理器通过添加了内存屏障(memory barriers)的特殊指令来保证相互之间对共享变量的可见性。在这种情况下,内存屏障能保证对共享变量的写入要立即刷新到主内存,对共享变量的读取要立即从主内存中加载(将高速缓存失效)。这些内存屏障通常在加锁(lock)和解锁(unlock)操作的过程中使用,他们在高级编程语言中对开发人员是透明的(invisible)。

在支持强一致性内存模型的CPU上编程往往比较简单,因为减少了内存屏障的使用。但是,在很多支持强一致性内存模型的CPU上,内存屏障依旧是很必要的。内存屏障的使用是违反人类直觉的,这经常给开发者带来很大困扰。但是,在处理器设计上,目前越来越趋向于若一致性内存模型,因为它们放宽了缓存一致性要求,使得跨多个处理器和更大内存的可伸缩性更强。

共享变量可见的问题还要考虑指令的重排序。比如,编译器在不破坏代码语义的情况下,可以任意调整代码执行的顺序。

指令的重排序通常会发生在编译器,运行时,或者处理器等部分,对指令的重排序往往是为了提高运行效率。

分析下面的代码:

Class Reordering {
  int x = 0, y = 0;
  public void writer() {
    x = 1;             @1
    y = 2;            @2
  }

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

现在有两个线程并发访问上面的代码,x和y都是普通变量。假设现在一个线程正在执行代码@3,并且读到r1=y的值为2,该线程继续往下执行代码@4,读到r2=x的值一定等于1吗?显然不是。由于指令重排序的存在,由于@1和@2之间没有关系,可以进行重排序,因此@2可能在@1之前执行,这种情况下,在执行@4时,读到r2=x的值为0(int型x的默认值)。

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

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

其它编程语言有内存模型吗?

大多数其他编程语言,例如c和c++,没有设计为直接支持多线程。这些语言针对在编译器和体系结构中发生的重排序类型提供的保护,严重依赖于使用的线程库(如pthreads)、使用的编译器以及运行代码的平台提供的保证。

看到这里,Java程序员是不是油然产生很强烈的自豪感~

什么是JSR-133?

自1997年以来,在Java语言规范第17章中定义的Java内存模型中发现了几个严重的缺陷。这些缺陷允许混淆行为(比如观察到的最后字段更改值),并且破坏了编译器执行常见优化的能力。

Java内存模型是一项雄心勃勃的任务。这是第一次编程语言规范尝试合并一个内存模型,它可以在各种体系结构中为并发提供一致的语义。不幸的是,定义一个既一致又直观的内存模型比预期的要困难得多。JSR 133为Java语言定义了一种新的内存模型,它修复了早期内存模型的缺陷。为了做到这一点,final和volatile的语义需要更改。

完整的语义可以在http://www.cs.umd.edu/users/pugh/java/memoryModel上找到,但是正式的语义并不是保守的。开发者会像同步这样的看似简单的概念是多么复杂。幸运的是,您不需要了解正式语义的细节- - - - JSR133的目标是创建一组正式的语义,为volatile、synchronized和final工作提供一个直观的框架。

JSR 133的目标包括:

  • 保留现有的安全保障,比如类型安全,并加强其他安全保障。例如,变量值可能不是凭空创建的:某个线程所观察到的变量的每个值都必须是某个线程可以合理地放置在那里的值。
  • 正确同步的程序的语义应该尽可能的简单直观。
  • 不完全或不正确同步的程序的语义应该被定义,从而减少潜在的安全风险。
  • 程序员应该能够自信地解释多线程程序如何与内存交互。
  • 应该可以在广泛流行的硬件体系结构中设计正确的、高性能的JVM实现。
  • 应提供初始化安全的新保证。如果一个对象被正确地构造了(这意味着在构造过程中对它的引用不会被转义),那么所有看到该对象引用的线程都将看到在构造函数中设置的最终字段的值,而不需要进行同步。
  • 对现有代码的影响应该最小。

重排序是什么意思?

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

举个例子,如果一个线程写入字段a和字段b,字段b的值不依赖于字段a,然后编译器是可以自由重新排序这些操作,并且缓存可以将字段b先于字段a刷新到主内存。有很多的潜在来源重新排序,如编译器、JIT和缓存。

编译器、运行时和硬件应该联合起来创建一个"as-if-serial"的错觉,这意味着在单线程程序中,程序不应该能够观察到重排序的影响。然而,重排序可能会在不正确同步的多线程程序中发挥作用,其中一个线程能够观察其他线程的影响,并且可能能够检测到其他线程对其他线程的访问,与在程序中执行或指定的顺序不同。

大多数情况下,一个线程不关心另一个线程在做什么。但当它这么做的时候,这就是同步的目的。

旧的内存模型存在什么问题?

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

例如,一个广为流传的观点是,如果使用了final字段,那么线程之间的同步就没有必要,从而保证另一个线程能够看到该字段的值。虽然这是一个合理的假设和一个明智的行为,而且确实是我们想要的东西,在旧的内存模型下,它是不正确的。在旧的内存模型中,对final字段的处理与其他类型的字段没有区别(liang: 这是给人一个心理安慰吗?)——这意味着,同步是确保所有线程都能看到由构造函数编写的最终字段的值的惟一方法。

在就的内存模型中,对volatile字段的写操作可以与非volatile字段的读操作和写操作进行重排序,这与大多数开发人员对volatile的直觉不一致,因此造成了混乱。

最后,正如我们将要看到的,程序员对于当他们的程序被错误地同步时可能发生的事情的直觉往往是错误的。JSR-133的目标之一就是唤起人们对这一事实的关注。

"不正确的同步"是什么意思?

不同步的代码对不同的人含义不同。当我们在Java内存模型的上下文中讨论不同步的代码时,我们指的是包含下面操作的代码:

  • 一个线程有一个变量的写入
  • 另一个线程有一个相同的变量的读取
  • 写入和读取不是同步的

当这些规则被违反时,我们说我们在那个变量上有一个数据竞争。一个有数据竞争的程序是一个错误同步的程序。

"同步"指的是什么?

同步有几个方面。最明确的理解是互斥访问——只有一个线程可以持有监视器,因此在监视器上的同步意味着,一旦一个线程进入受监视程序保护的同步数据块,则没有其他线程可以输入受该监视器保护的数据块,直到第一个线程退出同步数据块。

但是,除了相互排斥之外,还有更多的同步。同步确保在同步块之前或在同步块上的内存以可预测的方式对同步在同一监视器上的其他线程可见。在我们退出一个同步块之后,我们将释放监视器,它具有将缓存刷新到主存的效果,因此该线程所做的写入可以对其他线程可见。在我们进入一个同步块之前,我们获取了监视器,它的作用是使本地处理器缓存失效,这样变量就会被重新加载。这里呼应了开头讲内存屏障时所说的,内存屏障在加锁和解锁操作中使用。

新的内存模型语义在内存操作(读取字段、写入字段、锁、解锁)和其他线程操作(start和join)上创建了一个局部排序,即happens-before语义。当一个动作发生在另一个动作之前,第一个动作就会被保证在第二个动作之前被预定。这一顺序的规则如下:

  • 线程中的每个操作要按照在线程中书写的顺序进行。
  • 监视器上的一个解锁操作happens-before该监视器上的加锁操作。
  • 对volatile字段的写操作happens-before该volatile字段的读操作。
  • 对start()方法的调用happens-before该线程的所有操作。
  • 线程中的所有操作happens-before该线程上的一个join()成功返回。

这意味着,在退出同步块之前,任何线程在线程进入同步块之前,任何线程都可以看到任何内存操作,因为所有的内存操作都在发布之前发生,并且在获得之前释放。这也隐含了下面的同步操作是不会正常工作的:

synchronized (new Object()) {}

这其实是一个无用操作,你的编译器会将它整个移除。编译器经过分析,发现没有其他的线程会在该监视器上进行同步(liang:监视器是和对象绑定的,这里每次new一个对象,自然不会有两个线程在同一个监视器上进行同步),你必须多个线程之间通过同一个监视器建立同步联系。

重要注意事项:注意,两个线程都必须同步在同一监视器上,以便正确设置happens-before关系。当线程A在对象X上建立了同步,线程B在对象Y上建立了同步,则线程A和线程B之间将不具备可见性的保证。监视器的释放和获取必须针对同一个对象才会满足正确的同步语义,否则,代码会产生数据竞争。

final字段怎么会呈现出不同的值?

通过String类的实现可以分析final字段有时会被发现是可变的。

String类的实现包含三个字段: 一个字符数组,一个数组的偏移值,一个长度。用这种方式实现String的基本原理是,它让多个StringStringBuffer对象共享相同的字符数组,避免了额外的对象分配和复制。例如,可以通过创建一个新字符串来实现方法String.substring(),该字符串与原始字符串共享相同的字符数组,而在长度和偏移量字段中只是不同。对于一个字符串,这些字段都是final字段。

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

字符串s2的偏移量为4,长度为4。但是,在旧模式下,另一个线程可以将偏移量看作默认值为0,然后看到正确的值为4, 它将显示为字符串“/ usr”更改为“/ tmp”。

最初的Java内存模型允许这种行为; 几个JVM已经展现出这种行为。 新的Java内存模型使得这是非法的。

在新的内存模型下final字段是怎么工作的?

对象的final字段的值在其构造函数中设置。假定对象是“正确地”构造的,一旦一个对象被构造,构造函数中的最后一个字段的值将会被所有其他线程看到,而不需要同步。此外,这些final字段引用的任何其他对象或数组的可见值至少与final字段一样是最新的。

一个对象被正确构造是什么意思? 这仅仅意味着在构造函数执行过程中,不允许产生当前对象引用(this)的“逃脱”(参见安全构造技术的例子)。换句话说,不要对正在构建的当前对象(this)进行引用,使得其他线程可以看到它。不要将其赋值给静态字段,不要将其注册为任何其他对象的侦听器等等。 这些任务应该在构造函数完成之后完成,而不是在构造函数中完成。

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函数时,可以保证读取到x的值3,因为它是final的。不能保证读取到y的值4,因为它不是final的。

如果将FinalFieldExample的构造函数改成下面这个:

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

一个线程在执行reader函数时,不能保证读取到x的值3,因为发生了对象引用的"逃逸": global.obj = this

查看字段的正确构造值的能力是很好的,但是如果字段本身是一个引用,那么您也希望您的代码能够看到它指向的对象(或数组)的最新值。如果你的字段是final字段,这是可以保证的。因此,您可以有一个指向数组的最终指针,不必担心其他线程会看到数组引用的正确值,但是数组内容的值是不正确的。同样,在这里“正确”,我们的意思是“直到对象构造函数的结束”,而不是“最新的可用值”。

现在,在讨论完所有这些之后,如果,在线程构造了一个不可变对象(即仅包含final字段的对象)之后,您需要确保所有其他线程都能正确地看到它,那么您仍然需要使用同步。没有其他方法可以确保,例如,对不可变对象的引用将被第二个线程看到。程序从最终字段中获得的保证应该小心谨慎地理解在代码中如何管理并发性。

开发者无法通过JNI来改变final字段的值。

volatile是如何工作的?

volatile字段是用于在线程之间通信状态的特殊字段。volatile的每一个读都将看到任何线程的最后一次写操作。实际上,程序员可以将volatile字段理解为一个永远保持最新数据的字段(不会被处理器缓存;其他处理器更新后将立即对该处理器可见)。编译器和运行时被禁止在寄存器中分配它们。它们还必须确保在写入之后,将它们从缓存中清除到主内存中,这样它们就可以立即对其他线程可见。类似地,在读取一个volatile字段之前,缓存必须失效,以便主内存中的值,而不是本地处理器缓存,是可见的。对于重新排序对volatile变量的访问也有一些额外的限制。

在旧的内存模型中,对volatile变量的访问不能彼此重新排序,但是它们可以跟非volatile字段的访问进行重新排序。这破坏了volatile字段作为一种从一个线程到另一个线程发送信号的方式的有用性。

在新的内存模型下,volatile变量不能彼此重新排序。区别在于,它现在不再那么容易对周围的正常字段进行重新排序。对volatile字段的写入与监视器释放具有相同的内存效果,从volatile字段读取与监视器获取的内存效果相同。实际上,由于新的内存模型对volatile字段访问的重新排序施加了更严格的约束,其他字段访问( volatile或not 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()函数。由于变量v是volatile的,在writer()中,可以保证对x的写入操作happens-before对v的写入操作。因此,如果reader()函数中读取到v的值为true,则可以保证此时可以读取到x的值42。这在旧的内存模型下是不能保证的。如果v是非volatile字段,编译器可以在writer()中对x和y的写入操作进行重新排序,在reader()中读取到的x可能为0。

实际上,volatile的语义已经得到了显著的增强,几乎达到了同步的程度。为了可见性,每个读或写一个volatile字段就像“半”同步。

重要注意事项: 注意,两个线程都必须访问相同的volatile变量,以便正确设置happens-before关系。

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

首先声明,"双检查锁定"的写法是错误的,建议使用静态内部类或者枚举的方式来获取单例对象。

臭名昭著的双重检查锁定习语(也称为多线程单例模式)是一种用于支持延迟初始化的技巧,同时避免了同步的开销。在早期的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;
}

这看起来很聪明 —— 在公共代码路径上避免了同步。它只有一个问题 —— It does not work。 为什么呢? 最明显的原因是初始化实例和对实例字段的写入可以由编译器或缓存重新排序,这将具有返回看起来是部分构造的Something的效果。 结果将是我们读取一个未初始化的对象。 还有很多其他原因,为什么这是错误的,为什么算法校正是错误的。 没有办法使用旧的Java内存模型进行修复。 关于"双检查锁定"问题的深入探讨可以参见: Double-checked locking: Clever, but brokenThe "Double Checked Locking is broken" declaration

许多人认为使用volatile关键字可以消除在尝试使用双重锁定模式时出现的问题。在1.5之前的JVM中,volatile不能确保它的工作(您的历程可能有所不同)。在新的内存模型中,使用volatile的实例字段将会“修复”双重检查锁定的问题,因为在构造线程的初始化和读取它的线程返回值之前,会有一个happens-before的关系。

通常,对于单例对象的实例化,我们推荐使用静态内部类的方式进行:

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

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

这段代码被保证是正确的,因为静态字段的初始化保证;如果在静态初始化器中设置了一个字段,那么它就会被保证对访问该类的任何线程都是可见的。

如果我在写虚拟机呢?

你可以参考Doug LeaThe JSR-133 Cookbook for Compiler Writers)一文。

我为什么要在意?

你为什么要关心? 并发错误很难调试。 他们经常不出现在测试中,直到你的程序在重负载下运行才可能出现,他们非常难以重现和陷阱。 开发者最好在写代码前下功夫研究并发编程的相关技术,以确保您的程序正确同步; 虽然这不容易,但比尝试调试严重同步的应用程序要容易得多。