2008年10月2日星期四

浅谈JMM的reordering导致的一些问题

1997年开始JVM的内存问题开始逐渐暴露出来,不同于脚本语言的简单内存模型或者C的不完整内存模型,由于Java是历史上第一个提供完整的复杂的内存模型的语言和运行环境,有没有考虑周全的问题是必然的。这后来导致了一代宗师Doug Lea的JSR133。这引发了很多VM和语言本身的变化,比如volatile关键字的含义的变化。我觉得reordering是其中比较tricky的一个问题,直到JDK5才得以解决。

什么是reordering问题?
表示对代码指令的执行顺序的改变。比如先写a再写b,如果b和a不是互相依赖,编译器可以改成先写a和另外一个变量,凑成一个字长写回,这种优化不仅可能发生在编译期间,甚至发生在运行期。这些看起来是很聪明的优化,但是这些优化大多数只是对单线程的执行优化,在多线程的时候会导致问题。过去C是依赖于线程类库来保证内存操作的正确性,Java是通过JMM实现的。

recording可以发生在几种情况如下:

*编译期间,编译器可以对某些指令重新排序甚至改变来提高性能,比如inline
*运行期间,处理器在某些情况下可能会改变指令的运算顺序来提高性能,比如64位子长机器为了一次写两个32位的值
*运行期间,缓存(寄存器)有可能不是按照指令的顺序写回更新

比如Doug Lea的一个例子:
Class Reordering {
int x = 0, y = 0;
public void writer() {
x = 1;
y = 2;
}

public void reader() {
int r1 = y;
int r2 = x;
}
}
一个线程调用writer的时候如果处理器发生了reordering,可能会先执行y=2然后再执行x=1。如果这时候另外一个线程调用reader,y被读取的时候可以看到2但是不一定能看到x=1,很可能看到x=0,因为writer执行的顺序发生了变化.

另外,对于比较短小的函数,编译器会做inline,和C++里inline关键字含义很类似,这样可以减少方法调用栈的频繁压栈弹栈来提升性能。比如这样一个构造函数
public Foo {
public int x = 0;
public Foo(int v) {
x=v;
}
}
当编译Foo f = new Foo()的时候,编译器会inline成两行普通代码:
Foo f = 分配内存;
f.x=0;
f.x=v;
这样导致一个问题就是如果另外一个线程看到x的时候可能是0,稍后又变成v.这会导致另外一个线程看到了一个不完全构造的Foo对象!即便你在构造函数内没有写类似于global.ref=this,即便你没有escape this reference到外部线程,由于这种inline+reordering的优化,外部线程仍然可以看到半成品的对象。这对于一般的情况我们可能会考虑把x作为私有的不让外部线程访问,或者其他线程访问的时候同步,因为同步会除了mutex和flush还会设立reordering的上下边界,也就是说在同步边界上,另外一个线程不会先获得s2的引用,必须退出同步块,s2完全构造结束后另一个线程才能得到s2的引用。但是对于我们通常理解final应该不受这个约束的,事实上老的JMM不是这样处理的,这对于final的字段是一场灾难。

final问题:
过去final没有被特殊处理,这导致final的东西不final,会发生很困惑不可预料的问题。
比如String这个类,包含了三个final成员变量,分别是长度,偏移量和一个字符数组。
下面的两行代码如果被一个线程执行到第二行的时候,
String s1 = "/usr/tmp";
String s2 = s1.substring(4);
JVM为s1.substring()所将要产生的String调用parent constructor Object(),这时候heap分配Object所需要的内存并把地址赋值给s2,然后所有final的成员变量会被初始化为0,然后String的构造函数再把offset和length设置为期望的值。由于在老的JMM里在内存地址先复制给s2然后再调用String的构造函数就会导致另外一个线程如果这时候使用了s2就会看到offset=0,也就是说s2="/usr/tmp",稍后再一次使用s2又会看到s2="/tmp"
这样感觉就是final的字段也会变化,immutable的String也会变化,这个问题会非常难以发现。JSR133新的内存模型改变了这种行为,叫做initialization safety. 意思是说当一个对象构造过程中没有把自己通过引用暴露给其他线程,构造函数结束后,这个对象才算安全构造成功,之后这个对象的内部所有的final字段都可以不需要同步就可以让外部的线程看到。这也隐含着新的JMM对s2的赋值会发生在构造函数完全结束,如果有final字段的话。

volatile问题
一般来说普通变量会被保存到线程级别local copy,通常是寄存器,用的时候直接从速度最快的寄存器读取,避免多次频繁的寻址读取主存,这就意味着在某些情况下必须在主存和local copy之间flush.这种flush保证了同步,保证了happens before 原则。
而volatile他会总是访问主存而不是local copy,换句话说运算的时候,每次都是重新读取内存然后写入寄存器再参与计算,L1/L2 cache也不会缓存。
这样他总是可以看到其他线程对主存的最新的操作结果。但是这不能保证独享,因为没有lock
比如两个线程都进行a++,那么理路上可能两个线程都看到了初始值0,然后都累加到1,都在某个flush的时候写回主存,结果是最终结果不是2,而是1。所以这个不可以用于计数器或者信号量。

但是他更快更简单,不需要锁,但是如果你有大量的volatile变量参与运算,那么大量的main memory操作也可能会比较慢,这时候你也可以考虑用lock批量的操作之后flush回main memory。

老的JMM中volatile是仅仅不可以和volatile变量之间reordering,但和普通变量之间是可以发生reordering的,这导致实用性大大下降。Doug Lea给了一个例子:
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.
}
}
}
如果在老的JMM中,x和v可以reorder,处理器可能先写了v=true,这个时候还没有写x=42,恰巧这个时候另外一个线程执行reader(),那么这个线程可以看到v=true,然后认为x一定已经是42了,其实不然,这时候x是0!只有在新的JMM中,volatile和non-volatile彼此不允许在一个方法调用栈中recorder的时候才能保证数据操作的顺序性。注意:volatile还保证原子性。对于long和double由于是64位,其他primitives类型是32位,所以在32位字长的系统上long和double不能保证原子性,这时候需要volatile.对于64为系统是否需要volatile我不敢说,但是我会安全起见也加上volatile,毕竟Java会运行在不同的硬件平台上。

一个相关的有趣的问题可以在这里找到,volatile禁止reordering最终解决了DCL问题。
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html

这里我只是简单介绍一下:
下面这段代码想避免每次都要同步的开销同时保证只创建一次helper.

class Foo {
private Helper helper = null;
public Helper getHelper() {
if (helper == null)
synchronized(this) {
if (helper == null)
helper = new Helper();
}
return helper;
}
// other functions and members...
}
其实这里有个问题,helper的构造可能会被Inline+reorder,导致赋值和构造顺序的变化,所以你是没有办法保证得到一个完全构造好的helper的,除非helper所有的字段都是final的,而且运行在JDK5下。有了volatile可以禁止这种优化,下面的代码就可以正常运行:

class Foo {
private volatile Helper helper = null;
public Helper getHelper() {
if (helper == null) {
synchronized(this) {
if (helper == null)
helper = new Helper();
}
}
return helper;
}
}

当然这里注意的是对于32位的primitive,不加volatile的DCL也是可以的,因为32位字长的机器写32位的int/float本身就是硬件提供了原子操作的能力的,但是32位子长的机器写64位的long和double的时候,也一定要加volatile,因为第一个进入的线程写高32位和低32位之间,如果另外一个线程进入同步快会看到helper不等于0(其实是写了一半的数值),第二个线程就会面临一场灾难了.

volatile还有一些技巧,比如当作cheap lock用:
public class CheesyCounter {
private volatile int value;
public int getValue() { return value; }
public synchronized int increment() {
return value++;
}
}

小结,JMM其实更为复杂的多,写出伸缩性好的程序一定要对JMM有基本的理解,因为JMM其实主要是和线程相关的,而大规模的应用几乎没有不使用线程的,而且可能是非常intensive的使用。

没有评论: