JVM上的併發和Java記憶體模型之同步塊筆記

fairjm發表於2015-05-08

本文來自 圖靈社群@fairjm
轉截請註明出處


這個是書的筆記 書可以在safaribooksonline看,話說這個真的是一個很好的讀外文書的網站啊,一個月39刀就可以暢想全站的書,很值。(訂訂訂訂訂

因為是筆記所以覺得散不是你的錯覺...因為本來就是散的...
筆記記錄了一些概念 方便複習回顧的時候看 更多內容可以戳上面的連結


Scala的記憶體模型 多執行緒能力 和它的執行緒間同步全部繼承自JVM

所有的抽象都有一定程度的洩漏 (all abstractions are to some extent leaky)


Processes and Threads

這通常是OS的任務:指派程式的執行部分給特定的處理器 - 這個機制被稱為多工,並且這對於計算機使用者來說是透明的。

時間片(time slices)

一個程式是正被執行的計算機程式的例項。兩個程式不能直接讀取彼此的記憶體或者同時使用大部分的資源,使用多程式來表述多工會非常麻煩 。

在同一個程式中互相獨立的運算單元被稱為執行緒。在典型的作業系統中,執行緒的數量比程式多得多。

每一個執行緒在程式執行時描述了當前的狀態:通過程式棧和程式計數器。

當我們說程式執行了一個動作(比如向記憶體寫入內容) 我們的意思是一個處理器執行了執行這個動作的執行緒。(注意這邊的分句,我自己從onenote上貼上上來都看了好幾遍...)

OS執行緒是作業系統提供的程式設計設定 通常通過OS相關的程式設計介面來暴露出來被使用

在同一個程式中的分隔的執行緒共享同一區域的記憶體,通過讀寫記憶體來彼此互動。另一個來定義程式的方式是: 一系列的OS執行緒和這些執行緒共享的記憶體和資源。
enter image description here
系統週期性地指派不同的OS執行緒到CPU核心中來允許計算在所有處理器中進行。

java.lang.Thread Java的執行緒是直接對映到系統執行緒的 這意味著Java執行緒的行為是和OS執行緒是非常相似的


建立和啟動執行緒

當JVM程式啟動時 他預設會建立一些執行緒。最重要的執行緒是主執行緒(main thread) 執行程式的 main方法。這個執行緒的名字就叫main

Thread的狀態:
剛被建立時是 new
當被執行時是 runnable
結束執行是 terminated

啟動一個獨立的執行緒包含兩步:
1 建立執行緒物件
2 用start方法執行

object ThreadsCreation extends App {
  class MyThread extends Thread {
    override def run(): Unit = {
      println("New thread running.")
    }
  }
  val t = new MyThread
  t.start()
  t.join()
  println("New thread joined.")
}

join方法是中止main執行緒的執行直到t執行緒執行完畢
執行這個方法 將main執行緒轉換到了 waiting 狀態
等待的執行緒放棄了他的執行機會 OS可以將處理器用於其他的執行緒

注意: 等待的執行緒提醒OS它們正在等待某個狀態並且 停止消耗 CPU週期 而不是重複地檢查這個狀態

sleep方法將執行緒放入 timed waiting 狀態
OS同樣可以將本該執行這個執行緒的處理器用來執行其他的執行緒

確定性: 特定的輸入 程式總會產生相同的輸出 而不管OS選擇的執行排程有什麼不同 由OS選擇的排程不同而會對相同輸出產生不同結果的稱為 不確定性

大多數的多執行緒程式是非確定性的 這也是為什麼多執行緒程式設計如此困難的原因


原子性執行(Atomic execution)

競態條件(race condition) 是一種現象:併發程式的輸出依賴於語句的執行排程。
不一定是不正確的行為。
但如果一些排程的結果是我們不預期的 那麼這個競態條件就可以考慮是一個程式錯誤

原子性執行:程式碼塊的原子執行意味著程式碼中的語句不能由執行這段程式碼的執行緒和另一個執行這段程式碼的執行緒交錯執行。
在原子性執行中 要麼所要執行的程式碼都執行了 要麼都沒執行
可以通過同步塊保證原子性:

def getUniqueId() = this.synchronized {
  val freshUid = uidCount + 1
  uidCount = freshUid
  freshUid
}

在錯誤的物件上同步會造成比較難以找到的併發錯誤。

每一個JVM中建立的物件都會有一個特殊的實體成為內部鎖(intrinsic lock)或者成為監視器(monitor) 用來保證只有一個執行緒可以執行在這個物件上的同步塊

當T執行緒執行在x物件的同步塊時 我們可以說 T執行緒獲得了x的監視器的所有權。當執行緒執行完這個同步塊 我們說他釋放了這個監視器。

同步塊語句是執行緒內通訊的基本機制。無論何時,當多個執行緒要訪問並修改在同一個物件中的欄位時 你應該使用同步塊。


重排序(reordering)
同步塊也不是沒有開銷的
對欄位進行寫入將更加昂貴

同步塊的效能損失程度取決於JVM實現 但通常不會很大
你可能會趨向於避免使用同步塊當你覺得這裡沒有什麼不好的語句互動執行的情況,比如上面那個例子一樣(就是上面的程式碼沒加同步塊被多個執行緒訪問)。永遠不要這麼做!(永遠不要高估自己)

object ThreadSharedStateAccessReordering extends App {
  for (i <- 0 until 100000) {
    var a = false
    var b = false
    var x = -1
    var y = -1
    val t1 = thread {
      a = true
      y = if (b) 0 else 1
    }
    val t2 = thread {
      b = true
      x = if (a) 0 else 1
    }
    t1.join()
    t2.join()
    assert(!(x == 1 && y == 1), s"x = $x, y = $y")
  }
}

比如這個例子,我們的預期是0 0,10,0 1。1 1是我們所不預期的。
理論上不管我們執行多少次 永遠不會有 x=1 y=1的情況發生(所以assert不會不成立 也就不會拋錯)
但實際執行就會..

JVM允許重排序由一個執行緒執行的特定程式的語句 只要這個重排序不會改變這個執行緒的序列化執行語義。(the JVM is allowed to reorder certain program statements executed by one thread as long as it does not change the serial semantics of the program for that particular thread)
PS 這裡簡單說下:

a = true
y = if (b) 0 else 1

y = if (b) 0 else 1
a = true

對於序列化執行來說,這兩個並沒什麼不同(不會產生不同的結果),所以這兩個語句是可以重排序(不需要按照指令的執行順序)

因為一些處理器不總是會按照程式的指令順序執行
而且 執行緒也不需要將他們做的改動立馬寫入主存 而是暫時存在處理器的暫存器中快取。這樣可以最大化程式的執行效率並且允許更好的編譯器優化。

以上的錯誤是我們假定執行緒中所有的寫操作都可以立馬被其他執行緒看到。我們需要應用一些合適的同步來確保一個執行緒修改被另一個執行緒看到的可見性。

同步塊是其中一種實現這個可見性的同步方式。同步塊不僅確保原子性也確保可見性。

相關文章