簡體   English   中英

在Java JVM中重新排序的說明

[英]Instructions reordering in Java JVM

我正在閱讀這篇博文。

作者在談論在多線程環境中打破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;

我嘗試用“字節碼”形式表達它:

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不僅可以重新排序指令,還可以減少使用的寄存器數量。

在您修改的代碼中:

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

代碼在單線程環境中嚴格等效(它將始終返回與原始方法相同的值),因此允許重新排序。 但是在多線程環境中,很明顯如果hash從前兩行之間的另一個線程從0更改為1,則此重新排序的方法將錯誤地返回0。

我認為需要注意的關鍵是,在得到錯誤答案的線程中(返回0), if語句的主體不會被執行 - 忽略它,可能是任何東西。

錯誤的讀取線程讀取非易失性字段兩次但從不寫入。 所以我們只討論兩次讀取的順序。 聲稱這些都沒有訂購。 在更復雜的情況下,可能存在別名,並且編譯器檢查這是否是相同的內存位置將是非常重要的。 采取保守的路線可能會阻止優化。

通俗地說,我認為這個問題與read(fetch)重新排序有關。

每個線程T1和T2都希望獲得所有“輸入”來進行處理(並且沒有嚴格的volatile標記),他們可以自由地了解如何/何時讀取數據。

壞情況:

每個線程需要讀取(實例)變量兩次 ,一次檢查返回值的if和once。 讓我們說T1選擇首先執行if和T2選擇首先執行return讀取的參數。

這產生了競爭條件,該hash的更新之間的可變被改變(通過T1) hash由T1和T2的第二讀取(其T2使用來檢查if條件)。 所以現在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;
 }

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

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM