第 16 章 Java 記憶體模型
@@@ 安全釋出 、 同步策略的規範以及一致性等的安全性都來自於 JMM 。
》》什麼是記憶體模型,為什麼需要它
@@@ 在編譯器中生成的指令順序,可以與原始碼中的順序不同;
此外編譯器還會把變數儲存在暫存器而不是記憶體中;
處理器可以採用亂序或並行等方式來執行指令;
快取可能會改變將寫入變數提交到主記憶體的次序;
儲存在處理器本地快取中的值,對於其他處理器是不可見的。
@@@ 隨著處理器變得越來越強大,編譯器也在不斷地改進:通過對指令重新排序來實現優化
執行,以及使用成熟的全域性暫存器分配演算法。
@@@ 只有當多個執行緒要共享資料時,才必須協調它們之間的操作,並且 JVM 依賴程式通過同步
操作來找出這些協調操作將在何時發生。
### 平臺的記憶體模型
@@@ JMM 規定了 JVM 必須遵循一組最小保證,這組保證規定了對變數的寫入操作在何時將對
於其他執行緒可見。
@@@ JMM 在設計時就在可預測性和程式的易於開發性之間進行了權衡,從而在各種主流的處理器體系
架構上能實現高效能的 JVM 。
@@@ Java 提供了自己的記憶體模型,並且 JVM 通過在適當的位置上插入記憶體柵欄來遮蔽在 JMM 與
底層平臺記憶體模型之間的差異。
@@@ 在現代支援共享記憶體的多處理器(和編譯器)中,當跨執行緒共享資料時,會出現一些奇怪的情況,
除非通過使用記憶體柵欄來防止這些情況的發生。Java 程式不需要指定記憶體柵欄的位置,而只需要通過正
確地使用同步來找出將何時訪問共享狀態。
### 重排序
@@@ JMM 使得不同執行緒看到的操作順序是不同的,從而導致缺乏同步的情況下,要推斷操作的執行
順序將變得更加複雜。各種操作延遲或者看似亂序執行的不同原因,都可以歸為重排序。
@@@ 在沒有正確同步的情況下,即使要推斷最簡單的併發程式的行為也很困難。
@@@ 記憶體級的重排序會使程式的行為變得不可預測。
@@@ 要確保在記憶體中正確地使用同步。同步將限制編譯器 、 執行時 和 硬體對記憶體操作重排序的方式,
從而在實施重排序時不會破壞 JMM 提供的可見性保證。
### Java 記憶體模型簡介
@@@ Java 記憶體模型是通過各種操作來定義的,包括對變數的讀 / 寫 操作,監視器的加鎖和釋放操作,
以及執行緒的啟動和合並操作。
@@@ JMM 為程式中所有的操作定義了一個偏序關係,稱之為 Happens-Before 。
@@@ 要想保證執行操作 B 的執行緒看到操作 A 的結果(無論 A 和 B 是否在同一個執行緒中執行 ),
那麼在 A 和 B 之間必須滿足 Happens-Before 關係。如果兩個操作之間缺乏 Happens-Before 關係,
那麼 JVM 可以對它們任意地重排序。
@@@ 當一個變數被多個執行緒讀取並且至少被一個執行緒寫入時,如果在讀操作和寫操作之間沒有
依照 Happens-Before 來排序,那麼就會產生資料競爭問題。
在正確同步的程式中不存在資料競爭,並會表現出序列一致性,這意味著程式中的所有操作
都會按照一種固定和全域性的順序排序。
@@@ Happens-Before 的規則包括:
------- 程式順序規則。如果程式中操作 A 在操作 B 之前,那麼線上程中 A 操作將在 B 操作之前
執行。
------- 監視器鎖規則。在監視器鎖上的解鎖操作必須在同一個監視器鎖上的加鎖操作之前執行。
------- volatile 變數規則。 對 volatile 變數的寫入操作必須在對該變數的讀操作之前執行。
------- 執行緒啟動規則。線上程上對 Thread.start 的呼叫必須在該執行緒中執行任何操作之前執行。
------- 執行緒結束規則。執行緒中的任何操作都必須在其他執行緒檢測該執行緒已經結束之前執行,或者
從 Thread.join 中成功返回,或者在呼叫 Thread.isAlive 時返回 false。
------- 中斷規則。當一個執行緒在另一個執行緒上呼叫 interrupt 時,必須在被中斷執行緒檢測到 interrupt
呼叫之前執行(通過丟擲 InterruptedException ,或者呼叫 isInterrupted 和 interrupted)。
------- 終結器規則。物件的建構函式必須在啟動該物件的終結器之前執行完成。
------- 傳遞性。如果操作 A 在操作 B 之前執行,並且操作 B 在操作 C 之前執行,那麼操作 A 必須
在操作 C 之前執行。
補充:上面操作只滿足偏序關係。
@@@ 同步操作,如鎖的獲取與釋放等操作,以及 volatile 變數的讀取和寫入操作,都滿足全序關係。
### 藉助同步
@@@ 將 Happens-Before 的程式順序規則與其他某個程式規則(通常是監視器鎖規則或者 volatile 變數)
結合起來,從而對某個未被鎖保護的變數的訪問操作進行排序。(這項技術由於對語句的順序非常敏感,
因此很容易出錯,它是一項高階技術)
@@@ 由於 Happens-Before 的排序功能很強大,因此有時候一可以 “ 藉助 ” 現有同步的可見性屬性。
--------- 在 FutureTask 的保護方法 AbstractQueuedSynchronizer 中說明了如何使用這種 “ 藉助 ”技術。
--------- 之所以將這項技術稱為 “ 藉助 ” , 是因為它使用了一種現有的 Happens-Before 順序來確保
物件 X 的可見性,而不是專門為了釋出 X 而建立一種 Happens-Before 順序。
@@@ 在 FutureTask 中使用的 ” 藉助 “ 技術很容易出錯,因此要謹慎使用。但在某些情況下,這種
” 藉助 “ 技術是非常合理的。
@@@ 在類庫中提供的其他 Happens-Before 排序包括:
-------- 將一個元素放入一個執行緒安全容器的操作將在另一個執行緒從該容器中獲得這個元素的操作之前
執行。
-------- 在 CountDownLatch 上的倒數操作將線上程從閉鎖上的 await 方法中返回之前執行。
--------- 釋放 Semaphore 許可的操作將在從該 Semaphore 上獲得一個許可之前執行。
--------- Future 表示的任務的所有操作將在從 Future.get 中返回之前執行。
-------- 向 Executor 提交一個 Runnable 或 Callable 的操作將在任務開始執行之前執行。
--------- 一個執行緒到達 CyclicBarrier 或 Exchange 的操作將在其他到達該柵欄或交換點的執行緒被釋放
之前執行。如果 CyclicBarrier 使用一個柵欄操作,那麼到達柵欄的操作將在柵欄操作之前執行,
而柵欄操作又會線上程從柵欄中釋放之前執行。
》》釋出
@@@ 軟體安全性都來自於 JMM 提供的保證,而造成不正確釋出的真正原因,就是在 “ 釋出一個共享
物件 ” 與 “ 另一個執行緒訪問該物件 ” 之間缺少一種 Happens-Before 排序。
### 不安全的釋出
@@@ 當缺少 Happens-Before 關係時,就可能出現重排序問題。
@@@ 錯誤的延遲初始化將導致不正確的釋出。
@@@ 除了不可變物件以外,使用被另一個執行緒初始化的物件通常都是不安全的,除非物件的釋出操作
是在使用該物件的執行緒開始使用之前執行。
### 安全的釋出
@@@ 在 BlockingQueue 的實現中有足夠的內部同步確保了 put 方法在 take 方法之前執行。同樣,
通過使用一個由鎖來保護共享變數或使用共享的 volatile 型別變數,也可以確保對該變數的讀取操作和
寫入操作按照 Happens-Before 關係來排序。
@@@ Happens-Before 比安全釋出提供了更強可見性與順序保證。
@@@ Happens-Before 關係、@GuardedBy 、安全釋出 之間的區別:
------- 與記憶體寫入操作的可見性相比,從轉移物件的所有權以及物件釋出等角度來看,@GuardedBy與
安全釋出 更符合大多數程式設計。
------- Happens-Before 排序是在記憶體訪問級別上操作的,它是一種 “ 併發級組合語言 ” ;
安全釋出的級別更接近程式設計。
### 安全初始化模式
@@@ 有時候,我們需要推遲一些高開銷的物件初始化操作,並且只有當使用這些物件時才進行
初始化,但我們也看到了誤用延遲初始會導致很多問題。
@@@ 靜態(static)初始化器是由 JVM 在類的初始化階段執行,即在類被載入後並且被執行緒使用
之前。
由於 JVM 將在初始化期間獲得一個鎖,並且每個執行緒都至少獲取一次這個鎖以確保這個類
已經載入,因此在靜態初始化期間, 記憶體寫入操作將自動對所有執行緒可見。
因此無論是在被構造期間還是被引用時,靜態初始化物件都不需要顯式的同步。然而,這個
規則僅適用於在構造時的狀態,如果物件是可變的,那麼在讀執行緒和寫執行緒之間仍然需要通過同步
來確保隨後的修改操作是可見的,以避免資料破壞。
@@@ 提前初始化技術和 JVM 的延遲載入機制結合起來,可以形成一種延遲初始化技術,從而在
常見的程式碼路徑中不需要同步。
### 雙重檢查加鎖(DCL)
@@@ 由於早期的 JVM 在效能上存在一些有待優化的地方,因此延遲初始化經常被用來避免不必要
的高開銷操作,或者降低程式的啟動時間。
在編寫正確的延遲初始化方法中需要使用同步。
@@@ 在同步中需要了解的概念:
-------- “ 獨佔性 ” 的含義
-------- “ 可見性 ” 的含義
》》初始化過程中的安全性
@@@ 安全性架構依賴於 String 的不可變性,如果缺少了初始化安全性,那麼可能導致一個安全漏洞,
從而使惡意程式碼繞過安全檢查。
@@@ 初始化安全性將確保,對於被正確構造的物件,所有執行緒都能看到由建構函式為物件給各個
final 域設定的正確值,而不管採用何種方式來發布物件。而且,對於可以通過被正確構造物件中某個
final 域到達的任意變數(例如某個 final 陣列中的元素,或者由一個 final 域引用的 HashMap 的內容)
將同樣對於其他執行緒是可見的。
@@@ 當建構函式完成時,建構函式對 final 域的所有寫入操作,以及對通過這些域可以到達的任何
變數的寫入操作,都將被 “ 凍結 ” ,並且任何獲得該物件引用的執行緒都至少能確保看到被凍結的值。對於
通過 final 域可達到的初始化變數的寫入操作,將不會與構造過程後的操作一起被重排序。
@@@ 初始化安全性只能保證通過 final 域可達的值從構造過程完成時開始的可見性。對於通過非 final
域可達的值,或者在構造過程完成後可能改變的值,必須採用同步來確保可見性。
》》小結
@@@ Java 記憶體模型說明了某個執行緒的記憶體操作在哪些情況下對於其他執行緒是可見的。其中包括確保
這些操作是按照一種 Happens-Before 的偏序關係進行排序,而這種關係是基於記憶體操作和同步操作等
級別來定義的。
如果缺少充足的同步,那麼當執行緒訪問共享資料時,會發生一些非常奇怪的問題。