JSR 133 (Java Memory Model) FAQ

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

What is a memory model, anyway?

在多处理器系统中,处理器通常有一层或多层缓存,这不仅提升了性能,因为访问数据变得更快(离处理器更近了),还降低了内存总线的拥堵(因为大部分内存操作都可以通过访问缓存来得到满足)。内存缓存可以极大的提升性能,但也带来了一系列其他的挑战。比如,两个不同的处理器访问同一时间同一个内存地址,会发生什么?在什么条件下,他们会看到一致的值?

在处理器层面,内存模型定义了在什么样的条件下,当前线程可以看到其他线程写入内存的值,以及在什么样的条件下,其他线程可以看到当前线程写入内存的值。有些处理器实现了强内存模型,即所有的处理器在任意时刻在指定的内存位置看到的值都是一样的。其他处理器提供的则是弱内存模型,需要特殊的指令(内存屏障)来刷新或使本地缓存无效,以便看到其他处理写入内存的值,或让其他处理器看到自己写入内存的值。这些指令通常是在获取锁和释放锁的时候执行,在高级语言中,它们对程序员是不可见的。

有时为强内存模型编写程序更容易,因为对内存屏障的需求减少了。然而,即使是在一些强内存模型中,内存障碍也是必要的,它们的位置常常违反直觉。处理器设计的新趋势是鼓励弱内存模型的,因为它对缓存一致性的宽松要求,使得我们在面对多处理器和更大的内存时,有了更好的可伸缩性。

由于编译器的代码重排序,一个线程的写操作对其他线程可见的问题变得更加复杂了。比如说,编译器可能会认为将一个写操作放到程序后面部分更有效率,只要这个移动不会改变程序语义,它就可以这么做。如果编译器延迟了一个操作,在它执行之前,其他线程都看不到结果,就像看到的时缓存一样。

而且,内存写入操作可以被移动到程序前面部分。这种情况下,其他线程可能在程序实际“发生”之前就看到一个写入操作。所有这些灵活性都是经过设计的 -- 通过给编译器、运行时或硬件以最优顺序执行操作的灵活性,在内存模型的边界内,我们可以实现更高的性能。

从下面的代码可以看到一个简单的例子:

class Reordering {
    int x = 0, y = 0;
    public void write() {
        x = 1;
        y = 2;
    }

    public void read() {
        int r1 = y;
        int r2 = x;
    }
}

我们假设有两个线程在并发的执行上面的代码,并且读线程看到了y的值:2。由于对y的写在对x的写之后,所以程序员可能会假设对x的读一定会读到1。但,写操作可能被重排序。如果发生了重排序,那么就有可能先写入y,紧接着读取x和y,然后再写入x。结果就是r1的值为2,但r2的值却是0。

Java内存模型描述了多线程代码中那些行为是合法的,以及线程间如何通过内存进行交互。它描述了程序中的变量和它们在真实计算机系统中的内存或寄存器的存储和访问的底层细节之间的关系。它通过其可以被各种硬件和编译器优化实现来做到这一点。

Java有一些语言级别的设计,以帮助程序员向编译器描述其对并行方面的需求,包括:volatile、final以及synchronized。Java内存模型定义了volatile和synchronized的行为,以及更重要的是,确保做了正确同步处理的Java程序在所有处理器架构上都能正确运行。

Do other languages, like C++, have a memory model?

大部分其他的编程语言,比如C和C++,对多线程都没有直接的支持。这些语言针对编译器和其他体系结构中发生的各种重新排序提供的保护很大程度上依赖于所使用的线程库(比如pthreads)、编译器以及代码运行的平台所提供的保证。

What is JSR 133 about?

自1997年以来,Java语言规范第17章所定义的Java内存模型,被发现了几个严重的缺陷。这些缺陷会导致一些令人困惑的现象(比如,final域的值发生改变),以及暗地里破坏了编译器执行一些常见的优化。

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

正式的语义见:http://www.cs.umd.edu/users/pugh/java/memoryModel,但它不适合胆小的人(意思是说,很复杂,会吓到你)。发现像同步这样看似简单的概念实际上有多么的复杂,很吃惊,也很让人清醒。幸运的是,你不需要去理解正式语义的细节 -- JSR 133的目标是给volatile、synchronized以及final如何工作提供一个直观的框架。

JSR 133的目标包括:

  • 保留现有的安全保证,如类型安全,并加强其他保证,例如,变量值不能是凭空创建的:某个线程看到的变量的每个值都必须是某个线程合理设置的值;
  • 做了正确同步处理的程序的语义应该越简单越直观越好;
  • 应该定义未完全同步和未正确同步的程序的语义,以降低潜在安全风险;
  • 程序员应该可以自信的判断多线程程序如何与内存交互;
  • 应该可以在各种流行的处理器架构上设计正确的高性能JVM实现;
  • 一个新的安全保证:初始化安全。如果一个对象被正确的创建(即其引用没有在创建过程中逸出),那么所有线程都可以在不用同步的情况下看到其在构造函数中设置的final域的值;
  • 对现有代码的影响应尽量的小。

What is meant by reordering?

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

例如,如果一个线程先写字段a,然后写字段b,由于b的值不依赖于a的值,那么编译器可以自由的重排序这两个操作,缓存也可以自由地在a之前将b刷新到主内存。现实中有许多潜在的重新排序源头,比如编译器、JIT和缓存。

编译器、运行时以及硬件应该一起创造一种as-if-serial语义的假象,即在单线程程序中,程序应该不能观测重排序的效果。然而,重新排序可能对未正确同步的多线程程序中产生影响,其中一个线程能够观察到其他线程的影响,并且可能能够检测到变量访问以不同于程序定义的顺序变得对其他线程可见。

大多数时候,一个线程并不关心另一个线程在做什么。但当它关心时,就需要同步了。

What was wrong with the old memory model?

老的内存模型有几个严重的问题。它很难理解,因此经常被违反。比如说,老的模型不允许任意JVM里面发生重排序。老模型的这种混乱导致了JSR 133的出现。

比如说,人们都认为只要我们使用了final域,就不必使用同步来确保另外的线程看到正确的值。虽然这是一个合理的假设,也确实是我们所希望的,但在老模型里,并不是这样。老模型对待final域和其他域并没有什么区别 -- 意味着同步是唯一使所有其他线程能看到由构造函数给final域设置的值的方法。结果就是,一个线程可能先看到这个域的默认值,然后某个时刻又看到其构造的值。也就是说,比如,不可变对象(比如String)可能改变它们的值 -- 这确实令人不安。

老的模型允许将对volatile变量的写与非volatile变量的读写重排序,这不太符合大部分开饭人员对volatile的直觉,因而导致混乱。

最后,正如我们将看到的,程序员对于程序同步不正确时会发生什么的直觉常常是错误的。JSR-133的目标之一是引起对这一事实的注意。

What do you mean by “incorrectly synchronized”?

不正确的同步代码对不同的人可能意味着不同的东西。当我们在Java内存模型上下文中讨论不正确的同步代码时,我们的指的是这样的代码:

  1. 有一个线程在写一个变量,
  2. 有一个线程在读同一个变量,同时
  3. 读和写没有进行同步。

当违反这些规则时,我们说在该变量上有数据竞争。具有数据竞争的程序是没有正确同步的程序。

What does synchronization do?

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

但是同步不仅仅是互斥。同步确保线程在同步块之前或期间的内存写入以可预测的方式对在同一监视器上的其他线程可见。在退出同步块之后,我们释放监视器,它的作用是将缓存刷新到主内存,这样其他线程就可以看到这个线程的写操作。在进入同步块之前,我们获取监视器,它的作用是使本地处理器缓存失效,以便从主内存重新加载变量。然后,我们将能够看到由前一个释放导致可见的所有写操作。

从缓存的角度讨论这个问题,听起来好像这些问题只影响多处理器机器。然而,在单个处理器上很容易看到重新排序的影响。比如,编译器不可能将你的代码移动到获取监视器操作之前或释放监视器操作之后(也就是说,在中间的代码都是其实都在缓存里面操作的)。When we say that acquires and releases act on caches, we are using shorthand for a number of possible effects.(这句话真不知道什么意思)

新的内存模型语义创建了一个关于内存操作(读、写、锁、释放锁)和其他线程操作(start和join)的偏序关系,称之为一些操作happens before另外一些操作。当一个操作happens before另一个操作的时候,第一个操作一定对排在另一个操作前面并对其可见。规则如下:

  • 线程中的每个操作都happens before其在程序中的后续操作;
  • 监视器的释放操作happens before后续对这个监视器的获取操作;
  • volatile域的写操作happens before后续对这个域的读操作;
  • 对线程start()的调用happens before这线程启动后的任意操作;
  • 线程里的所有操作happens before另一个线程对这个线程的join()方法的调用成功返回。

这意味着对一个线程在进入同步块之前可见的任何内存操作,对后续任意线程进入该同步块后都可见,因为所有内存操作都happens before释放操作,而释放操作happens before获取操作。

另一个含义,像下面这样的模式,被有些人用来作为一个强制性的内存屏障,但不管用:

synchronized (new Object()) {}

这实际上是一个无意义操作,编译器可以完全删除它,因为编译器知道没有其他线程会在同一个监视器上进行同步。想要一个线程看到另一个线程的操作结果,你必须在它们之间建立happens before关系。

注:为了建立正确的happens before关系,一定要让线程在同一个监视器上进行同步。并不是说线程A在对象X上进行同步后的一切操作都对线程B在对对象Y同步后可见。释放和获取必须“匹配”(也就是,作用在同一个监视器上),才能有正确的语义。否则,你的代码就存在数据竞争。

How can final fields appear to change their values?

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

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

字符串s2的偏移量为4,长度为4。但是,在旧模型下,另一个线程可能先看到偏移量的值为默认值0,然后又看到偏移量的值变为正确的值4,就好像字符串由“/usr”变为了“/tmp”的一样。

老的Java内存模型允许这种行为,一些jvm已经出现了这种现象。在新的Java内存模型中,这种行为并不合法。

How do final fields work under the new JMM?

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

一个对象被正确构造意味着什么?它意味着在对象构造过程中不会有任何指向该对象引用“逸出”。(参见安全构造技术示例)。换句话说,不要将对正在构造的对象的引用置于其他线程可能看到它的任何位置,不要将它分配给静态字段,不要将它注册为任何其他对象的监听器,等等。这些任务应该在构造函数完成后进行,而不是在构造函数中。

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;
    // 不正确的构造 - this可能逸出
    global.obj = this;
}

这时,从global.obj读取this引用就不被保证能看到x的值为3。

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

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

如果你想通过JNI来修改final字段的值,很遗憾没有相关的行为定义。

What does volatile do?

Volatile字段是用于在线程之间通信状态的特殊字段。每次读取volatile都将看到任何线程对该volatile的最后一次写入,实际上,程序员通过volatile将字段指定为不接受任何由于缓存或重新排序而导致的“陈旧”值。编译器和运行时被禁止从寄存器读取它们。它们还必须确保在写入之后,将它们从缓存刷新到主内存中,这样其他线程就可以立即看到它们。类似地,在读取volatile字段之前,必须使缓存失效,这样才能看到主内存中的值,而不是本地处理器缓存中的值。对于volatile变量的访问操作的重排序也有额外的限制。

在老的内存模型下,对volatile变量的访问操作彼此间不能重新排序,但是可以和对非volatile变量的访问操作进行重排序。这削弱了volatile变量作为一个线程向另一个发送条件信号的作用(意思就是,比如:你一个volatile变量变为true后就可以确定另一个非volatile变量等于多少了,结果被重排序了,你的“确定”就可能出问题)。

在新的内存模型下,volatile变量之间依然不能被重排序。不同之处在于,现在对它们周围的普通字段访问重排序不再那么容易了。写volatile字段具有与释放监视器相同的内存语义,读volatile字段具有与获取监视器相同的内存语义。实际上,由于新的内存模型对volatile字段访问与其他字段(无论是否volatile)访问的重新排序施加了更严格的限制,线程A写入volatile字段f时可见的任何内容,在线程B读取f时也都是可见的。

下面是一个如何使用volatile字段的简单例子:

class VolatileExample {
    int x = 0;
    volatile boolean v = false;
    public void writer() {
        x = 42;
        v = true;
    }

    public void reader() {
        if (v == true) {
            // 使用x - JMM保证此时其值为42
        }
    }
}

假设一个线程调用writer,另一个线程调用readerwriter中对v的写入将对x的写入释放到了主内存中,然后对v的读取将从主内存中读取。因此,如果读线程看到了v的值是true,那么它同样可以看到happens before与对v的写入的对x写入的值42。但在老的模型下却看不到(因为两个写入有可能被重排序)。如果v不是volatile的,那么编译器可以在writer中重新排序写操作,而readerx的读操作可能会看到0。

实际上,volatile的语义得到了很大的增强,几乎达到了同步的层级。对于可见性,volatile字段的每一次读或写都相当于同步的“一半”(也就是内存语义一样,只是没有互斥性)。

注:请注意,为了正确设置happens-before关系,两个线程访问同一个volatile变量是非常重要的。并不是说线程A写入volatile字段f时可见的所有内容在线程B读取volatile字段g后都变为可见的。发布和获取必须“匹配”(即,作用于共一个volatile变量),以获得正确的语义。

Does the new memory model fix the "double-checked locking" problem?

(臭名昭著的)双重检查锁模式(也称为多线程单例模式)是一种技巧,旨在支持延迟初始化,同时避免同步的开销。在早期的jvm中,同步很慢,开发人员急于避免它 -- 可能太着急了。双重检查锁的习惯用法如下:

// 双重检查锁 - 不要这么干!

private static Something instance = null;

public static Something getInstance() {
    if (instance == null) {
        synchronized (Something.class) {
            if (instance == null)
                instance = new Something();
            }
        }
    return instance;
}

这看起来非常聪明 -- 在公共代码路径上避免了同步。只有一个问题 -- 它是错的。为啥?最明显的原因就是,对instance的赋值操作和对instance内部字段的写操作可能被缓存或者编译器重排序,这将导致返回一个部分构造的Something对象。结果就是我们读到了一个未完全初始化的对象。还有很多原因说明为什么它是错的,以及为什么它的修正算法也是错的。在老的模型下,没有修复它的方法。想要深入了解请参考:Double-checked locking: Clever, but broken以及 The "Double Checked Locking is broken" declaration。

许多人认为使用volatile关键字可以消除在使用双重检查锁模式时出现的问题。在1.5之前的jvm中,volatile不能确保它能工作(因人而异)。在新的内存模型下,用volatile修饰字段可以“修复”双重检查锁的问题,因为在构造线程对Something的初始化和读取线程读取它的值之间存在happens-before关系。

然而,对于双重检查锁的爱好者(我们真的希望已经没有了)来说,消息仍然不是很好。双重检查锁的目的是为了避免同步带来的性能开销。从Java 1.0开始,简单的同步不仅变得快速了很多,而且在新的内存模型下,使用volatile的性能成本上升了,几乎达到了使用同步的水平。因此,仍然没有什么好的理由使用双重检查锁。(修正 -- volatile在大部分平台上开销都小得多。)

相反,使用Initialization On Demand Holder模式,既是线程安全的,又更容易理解:

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

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

因为静态字段的初始化保证,确保上面代码的正确性。如果一个字段被放在一个静态初始化器类里面,它将在任何线程访问这个初始化器类的时候保证正确,可见。

What if I'm writing a VM?

你应该看这:http://gee.cs.oswego.edu/dl/jmm/cookbook.html 。

Why should I care?

你应该关心些啥?并发性错误很难调试。它们通常不会出现在测试中,而是等到程序在高负载下运行时才出现,并且很难重现和追踪。你最好提前花费额外的精力来确保你的程序是正确同步的。虽然这并不容易,但还是要比调试一个没有正确同步的应用程序容易得多。

你可能感兴趣的:(JSR 133 (Java Memory Model) FAQ)