Java多線程編程實戰02:多線程編程模型

多線程編程模型

線程安全名詞

串行、並發和並行

  • 串行:一個人,將任務一個一個完成
  • 並發:一個人,有策略地同時做多件事情
  • 並行:多個人,每人做一個事情

競態

名詞

  • 競態:計算結果的正確性與時間有關的現象被稱為競態
  • 共享變量:可以被多個線程共同訪問的變量

競態產生的條件

  • read-modify-write
  • check-then-act

線程安全性

如果一個類在多線程環境下無需做任何改變也能運作正常,則稱其為線程安全的

線程安全問題

原子性

要點

  • 訪問(讀、寫)某個共享變量的操作從其執行線程以外的任何線程來看,該操作要麼已經執行結束要麼尚未發生,即其他線程不會「看到」該操作執行了部分的中間效果。
  • 原子性只有在多線程環境下才有意義。

如何實現原子性?

  • 使用鎖
  • 利用CAS指令

Java語言中的原子性操作

  • 對所有變量的讀操作都具有原子性
  • 對 long 和 double 以外的任何類型的變量(基礎類型、引用類型)的寫操作都是原子性的

可見性

要點

  • 可見性 就是指一個線程對共享變量的更新的結果對於讀取相應共享變量的線程而言是否可見的問題。
  • 多線程程序在可見性方面的問題意味着某些線程會讀取到舊的數據,從而導致不可預期的後果。

問題產生的原因

對內存的訪問不是直接進行的,為了提高訪問的速度,會先在高速緩存中進行相關操作;另外,每個處理器都有其寄存器,這也可能導致不同的線程看到的數據不一致。

處理器不是直接與主內存打交道,而是通過寄存器(Register)、高速緩存(Cache)、寫緩衝器(Store Buffer)和無效化隊列[1](Invalidate Queue)等部件執行內存的讀寫操作的。

有序性

重排序概念

重排序是什麼

  • 編譯器可能改變兩個操作的先後順序,而不是完全按照程序的目標代碼所指定的順序執行
  • 一個處理器上執行的多個操作,從其他處理器的角度來看其順序可能與目標代碼所指定的順序不一致

重排序的機遇與挑戰

重排序是對內存訪問有關的操作所做的一種優化,可以在不影響單線程程序正確性的情況下提升程序的性能。但是,它可能對多線程程序的正確性產生影響。

重排序的來源

  • 編譯器(如JIT編譯器)
  • 處理器和存儲子系統(包括寫緩衝器 Store Buffer、高速緩存Cache)

幾個相關的術語

  • 源代碼順序:源代碼中指定的內存訪問操作的順序
  • 程序順序:在給定處理器上運行的目標代碼所指定的內存訪問順序,如JVM位元組碼
  • 執行順序:內存訪問操作在給定處理器上的實際執行順序
  • 感知順序:給定處理器所感知到的該處理器及其他處理器的內存訪問操作發生的順序

在此基礎上,重排序可以做如下劃分:

指令重排序

回顧:Java平台包含兩種編譯器:靜態編譯器(javac)和動態編譯器(JIT編譯器)。前者的作用是將Java源代碼(.java文本文件)編譯為位元組碼(.class二進制文件),它是在代碼編譯階段介入的。後者的作用是將位元組碼動態編譯為Java虛擬機宿主機的本地代碼(機器碼),它是在Java程序運行過程中介入的。

在Java平台中,靜態編譯器基本不會執行指令重排序,而JIT編譯器則可能執行指令重排序

對於編譯器如何優化代碼的解釋: (摘自《Java多線程編程實戰》)

處理器對指令進行重排序也被稱為處理器的亂序執行 (Out-of-order Execution)。

現代處理器為了提高指令執行效率,往往不是按照程序順序逐一執行指令的,而是動態調整指令的順序,做到哪條指令就緒就先執行哪條指令,這就是處理器的亂序執行。在亂序執行的處理器中,指令是一條一條按照程序順序被處理器讀取的(亦即「順序讀取」),然後這些指令中哪條就緒了哪條就會先被執行,而不是完全按照程序順序執行(亦即「亂序執行」)。

這些指令執行的結果(要進行寫寄存器或者寫內存的操作)會被先存入重排序緩衝器(ROB, Reorder Buffer),而不是直接被寫入寄存器或者主內存。重排序緩衝器會將各個指令的執行結果按照相應指令被處理器讀取的順序提交(Commit,即寫入)到寄存器或者內存中去(亦即「順序提交」)。

在亂序執行的情況下,儘管指令的執行順序可能沒有完全依照程序順序,但是由於指令的執行結果的提交(即反映到寄存器和內存中)仍然是按照程序順序來的,因此處理器的指令重排序並不會對單線程程序的正確性產生影響。

猜測執行

比如,處理器可以先執行 IF 語句中的內容,並將接過來保存在 ROB 中,然後再判斷 IF 是否成立,如果成立就可以直接使用,不成立則丟棄。

當然,在多線程環境下,這也可能造成線程安全問題。

存儲子系統重排序

存儲子系統

  • 寫緩衝器:對主內存的操作都是通過寫緩衝器進行的
  • 高速緩存:處理器通過高速緩存訪問主內存

內存重排序

即使在處理器嚴格依照程序順序執行兩個內存訪問操作的情況下,在存儲子系統的 作用下其他處理器對這兩個操作的感知順序仍然可能與程序順序不一致,即這兩個操作的執 行順序看起來像是發生了變化。這種現象就是存儲子系統重排序, 也被稱為內存重排序(Memory Ordering)。

內存重排序的類型

如果把讀內存稱為 Load,寫內存稱為 Store,則內存重排序有如下四種可能:

  • LoadLoad重排序
  • StoreStore重排序
  • LoadStore重排序
  • StoreLoad重排序

內存重排序與具體的處理器微架構有關,不同微架構的處理器允許的內存重排序也是不同的

貌似串行語義

這個概念類似於 MySQL 中的可串行化和分佈式中的 XX 概念

重排序也是遵循一定的規則的,我們要做到一種假象:貌似串行語義。也就是從單線程程序的角度保證重排序後的結果不影響程序的正確性。(但是不保證多線程環境下的正確性)

規則如下:

  • 存在數據依賴關係的語句不會被重排序,只有不存在數據依賴關係的語句才會被重排序。
  • 存在控制依賴關係的語句可以允許被重排序,如之前的猜測執行

保證有序性

在多線程角度下,從邏輯上(看上去)禁止重排序,從而保證有序性。

Java 的 volatile 關鍵字、sychronized 等都能夠實現有序性。

多線程模型的其他問題

上下文切換

線程的活性故障

這些由資源稀缺性或者程序自身的問題和缺陷導致線程一直處於非 RUNNABLE 狀態,或者線程雖然處於 RUNNABLE 狀態但是其要執行的任務卻一直無法進展的現象就被稱為 線程活性故障

  • 死鎖(哲學家進餐問題)
  • 鎖死(沒有喚醒線程,比如喚醒線程也睡眠了)
  • 活鎖(一個線程對值做add,另一個做sub,導致程序一直進行,無法停止)
  • 飢餓(某些線程無法獲得其所需資源,而使得任務無法進展)

資源爭用與調度

概念

  • 一次只能被一個線程佔用的資源被稱為 排他性資源
  • 資源被一個線程訪問時,其他線程試圖訪問該資源的現象被稱為 資源爭用。我們要達到的理想狀態是:高並發、低爭用

資源調度的公平性

資源調度的一個常見特性是:他是否保證公平性(是否先到先得)。

非公平調度策略是我們多數情況下的首選資源調度策略,其優點是吞吐量大,缺點是資源申請者申請資源所需時間的是偏差可能較大,並可能導致飢餓現象。

公平調度適合在資源的持有線程佔用資源的時間相對長資源的平均申請時間間隔相對長的情況下,或對申請的時間偏差有要求的情況下使用,優點和缺點則反之。


  1. 參見此文,後續會進行補充 ↩︎