JSR 133 (Java Memory Model) FAQ - 下

引言

FAQ 概述了 Java 内存模型,重排序,同步,final 字段和 volatile 字段,以及双重校验锁。

因为内容较多,分为上下两篇


final 字段是如何改变值的?

老内存模型中 final 字段是如何改变值的一个最好例子是 String 类的实现。

一个 String 是一个有 3 个字段的对象 - 一个字符数组,针对数组的偏置值,以及长度。这样实现 String,而不是仅仅拥有一个字符数组,是为了让多个 StringStringBuffer 对象可以共享同一个字符数组,这样可以避免额外的对象分配和复制。所以,方法 String.substring() 可以通过创建一个 String,共享同一个字符数组的方式,只需要修改偏置值和长度。对于 String 类,这 3 个字段都是 final 类型的

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

字符串 s2 有一个偏置值 4,以及长度 4。不过在老内存模型中,另一个线程可能能够看到偏置值为默认值 0,然后变成正确的值 4,这就好像字符串 s2 从 “/usr” 变成了 “/tmp“。

起初的内存模型允许这种行为,一些虚拟机已经出现了这种行为。新的内存模型认为这种行为是非法的。

个人见解

利用 String 的例子说明了原先的内存模型中 final 字段的缺陷,会出现线程安全的问题


final 字段在新的 Java 内存模型(JMM)下如何工作?

final 字段的值可以在对象构造器中设置。假设对象被“正确”的构造,那么一旦对象构造完成,这个 final 字段的值将被所有线程可见,不需要同步操作。另外,final 字段引用的其它对象或数组改变后,final 字段的值也一定会改变(the visible values for any other object or array referenced by those final fields will be at least up-to-date as the final fields)。

怎么样才是对象被正确的构造?简单的说,对象引用不应该在构造期间”泄露”。(举例:Safe construction techniques)换句话说,不应该放置构造期间的对象引用在另一个线程可能会看到它的地方;不要赋值给静态字段;不要将其注册为其它对象的监听器,等等(In other words, do not place a reference to the object being constructed anywhere another thread might be able to see it; do not assign it to a static field, do not register it as a listener with any other object, and so on.)。这些事情应该在构造完成后操作,而不是在构造期间。

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,因为它不是 final 类型。

为什么:在 reader 方法中首先判断静态实例变量 f 是否存在,如果存在,表明调用了构造器,那么 final 字段 x 的值指向最新值 3,但是字段 y 的最新值可能保存在缓存,没有刷新回内存,此时另一个线程读取时仍旧为默认值 0

如果构造器如下:

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

那么线程从 global.obj 读取 this 的引用,无法保证能够看到 x 值为 3

为什么:通过指令重排序,在 final 变量就泄露了当前对象的引用,那么此时调用变量 x 的值为默认值

如果字段是 final 修饰的,那么能够保证构造器结束后,代码能够看到该字段所指向的对象或者数组最新的值。所以,这种情况下,不需要担心其它线程看见了这个字段引用指向的数组,但是看不到数组最新的值(you can have a final pointer to an array and not have to worry about other threads seeing the correct value for the array reference, but incorrect values for the content of the array.)。再次声明,这个 “正确” 指的是 “对象构造器结束时最新的值” 而不是 “最新有效的值”(we mean "up to date as of the end of the object's constructor", not "the latest value available")。

讲了上面这段之后,如果一个线程构造了一个不可变对象(也就是说,一个仅包含 final 字段的对象),如果想要确保它能够被其它线程正确的看见,那么通常还是需要使用同步操作。不然,没有其它办法可以确保其它线程可以看见这个不可变对象的引用。程序从 final 字段得到的保证应该
在深入理解你的代码中并发是如何管理的情况下小心的调试。

如果你使用 JNI 方法来改变 final 字段,没有明确的定义。

个人见解

正确构造后的 final 字段能够被所有线程可见,但是无法确定 final 字段引用的数组或者对象是最新的值,所以需要结合实际程序,在多线程情况下最好是执行同步操作。

参考:Java内存模型FAQ(九)在新的Java内存模型中,final字段是如何工作的


volatile 做了什么?

volatile 字段是一个特殊的字段,它被用于线程之间沟通状态。对 volatile 字段的每一次读都将看到所有线程对它进行的最后一次写;实际上,它被程序员指定为永远不会因为缓存或者重排序而看到 “陈旧” 的值。编译器和运行时环境禁止将它分配在寄存器中。volatile 字段还确保在写完之后,会从缓存刷新回内存,这样就可以对所有线程可见。类似的,在 volatile 字段读之前,缓存的值必须失效,这样当前线程看到的值是来自主内存的。对 volatile 变量的重排序访问还有额外的限制。

在旧内存模型中,对 volatile 变量的访问无法重排序,但是可以对其它非 volatile 变量进行重排序访问。这削弱了 volatile 字段作为多个线程之间信号条件的可用性。

在新内存模型中, volatile 变量仍旧无法被重排序,差异是 volatile 变量周围的正常变量也不会很容易被重排序。写入一个 volatile 字段和一次监视器释放有相同的内存影响,读一次 volatile 字段和一次监视器获取有相同的内存影响。实际上,因为新的内存模型对 volatile 字段对其周围字段访问设置了更严格的约束条件,当线程 A 写入 volatile 字段 f 时,任何对线程 A 可见的变量也对写入字段 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 的读取从内存中获取了那个值(应该指 x)。因此,如果 reader 方法看到了 v 的值为 true,这也保证了对 x 的写入 42 操作一定 happends-before 它。这在旧内存模型不可能是真的,因为 v 不是 volatile 修饰的,所以编译器仍旧可以重排序它的读操作,在 reader 方法中 x 的值仍旧可能得到 0

事实上,volatile 的语义基本上加强到同步的水平。对 volatile 字段的每一次读和写就像半个同步操作,即保证了可见性。

Note: 为了正确的设置 happends-before 关系,两个线程应该去访问同一个 volatile 变量。如果线程 A 访问 volatile 字段 f,线程 B 访问 volatile 字段 g,那么线程 A 可见的变量不会对线程 B 也同样可见。对 volatile 变量的获取和释放应该匹配(即执行在同一个 volatile 字段)到同一个语义。

个人见解

两个线程对同一个 volaitle 变量进行读写操作(Note:先写再读),那么对读线程可见的变量对写线程同样可见,仅在这种情况下使用 volatile 即可保证线程安全,其它情况必须增加同步操作。

JLS 8.3.1.4 小节提出一个示例:

class Test {
    static volatile int i = 0, j = 0;
    static void one() { i++; j++; }
    static void two() {
        System.out.println("i=" + i + " j=" + j);
    }
}

变量 i,j 都是 volatile 声明的,线程 A 不断读取方法 one,线程 B 不断读取方法 two。这种情况下,仍有可能打印出 j 的值比 i 大,存在这种情况,在线程 B 读取 i 的值后,线程 A 执行了方法 one,然后线程 B 再读取 j

finalvolatile 不能同时使用,会产生编译错误

参考:Java Volatile Keyword


新的内存模型是否修复了”双重校验锁”(double-checked locking)的问题?

臭名昭著的双重校验锁(也被称为多线程单例模式)被设计用来支持延迟初始化(lazy initialization),以避免同步的开销。在早期的 JVM 中,同步速度很慢,所以开发者愿意去延迟加载。其实现代码如下:

// double-checked-locking - don't do this!
private static Something instance = null;

public Something getInstance() {
  if (instance == null) {                  // single checked
    synchronized (this) {
      if (instance == null)                // double checked
        instance = new Something();
    }
  }
  return instance;
}

这种代码结构看起来非常聪明 - 避免了在公共代码路径下进行同步。它只有一个问题 – 不工作。为什么?最明显的原因是因为初始化 instance 的写入和对 instance 字段的写入可以被编译器或缓存重排序,这样将出现返回一个部分构造的实例。结果就是我们读取了一个未初始化的对象。还有很多其它原因关于为什么这是错误的,以及为什么对它进行算法修正(algorithmic corrections)是错误的。在旧内存模型中没有方法可以修复它。更多的信息请查看 Double-checked locking: Clever, but brokenThe “Double Checked Locking is broken” declaration

许多人假定 volatile 关键字的使用将解决双重校验锁提出的问题。在 JDK 1.5 之前的 JVMvolatile 无法确保它工作;在新的内存模型中,声明 instancevolatile 将修复这个问题,因为这样就在构造线程对 Something 的初始化以及其它线程对它的读取之间存在一个 happens-before 关系。

相关,使用需求持有者(Demand Holder)方式,不仅线程安全而且更加容易理解:

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

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

这段代码保证是正确的,因为 static 字段的初始化保证;如果在静态初始化器中设置字段,对所有访问该类的线程而言是可见的。

个人见解

为什么叫 “双重校验锁”:因为有两次实例检查

为什么会出现问题:上面的解释没有很清晰,Double-checked locking: Clever, but brokenSo what's broken about DCL? 小节比较详细的介绍了重排序问题,翻译一下:

class SomeClass {
    private Resource resource = null;
    public Resource getResource() {
        if (resource == null) {
            synchronized {
                if (resource == null) 
                    resource = new Resource();
            }
        }
        return resource;
    }
}

双重校验锁依赖于对 resource 字段的非同步使用。这似乎是无害的,但是并不是。假设线程 A 在同步块中,执行表达式 resource = new Resource();这是线程 B 也进入了方法 getResource()。
考虑初始化对内存的影响:

* 新的 Resource 对象的内存被分配;
* Resource 的构造器被调用,初始化新对象的成员变量;
* resource 字段将引用这个新创建的对象。

然而,因为线程 B 没有在同步块中执行,它可能看到另一种顺序的内存操作:

* 分配内存;
* 赋值引用给 resource;
* 调用构造器。

假设线程 B 在对象内存已分配,字段已引用时进入。那么它看到了 resource 非空,跳过了同步块,返回了一个部分构造的 Resource 对象引用。不用说,结果既不是预期的,也不是期望的。

解决方法:上文提到,在旧内存模型中无法解决双重校验锁的问题。但是在新的内存模型中,声明字段为 volatile 即可。

原因:声明字段为 volatile,保证了对变量的可见性,读操作不会得到一个不完整的实例。

参考:

如何在Java中使用双重检查锁实现单例

Fixing Double-Checked Locking using Volatile


如果我写一个虚拟机,应该怎么办?

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


我应该关心哪些事情?

你应该关心哪些事情?并行 bug 是很难调试的。它们经常在测试阶段没有出现,而是等到程序在高负荷下才出现,并且很难去复现和发现。你最好花费时间去确保程序是正确同步的;尽管这也不容易,但是它比花费时间调试一个坏的同步程序更简单。

猜你喜欢

转载自blog.csdn.net/u012005313/article/details/81226981