1. 程式人生 > 實用技巧 >Object wait() 、notify()

Object wait() 、notify()

一、wait() 、notify() 方法

1. 為什麼必須和synchronized一起使用?

因為wait()notify()是通過物件來進行執行緒通訊的,而依靠物件進行執行緒通訊需要同步保證。

同步的作用:

  1. 防止notify()有wait()方法的執行的順序錯亂,導致wait執行緒無法被喚醒;
  2. 防止記憶體可見性問題

兩個執行緒之間要通訊,對於同一個物件來說,一個執行緒呼叫該物件的wait(),另一個執行緒呼叫該物件的notify(),該物件本身就需要同步!所以,在呼叫wait()、notify()之前,要先通過synchronized關鍵字同步給物件,也就是給該物件加鎖。

—— 摘自:Java併發實現原理:JDK原始碼剖析

2. 為什麼是放在Object物件?而不是Thread?

考慮到synchronized是物件鎖,可將任何物件當做鎖的普遍性;wait()、notify()又需要通過synchronzied保證同步,考慮到普遍性,所以將wait()、notify()一起放在Object。

synchronized關鍵字可以加在任何物件的成員函式上面,任何物件都可能成為鎖。那麼,wait()和notify()要同樣如此普及,也只能放在Object裡面了。

—— 摘自:Java併發實現原理:JDK原始碼剖析

二、 JMM與happen-before

1. 為什麼出現記憶體可見性?

首先要知道CPU的架構設計,CPU 為了提高計算效率,提供了多級快取(L1,L2,L3)。因為CPU存在快取一致性協議,如MESI

協議,保證了多個CPU之間的快取不會出現不同步的問題,不會出現“記憶體可見性問題”;

但是,快取一致性協議對效能有很大損耗。所以,為了解決這個問題,CPU設計者又加了很多優化,比如加入了:Store Buffer Load Buffer(還有其他Buffer)。L1,L2,L3和主記憶體是同步的,但是Store BufferLoad BufferL1之間是非同步的,記憶體可見性問題主要是在這裡出現的。

記憶體可見性,指的不是記憶體一直不可見,而是稍後可見

MESI中每個快取行都有四個狀態,分別是E(exclusive)、M(modified)、S(shared)、I(invalid)。

M:代表該快取行中的內容被修改了,並且該快取行只被快取在該CPU中。這個狀態的快取行中的資料和記憶體中的不一樣,在未來的某個時刻它會被寫入到記憶體中(當其他CPU要讀取該快取行的內容時。或者其他CPU要修改該快取對應的記憶體中的內容時(個人理解CPU要修改該記憶體時先要讀取到快取中再進行修改),這樣的話和讀取快取中的內容其實是一個道理)。

E:代表該快取行對應記憶體中的內容只被該CPU快取,其他CPU沒有快取該快取對應記憶體行中的內容。這個狀態的快取行中的內容和記憶體中的內容一致。該快取可以在任何其他CPU讀取該快取對應記憶體中的內容時變成S狀態。或者本地處理器寫該快取就會變成M狀態。

S:該狀態意味著資料不止存在本地CPU快取中,還存在別的CPU的快取中。這個狀態的資料和記憶體中的資料是一致的。當有一個CPU修改該快取行對應的記憶體的內容時會使該快取行變成 I 狀態。

I:代表該快取行中的內容時無效的。

2. 重排序

Store Buffer的延遲寫入是重排序的一種,稱為記憶體重排序(Memory Ordering)。

除此之外,還有編譯器和CPU的指令重排序。下面對重排序做一個分類:

  • (1)編譯器重排序。對於沒有先後依賴關係的語句,編譯器可以重新調整語句的執行順序。
  • (2)CPU指令重排序。在指令級別,讓沒有依賴關係的多條指令並行。
  • (3)CPU記憶體重排序。CPU有自己的快取,指令的執行順序和寫入主記憶體的順序不完全一致。

在三種重排序中,第三類就是造成“記憶體可見性”問題的主因。

建構函式溢位問題:

答案是:a,b未必一定等於1,2。和DCL(double check lock)的例子類似,也就是建構函式溢位問題。

obj=new Example() 這行程式碼,分解成三個操作:

① 分配一塊記憶體;

② 在記憶體上初始化 i=1,j=2

③ 把obj指向這塊記憶體。

操作②和操作③可能重排序,因此執行緒B可能看到未正確初始化的值。

對於建構函式溢位,通俗來講,就是一個物件的構造並不是“原子的”,當一個執行緒正在構造物件時,另外一個執行緒卻可以讀到未構造好的“一半物件”。

3. as-if-serial 語義(像序列一樣執行)

對開發者而言,當然不希望有任何的重排序,指令執行順序與程式碼保持一致,這樣更容易理解。

對編譯器、CPU的角度來看,希望盡最大可能進行重排序,提升執行效率。

於是,問題就來了,重排序的原則是什麼?什麼場景下可以重排序,什麼場景下不能重排序呢

  • a. 單執行緒重排序規則:不管如何重排序,單執行緒程式的執行結果不能改變。
  • b. 多執行緒 重排序規則:多執行緒之間的資料依賴過於複雜,編譯器、CPU無法處理,所以,編譯器、CPU只保證各個執行緒的as-if-serial

執行緒之間的資料依賴和相互影響,需要編譯器和CPU的上層來確定。上層要告知編譯器和CPU在多執行緒場景下什麼時候可以重排序,什麼時候不能重排序。

如:volatile

4. happen-before是什麼?

1)如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。

2)兩個操作之間存在happens-before關係,並不意味著Java平臺的具體實現必須要按照happens-before關係指定的順序來執行。如果重排序之後的執行結果,與按happens-before關係來執行的結果一致,那麼這種重排序並不非法(也就是說,JMM允許這種重排序)。

happen-before跟as-if-serial 本質是相同的:

  • as-if-serial語義保證單執行緒內程式的執行結果不被改變,happens-before關係保證正確同步的多執行緒程式的執行結果不被改變。
  • as-if-serial語義給編寫單執行緒程式的程式設計師創造了一個幻境:單執行緒程式是按程式的順序來執行的。
    happens-before關係給編寫正確同步的多執行緒程式的程式設計師創造了一個幻境:正確同步的多執行緒程式是按happens-before指定的順序來執行的。

5. JSR-133對volatile語義的增強

Java中的volatile關鍵字不僅具有記憶體可見性,還會禁止volatile變數寫入和非volatile變數寫入的重排序,但C++中的volatile關鍵字不會禁止這種重排序。

Java的volatile比C++多出的這點特性,正是JSR-133對volatile語義的增強。

下面這段話摘自JSR-133的原文:

What was wrong with the old memory model?The old memory model allowed for volatile writes to be reordered withnonvolatile reads and writes,which was not consistent with most developersintuitions about volatile and therefore caused confusion.

也就是說,在舊的JMM模型中,volatile變數的寫入會和非volatile變數的讀取或寫入重排序,正如C++中所做的。但新的模型不會,這也正體現了Java對happen-before規則的嚴格遵守。

6. happen-before規則總結

(1)單執行緒中的每個操作,happen-before於該執行緒中任意後續操作。

(2)對volatile變數的寫,happen-before於後續對這個變數的讀。

(3)對synchronized的解鎖,happen-before於後續對這個鎖的加鎖。

(4)對final變數的寫,happen-before於final域物件的讀,happen-before於後續對final變數的讀。

四個基本規則再加上happen-before的傳遞性,就構成JMM對開發者的整個承諾。

在這個承諾以外的部分,程式都可能被重排序,都需要開發者小心地處理記憶體可見性問題。

個人理解:

JMM(java記憶體模型)主要是為了解決執行緒之間的記憶體可見性問題,記憶體可見性問題中主要是由於記憶體重排序導致的。

為什麼需要記憶體重排序呢?

因為CPU架構的原因(Load Buffer、Store Buffer非同步重新整理主記憶體),CPU為了提高效能所做的優化;但是重排序會導致指令執行順序的改變,最終導致執行結果錯誤。

為了相容編譯器、CPU重排序提高效能,又為了相容開發者更好的理解程式的執行過程,寫出更簡單理解的程式碼。

JMM制定了一些規則,如: as-if-serialhappen-before,這些規則規定了編譯器、CPU可以對哪些操作進行重排序,而哪些操作禁止重排序。

三、記憶體屏障

為了禁止編譯器重排序和CPU 重排序,在編譯器和CPU 層面都有對應的指令,也就是記憶體屏障(Memory Barrier)。這也正是JMM和happen-before規則的底層實現原理。

編譯器的記憶體屏障,只是為了告訴編譯器不要對指令進行重排序。當編譯完成之後,這種記憶體屏障就消失了,CPU並不會感知到編譯器中記憶體屏障的存在。

而CPU的記憶體屏障是CPU提供的指令,可以由開發者顯示呼叫。下面主要講CPU的記憶體屏障。

在理論層面,可以把基本的CPU記憶體屏障分成四種:

(1)LoadLoad:禁止讀和讀的重排序。

(2)StoreStore:禁止寫和寫的重排序。

(3)LoadStore:禁止讀和寫的重排序。

(4)StoreLoad:禁止寫和讀的重排序。

參考資料:《Java併發實現原理:JDK原始碼剖析》、《Java併發程式設計的藝術》