在Java JVM中重新排序的说明

我正在阅读这篇博文: http : //jeremymanson.blogspot.hk/2008/12/benign-data-races-in-java.html

作者在谈论在multithreading环境中打破String中的hashCode()

有了:

 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一书,但我似乎无法找到一个可以帮助我理解重新排序的地方。 如果有人能指出我正确的方向,我会非常感激。

编辑:感谢路易斯澄清“重新排序”的含义,我没想到“byteCode”

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

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

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

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

 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; 

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

似乎JVM正在进行简化

h = hash,它简化了R1,R2,R3到单个R1的使用

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

有什么想法吗?

在您修改的代码中:

 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是volatile,那么修改后的代码就没有问题了。

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; } 

我机器上的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 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); } 

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

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

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

通俗地说,我认为这个问题与read(fetch)重新排序有关。

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

坏情况:

每个线程需要读取(实例)变量两次 ,一次检查返回值的if和once。 让我们说T1选择首先执行if和T2选择首先执行return读取的参数。

这创建了一个竞争条件,即hash变换由T1和T2的第二次读取(T2用于检查if条件) 之间hash变量(由T1)改变。 所以现在T2的测试是假的,它什么都不做,并返回它(最初)为实例变量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; } 

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