在Java JVM中重新排序的说明

时间:2012-09-23 17:33:35

标签: java concurrency

我正在阅读this blogpost。

作者在谈论在多线程环境中打破hashCode()中的String

通过:

public int hashCode() {
     int h = hash;
     if (h == 0) {
         int off = offset;
         char val[] = value;
         int len = count;

         for (int i = 0; i < len; i++) {
             h = 31*h + val[off++];
         }
         hash = h;
     }
     return h;
 }

更改为:

public int hashCode() {
     if (hash == 0) {
         int off = offset;
         char val[] = value;
         int len = count;

         int h = 0;
         for (int i = 0; i < len; i++) {
             h = 31*h + val[off++];
         }
         hash = h;
     }
     return hash;
 }

作者说,我引用:

  

“我在这里做的是添加一个额外的读取:第二次读取哈希,在返回之前。听起来很奇怪,并且不太可能发生,第一次读取可以返回正确计算的哈希值,第二次读取可以返回0!这是在内存模型下允许的,因为该模型允许对操作进行大量重新排序。第二次读取实际上可以在代码中移动,以便处理器在第一次之前做到了!“

所以进一步评论,有人说它可以重新订购

int h = hash;
if (hash == 0) {
  ...
}
return h;

怎么可能?我认为重新排序只涉及上下移动程序语句。遵循什么规则?我已经Google搜索了,阅读了JSR133常见问题解答,检查了Java Concurrency in Practice一书,但我似乎无法找到一个可以帮助我理解重新排序的地方。如果有人能指出我正确的方向,我会非常感激。

感谢Louis澄清“重新排序”的含义,我没有考虑“byteCode”

然而,我仍然不明白为什么允许将第二个读取移到前面,这是我天真的尝试将其转换为某种“字节码”格式。

为简化起见,用于计算哈希码的操作表示为calchash()。因此,我将该程序表达为:

if (hash == 0)  {       
    h = calchash();
    hash = h;
}
return hash;

我尝试用“字节码”形式表达它:

R1,R2,R3 are in the operands stack, or the registers
h is in the array of local variables

按程序顺序:

if (hash == 0)  {       ---------- R1 = read hash from memory (1st read)
                        ---------- Compare (R1 == 0)
    h = calchash();     ---------- R2 = calchash()
                        ---------- h = R2 (Storing the R2 to local variable h)
    hash = h;           ---------- Hash = h (write to hash)
}
return hash             ---------- R3 = read hash from memory again(2nd read)
                        ---------- return R3

重新排序转换(我的版本基于评论):

                        ---------- R3 = read hash from memory (2nd read) *moved*
if (hash == 0)  {       ---------- R1 = read hash from memory (1st read)
                        ---------- Compare (R1 == 0)
    h = calchash();     ---------- R2 = calchash()
                        ---------- h = R2 (Storing the R2 to local variable h)
    hash = h;           ---------- hash = h (write to hash)
}
return hash             ---------- return R3

再次检查评论,我发现作者已经回答了这个问题:

重新排序转换(来自博客)

r1 = hash;
if (hash == 0) {
  r1 = hash = // calculate hash
}
return r1;

这种情况实际上适用于单线程,但是可能会因多线程而失败。

似乎JVM正在基于

进行简化
h = hash and it simplifies the use of R1, R2, R3 to single R1

因此,JVM不仅仅重新排序指令,它似乎也减少了正在使用的寄存器数量。

4 个答案:

答案 0 :(得分:12)

在修改过的代码中:

public int hashCode() {
     if (hash == 0) { // (1)
         int off = offset;
         char val[] = value;
         int len = count;

         int h = 0;
         for (int i = 0; i < len; i++) {
             h = 31*h + val[off++];
         }
         hash = h;
     }
     return hash; // (2)
 }

(1)和(2)可以重新排序:(1)可以读取非空值而(2)读取0.这在String类的实际实现中不会发生,因为计算是在局部变量和返回值也是局部变量,根据定义,它是线程安全的。

问题是Java内存模型无法保证在没有正确同步的情况下访问共享变量(hash) - 特别是它不能保证所有执行都是顺序一致的。如果hash易变,则修改后的代码就没有问题。

ps:我相信,该博客的作者是JLS第17章(Java内存模型)的作者之一 - 所以无论如何我都倾向于相信他; - )


<强>更新

遵循各种编辑/评论 - 让我们用这两种方法更详细地看一下字节码(我假设哈希码总是1来保持简单):

public int hashcode_shared() {
    if (hash == 0) { hash = 1; }
    return hash;
}

public int hashcode_local() {
    int h = hash;
    if (h == 0) { hash = h = 1; }
    return h;
}

我机器上的java编译器生成以下字节码:

public int hashcode_shared();
   0: aload_0                           //read this
   1: getfield      #6                  //read hash (r1)
   4: ifne          12                  //compare r1 with 0
   7: aload_0                           //read this
   8: iconst_1                          //constant 1
   9: putfield      #6                  //put 1 into hash (w1)
  12: aload_0                           //read this
  13: getfield      #6                  //read hash (r2)
  16: ireturn                           //return r2

public int hashcode_local();
   0: aload_0                           //read this
   1: getfield      #6                  //read hash (r1)
   4: istore_1                          //store r1 in local variable h
   5: iload_1                           //read h
   6: ifne          16                  //compare h with 0
   9: aload_0                           //read this
  10: iconst_1                          //constant 1
  11: dup                               //constant again
  12: istore_1                          //store 1 into h
  13: putfield      #6                  //store 1 into hash (w1)
  16: iload_1                           //read h
  17: ireturn                           //return h

在第一个示例中,共享变量hash有2次读取:r1和r2。如上所述,由于没有同步且变量是共享的,因此Java内存模型适用,并且允许编译器/ JVM对两个读取重新排序:第13行可以在第1行*之前插入。

在第二个例子中,h(本地变量)上的所有操作都需要按顺序一致,因为非线程语义和非共享变量的程序顺序保证。

注意:一如既往,允许重新排序的事实并不意味着它将被执行。它实际上不太可能发生在当前的x86 /热点组合上。但它可能发生在其他当前或未来的架构/ JVM上。


*这是一个捷径,在实践中可能发生的事情是编译器可能会像这样重写hashcode_shared

public int hashcode_shared() {
    int h = hash;
    if (hash != 0) return h;
    return (hash = 1);
}

代码在单线程环境中严格等效(它将始终返回与原始方法相同的值),因此允许重新排序。但是在多线程环境中,很明显如果前两行之间的另一个线程将hash从0更改为1,则此重新排序的方法将错误地返回0。

答案 1 :(得分:1)

我认为需要注意的关键是,在得到错误答案的线程中(返回0),if语句的主体不会被执行 - 忽略它,可能是任何东西。

错误的读取线程读取非易失性字段两次但从不写入。所以我们只讨论两次读取的顺序。声称这些都没有订购。在更复杂的情况下,可能存在别名,并且编译器检查这是否是相同的内存位置将是非常重要的。采取保守的路线可能会阻止优化。

答案 2 :(得分:1)

用外行人的话来说,我认为这个问题与read(fetch)重新排序有关。

每个线程T1和T2都希望得到所有“输入”来进行处理(并且没有严格的volatile标记),他们可以自由地了解如何/何时读取他们的数据。

坏情况:

每个线程需要读取(实例)变量两次,一次检查if,一次检查返回值。假设T1选择首先进行if读取而T2选择首先进行return读取。

这会创建竞争条件,hash变量(通过T1)更新{/ 1>更新hash T1和T2的第二次读取(T2用于检查) if条件。所以现在T2的测试是假的,它什么都不做,并返回它(最初)为实例变量0读取的内容。

固定案例:

每个线程只需要读取(实例)变量一次,然后立即将其存储在自己的局部变量中。这样就不会发生重新排序读取问题(因为只有一次读取)。

答案 3 :(得分:0)

首先是代码:

int hash = 0;

public int hashCode() {
     if (hash == 0) {
         int off = offset;
         char val[] = value;
         int len = count;

         int h = 0;
         for (int i = 0; i < len; i++) {
             h = 31*h + val[off++];
         }
         hash = h;
     }
     return hash;
 }

显然,我们可以将其简化为:

int hash = 0;

public int hashCode() {
     if (hash == 0) {
         // Assume calculateHash does not return 0 and does not modify hash.
         hash = calculateHash();
     }
     return hash;
 }

现在理论建议在一个线程上以特定方式与第二个线程交织的重新排序可以导致零返回。我能想象的唯一场景是:

// Pseudocode for thread 1 starts with <1>, thread 2 with <2>. 
// Rn are local registers.
public int hashCode() {
     <2> has not begun
     <1> load r1 with hash (==0) in preparation for return for when hash is != 0
     <2> begins execution - finds hash == 0 and starts the calculation
     <2> modifies hash to contain new value.
     <1> Check hash for zero - it is not so skip the contents of the if
     if (hash == 0) {
         // Assume calculateHash does not return 0 and does not modify hash.
         hash = calculateHash();
         <2> got here but at a time when <1> was up there ^^
     }
     <1> return r1 - supposedly containing a zero.
     return hash;
 }

但是 - 对我来说 - 可以使用好的代码进行类似的处理:

public int hashCode() {
     int h = hash;
     if (h == 0) {
         hash = h = calculateHash();
     }
     return h;
 }

然后

public int hashCode() {
     <2> has not begun
     <1> load r1 with hash (==0) in preparation for return for when h is != 0
     <2> begins execution - finds h == 0 and starts the calculation
     <2> modifies hash to contain new value.
     <1> load r2 with hash - from now on r2 is h
     int h = hash;
     <1> Check r2 for zero - it is not so skip the contents of the if
     if (h == 0) {
         hash = h = calculateHash();
     }
     <1> we know that when hash/h are non-zero it doesn't matter where we get our return from - both r1 and r2 must have the same value.
     <1> return r1 - supposedly containing a zero.
     return h;
 }

我不明白为什么一个是真正的可能性而另一个不是。