Java内存模型jsr-133-faq

 2019-12-10 16:03  阅读(578)
文章分类:Java Core

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

什么是内存模型?

在多核系统中,处理器一般有一层或者多层的缓存,这些缓存通过加速数据访问(因为数据距离处理器更近)和降低在共享内存总线上的通讯(因为本地缓存能够满足许多CPU内核的内存操作)来提高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;
      }
    }

让我们看在两个线程中并发执行这段代码的两个方法,如果在reader方法中读取y变量得到2这个值,因为在writer中对y写入比写到x变量更晚一些,程序员可能认为读取x变量将肯定会得到1。然而,写操作可能被重排序。如果对writer中xy的写操作被交换执行顺序(因为在writer中这2个写操作没有依赖,交换执行顺序不会影响程序原语义),那么就可能发生对y变量的写操作后紧接着第二个线程(调用reader)读取两个变量,最后执行对x的写操作。程序的结果可能是r1变量的值是2,但是r2变量的值为0。在reader方法中读到的值是不确定的,可能产生4种组合结果。

Java内存模型描述了在多线程代码中哪些行为是合法的,以及线程如何通过内存进行交互。它描述了“程序中的变量“ 和 ”从真正的计算机系统中的内存或寄存器中存储或检索的底层细节”之间的关系。在使用各种各样的硬件和编译器的优化的情况下,Java内存模型保证代码能被正确执行。

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

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

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

JSR133是什么?

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

Java内存模型是一个雄心勃勃的计划;它是编程语言规范首次尝试建立一个内存模型,该模型能在各种架构中为并发提供一致性的语义。不幸的是,定义一个一致且直观的内存模型比预期的要困难得多。JSR133 Java语言定义了一个新的内存模型,它修复了早期的内存模型的缺陷。为了做到这一点,final和volatile的语义需要加强。

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

JSR 133的目标包含了:

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

某些情况下,访问程序变量(对象实例域,类的静态域,和数组元素)可能会出现执行时的顺序和程序顺序不同。编译器为了优化能够对指令重新排序。处理器在特定情况下可能不是按照编译后的顺序执行指令。数据在寄存器,处理器高速缓存,主存之间可能以不同于程序顺序移动。

例如,如果一个线程写入字段a然后写入b,而且b的值不依赖于a的值,那么编译器就能自由对这两个写操作重新排序,而且缓存也能自由地选择先刷新b的值到主内存。有许多潜在的重排序的来源,比如编译器,JIT,和高速缓存。

编译器,运行时,硬件被期望成协同创造一个看起来像串行执行的假象,这意味着在单线程的程序中,无法观察到重排序的影响。然而,重排序在没有正确同步的多线程程序中开始起作用,一个线程能够观察到其他线程的影响, 并且能够检测到其他线程以不同于程序顺序来执行时,变量的访问变得可见。

大部分情况,一个线程并不关心其他线程在做什么。但当它需要关心时,就需要同步了。

旧内存模型有什么问题?

旧的内存模型中有几个严重的问题。这些问题很难理解,因此被广泛的违背。例如,旧的内存模型在许多情况下,不允许JVM发生各种重排序行为。旧内存模型中含义的混乱促使JSR-133规范的诞生。

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

旧的内存模型允许volatile变量的写操作和非volatile变量的读写操作一起进行重排序,这和大多数的开发人员对于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()方法,需要等这个线程成功返回的任何其他线程。

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

另一个含义就是如下模式的实现被一些人用来强迫实现一个内存屏障的,但不会生效:

synchronized (new Object()) {}

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

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

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

分析final字段的值是怎么改变的一个最好例子,就是String类的具体实现。

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

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

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

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

在新的Java内存模型中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字段。它不保证看到y的值为4,因为它不是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(Java Native Interface)来改变你的final字段,这方面的行为是没有定义的。

(附注:对于不可变对象,一种是无状态对象即没有数据域,另外一种就是仅包含基本类型final字段的对象;如果对象含有引用类型的final字段,该字段对象必须递归满足前面两种之一。)

volatile是做什么的?

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”。在“writer”中对变量v的写操作会把对变量x写也刷新到主内存,因为不能重排序x的写会在v的写操作之前发生,然后“reader”线程从主存获取v的值。因此,如果reader线程看到了v的值为true,那么,它也保证能够看到在之前发生的写入42这个操作。而这在旧的内存模型中却未必是这样的。如果v不是volatile变量,那么,编译器可以在writer线程中重排序写入操作,那么reader线程中的读取x变量的操作可能会看到0。

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

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

(附注:volatile语义的写相当于monitor exit,读相当于monitor enter;volatile能保证可见性,但不能保证同步互斥;多核处理器下不推荐使用volatile,线程每次写volatile字段都会把工作内存及时刷新到主存,每次读都会从主存获取数据,因为要和主存交换数据,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;
    }

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

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

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

使用IODH(Initialization On Demand Holder)来实现多线程模式下的单例会更简单易读:

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

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

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

如果要写一个VM该怎么做?

请参考:http://gee.cs.oswego.edu/dl/jmm/cookbook.html

为什么需要关注Java内存模型?

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

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> Java内存模型jsr-133-faq

相关推荐