2014-07-16 47 views
12

我剛剛跑過an article,它提出了我以前從未聽說過的聲明,並且找不到其他任何地方。聲明是從另一個線程的角度來看,構造函數返回的值的賦值可能會根據構造函數中的指令進行重新排序。換言之,聲明是在下面的代碼中,另一個線程可能會讀取一個非空值a,其中x的值尚未設置。構造函數和指令重新排序

class MyInt { 
    private int x; 

    public MyInt(int value) { 
     x = value; 
    } 

    public int getValue() { 
     return x; 
    } 
} 

MyInt a = new MyInt(42); 

這是真的嗎?

編輯:

我認爲這是保證從線程執行MyInt a = new MyInt(42)的角度來看,x分配有之前發生與a的分配關係。但是這兩個值都可以緩存在寄存器中,並且它們可能不會按照它們最初寫入的順序刷新到主內存。如果沒有內存屏障,另一個線程可以在寫入x的值之前讀取a的值。正確?

因此根據axtavt的回答以及後面的評論,這些線程安全評估是否正確?

// thread-safe 
class Foo() { 
    final int[] x; 

    public Foo() { 
     int[] tmp = new int[1]; 
     tmp[0] = 42; 
     x = tmp; // memory barrier here 
    } 
} 

// not thread-safe 
class Bar() { 
    final int[] x = new int[1]; // memory barrier here 

    public Bar() { 
     x[0] = 42; // assignment may not be seen by other threads 
    } 
} 

如果這是正確的......哇,這是非常微妙的。

+0

where'MyInt a = new MyInt(42);'located? –

+0

呃...我不知道。如果這有所作爲,你能否詳細解答一下答案? –

回答

8

您引用的文章在概念上是正確的。它的術語和用法有些不準確,您的問題也是如此,這會導致潛在的溝通不暢和誤解。看起來好像我在這裏討論術語,但是Java存儲模型非常微妙,如果術語不精確,那麼理解就會受到影響。

我將摘錄您的問題(以及來自評論)的觀點並向他們提供回覆。

由構造函數返回的值的賦值可能會根據構造函數中的指令進行重新排序。

幾乎是...這不是指令,但內存操作(讀取和寫入)可以重新排序。一個線程可以按照特定的順序執行兩條寫指令,但是數據到達內存,從而這些寫入其他線程的可見性可能會以不同的順序發生。

我認爲這是保證從線程執行MyInt a = new MyInt(42)的角度來看,x分配具有的a分配一個之前發生關係。

差不多。確實,在程序訂單中,分配給x的分配發生在分配給a之前。但是,發生 - 在是一個適用於所有線程的全局屬性之前,所以在針對特定線程討論發生之前沒有意義。

但是這兩個值都可能被緩存在寄存器中,並且它們可能不會按照它們最初寫入的順序刷新到主存儲器。如果沒有內存屏障,另一個線程可能會在寫入x的值之前讀取a的值。

還是差不多。值可以緩存在寄存器中,但部分內存硬件(如高速緩存或寫入緩衝區)也會導致重新排序。硬件可以使用各種機制來改變排序,例如緩存刷新或內存屏障(通常不會導致刷新,但僅僅是防止某些重新排序)。然而,在硬件方面思考這個問題的困難在於真實系統非常複雜,並且具有不同的行爲。例如,大多數CPU都有幾種不同的內存屏障。如果你想推理JMM,你應該考慮模型的元素:內存操作和通過建立約束來重新排序的同步 - 在關係之前發生。

因此,根據JMM重新審視這個例子,我們看到寫入字段x和按程序順序寫入字段a。這個程序中沒有什麼限制重新排序,即沒有同步,沒有揮發物的操作,沒有寫入最終字段。這些寫入之間沒有任何反應前的關係,因此可以重新排序。

有幾種方法可以防止這些重新排序。

一種方法是使x最終。這是有效的,因爲JMM表示在構造函數返回之前發生的返回操作之前寫入最終字段。由於a是在構造函數返回後寫入的,因此在寫入a之前會發生最終字段x的初始化,並且不允許重新排序。

另一種方法是使用同步。假設MyInt實例是在另一個類像這樣使用:

class OtherObj { 
    MyInt a; 
    synchronized void set() { 
     a = new MyInt(42); 
    } 
    synchronized int get() { 
     return (a != null) ? a.getValue() : -1; 
    } 
} 

解鎖在set()通話結束時寫入到xa領域後發生。如果另一個線程調用get(),它會在通話開始時鎖定。這建立了在set()結束時鎖定釋放與在get()開始鎖定獲取之間的發生之前的關係。這意味着對xa的寫入不能在get()調用開始後重新排序。因此讀者線程將看到ax的有效值,並且永遠不會找到非空的a和未初始化的x

當然,如果讀者線程先前調用get(),它可能會看到a爲空,但這裏沒有內存模型問題。

您的FooBar示例很有趣,您的評估基本正確。寫入分配給最終數組字段之前發生的數組元素之後不能重新排序。寫入數組元素後,可能會對後面發生的其他內存操作重新排序,因此其他線程可能確實會看到過時的值。

在您詢問有關String是否存在問題的評論中,因爲它有一個包含其字符的最終字段數組。是的,這是一個問題,但是如果你看看String.java構造函數,他們都非常小心地將構造函數最後的最終字段賦值給它。這確保了數組內容的正確可見性。

是的,這是微妙的。 :-)但是,只有在嘗試避免使用同步或變量變量時,纔會發生問題。大多數時候這樣做並不值得。如果您堅持「安全發佈」的做法,包括在構造函數調用期間不泄漏this,並且使用同步(如上面的我的OtherObj示例)存儲對構造對象的引用,則事情將按照您的預期完成。

參考文獻:

4

從Java存儲模型的意義上說 - 是的。但是,這並不意味着你會在實踐中觀察它。

從以下角度來看:可能會導致可見重新排序的優化不僅會在編譯器中發生,還會在CPU中發生。但是CPU並不知道任何有關對象及其構造函數的信息,對於處理器來說,它只是一對分配,如果CPU的內存模型允許,它可以重新排序。

當然,編譯器和JVM可能會指示CPU不要通過將memory barriers放置在生成的代碼中來重新排序這些分配,但對所有對象這樣做都會破壞可能嚴重依賴於這種積極優化的CPU的性能。這就是爲什麼Java內存模型不能爲這種情況提供任何特殊保證的原因。

這導致了例如在Java存儲器模型下的Double checked locking singleton implementation中的衆所周知的缺陷。

+0

有沒有在實踐中觀察到的情況?換句話說,我是否需要放置一個內存屏障,我希望保證在引用代碼後立即調用'a.getValue()'將返回'42'? –

+1

@MisrableVariable:有人聲稱一些非常老的JVM實現在編譯器中實際做了這樣的重新排序。關於CPU優化,就我所知x86內存模型在這種情況下不允許任何重新排序,因此在x86上檢查鎖定是安全的。儘管我認爲不管違反語言的記憶模型都不是一個好主意。 – axtavt

+1

我對臭名昭着的雙重檢查鎖定的理解是,它在1.5之前被破解,在這一點上,它真正開始工作,這要歸功於'volatile'關鍵字提供的新保證。但是,如果太聰明,並且沒有真正獲得多少,仍然被認爲是不好的做法。 –

1

換言之,聲明是在下面的代碼中,另一個線程可能會讀取其中沒有設置x的值的非空值。

簡短答案是肯定的。

龍答:是支撐着另一個線程讀取一個非空a與尚未設置的x值點 - 不嚴格的指令重新排序,但處理器在其寄存器緩存值(和L1緩存)而不是從主內存中讀取這些值。這可能間接暗示重新排序,但這不是必需的。

雖然CPU寄存器中的值緩存有助於加快處理速度,但它引入了在不同CPU上運行的不同線程之間值的可見性問題。如果始終從主程序區域讀取值,則所有線程始終會看到相同的值(因爲是該值的一個副本)。在您的示例代碼中,如果成員字段x的值被緩存到由線程1訪問的CPU1的寄存器中,並且另一個在CPU-2上運行的線程2現在從主內存讀取並更新它,從程序的角度來看,在CPU-1中緩存的這個值(由Thread-1處理)現在是無效的,但Java規範本身允許虛擬機將其視爲有效的場景。

+0

如果我正確理解你的答案,我認爲你正在回答錯誤的問題。請注意'MyInt'中沒有setter。一旦由構造函數設置,'x'的值永遠不會被任何線程更新。這可能是最終的,但文章聲稱,它不是最終的事實會產生潛在的問題。我還是不明白,在你的解釋中,Thread-2如何在'a'引用的對象中的'x'的值設置爲42之前讀取'a' **的非空值。 –

+0

Your混淆是有效的 - 在它的構造函數中設置後,你沒有更新'x'的值。但想象一下 - 在我的答案中引用線程 - 在Thread-2上調用構造函數(它將缺省值(0代表int類型)更新爲value代碼),而Thread-1調用參考'了'。你看到問題了嗎? – Bhaskar

+0

我可能會更困惑。你是否只是在你的評論和你的回答中扭轉了線索? –