33

我正在阅读这篇博文。

而作者正在谈论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;
 }

作者说,我引用:

“我在这里所做的是添加一个额外的读取:在 return 之前第二次读取哈希。听起来很奇怪,而且不太可能发生,第一次读取可以返回正确计算的哈希值, “

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

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

这怎么可能?我认为重新排序只涉及上下移动程序语句。它遵循什么规则?我在 Google 上搜索过,阅读了 JSR133 常见问题解答,查看了 Java 并发实践一书,但我似乎找不到一个可以帮助我理解特别是重新排序的地方。如果有人能指出我正确的方向,我将不胜感激。

感谢 Louis 阐明了“重新排序”的含义,我并没有考虑“字节码”

但是,我仍然不明白为什么允许将 2nd Read 移到前面,这是我将其转换为某种“字节码”格式的天真尝试。

为简化起见,用于计算哈希码的操作表示为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

4 回答 4

14

在您修改的代码中:

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

在第一个示例中,共享变量有 2 次读取hash: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。

于 2012-09-23T17:39:49.323 回答
1

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

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

坏情况:

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

这会产生竞争条件,即在T1更新和 T2 的第二次读取(T2 用来检查条件)之间hash更改变量(由 T1 )。所以现在 T2 的测试是假的,它什么也不做,并返回它为实例变量 0 读取的内容(最初)。hashif

固定案例:

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

于 2013-03-18T00:21:14.093 回答
1

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

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

于 2012-09-23T18:29:01.507 回答
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;
 }

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

于 2013-03-17T23:05:44.407 回答