在Java JVM中重新排序的说明

HBZ*_*HBZ 30 java concurrency

我正在阅读这篇博文.

作者正在谈论在多线程环境中打破hashCode()in 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;
 }
Run Code Online (Sandbox Code Playgroud)

变成:

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;
 }
Run Code Online (Sandbox Code Playgroud)

作者说,我引用:

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

所以进一步通过评论,有人说它可以重新排序

int h = hash;
if (hash == 0) {
  ...
}
return h;
Run Code Online (Sandbox Code Playgroud)

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

感谢路易斯澄清"重新排序"的含义,我没有想到"byteCode"

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

出于简化目的,用于计算哈希码的操作表示为calchash().因此,我将该计划表达为:

if (hash == 0)  {       
    h = calchash();
    hash = h;
}
return hash;
Run Code Online (Sandbox Code Playgroud)

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

R1,R2,R3 are in the operands stack, or the registers
h is in the array of local variables
Run Code Online (Sandbox Code Playgroud)

按程序顺序:

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
Run Code Online (Sandbox Code Playgroud)

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

                        ---------- 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
Run Code Online (Sandbox Code Playgroud)

再次检查评论,我发现作者回答:

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

r1 = hash;
if (hash == 0) {
  r1 = hash = // calculate hash
}
return r1;
Run Code Online (Sandbox Code Playgroud)

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

似乎JVM正在进行简化

h = hash and it simplifies the use of R1, R2, R3 to single R1
Run Code Online (Sandbox Code Playgroud)

因此,JVM不仅可以重新排序指令,还可以减少使用的寄存器数量.

ass*_*ias 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)
 }
Run Code Online (Sandbox Code Playgroud)

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

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

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


UPDATE

在各种编辑/评论之后 - 让我们用这两种方法更详细地看一下字节码(我假设哈希码总是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;
}
Run Code Online (Sandbox Code Playgroud)

我机器上的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
Run Code Online (Sandbox Code Playgroud)

在第一个示例中,共享变量有2个读取hash:r1和r2.如上所述,因为没有同步并且变量是共享的,所以应用Java Memory Model并允许编译器/ JVM对两个读取重新排序:可以在第1行*之前插入第13行.

在第二个示例中,h局部变量上的所有操作都需要按顺序一致,因为非线程语义和非共享变量的程序顺序保证.

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


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

public int hashcode_shared() {
    int h = hash;
    if (hash != 0) return h;
    return (hash = 1);
}
Run Code Online (Sandbox Code Playgroud)

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

  • @ user1671022 JLS声明:*"隔离的每个线程的动作必须表现为受该线程语义的控制,但每次读取所看到的值都由内存模型决定."*=>换句话说,一旦你开始阅读共享值,就可能发生重新排序,这些重新排序不必遵循单个线程的重新排序规则.您可以说,当相同的共享值被读取两次时,witohut同步时,第二次读取可以读取旧值,而第一次读取看到更新的值时,而不是讨论指令重新排序. (2认同)