1. 并發編程
1.1 并發編程的挑戰
并發編程的目的是為了加快程序的運行速度, 但受限于上下文切換和死鎖等問題, 啟動更多的線程并非能讓程序最大限度地并行執行.
1.1.1 上下文切換
- CPU 通過分配時間片的方式來支持多線程.
- 通過時間片分配算法來循環執行任務, 任務切換前會保存上一個任務的狀態, 以便以后再次運行時的再加載.
- 時間片一般為幾十毫秒(ms), 所以感覺多個線程是同時運行的.
- 上下文切換(Context Switch) : 任務從保存到再加載的過程.
- 并發執行會產生線程創建和上下文切換的開銷, 所以并非一定比串行執行更快.
- 減少上下文切換
- 無鎖并發編程. 多線程競爭鎖時, 會引起上下文切換.
- CAS 算法. 使用CAS 可以在無需加鎖的情況下, 進行Atomic 原子操作.
- 使用最少線程, 避免創建不需要的線程.
- 協程. 在單線程中實現多任務的調度和切換.
1.1.2 死鎖
- 一旦產生死鎖, 就會造成系統功能不可用.
- 此時, 業務是可感知的, 因為不能繼續提供服務了.
- 通過dump 線程來查看到底是那個線程出現了問題.
- 避免死鎖的常用手段:
- 避免一個線程同時獲取多個鎖.
- 避免一個線程在持有鎖期間同時占用多個資源, 盡量保證每個鎖只占用一個資源.
- 嘗試使用定時鎖. 例如
lock.tryLock(timeout)
.
1.2 資源限制的挑戰
只有在串行執行會浪費資源時, 將其修改為并行執行才能加快運行速度.
- 在并發編程時, 程序的執行速度受限于PC 硬件資源或軟件資源, 如網速, 硬盤讀寫速度, CPU 處理速度, 數據庫的鏈接數和socket 連接數等.
- 資源限制引發的問題:
- 在資源受限時, 將串行執行的代碼并發執行, 其結果仍然是串行執行. 由于增加了上下文切換和資源調度的消耗, 可能會使得程序的運行速度更慢.
- 例如, 在較慢的網絡條件下, 下載一個大文件, 單線程比并發編程速度更快.
2. Java 內存模型(JMM)
現代軟硬件的共同目標: 在不改變程序執行結果的前提下, 盡可能提高并行度.
2.1 內存模型的基礎
- 并發編程模型的兩個關鍵問題: 線程間如何通信, 以及線程之間如何同步.
- 通信: 交換信息的機制. 有兩種常見的方式:
- 共享內存: 讀寫內存中的公共狀態來進行隱式通信.
- 消息傳遞: 無公共狀態, 通過發送消息來顯示進行通信.
- 同步: 用以控制不同線程間操作發生的相對順序的機制.
- 共享內存的通信機制下, 必須進行顯式的同步.
- 消息傳遞的通信機制下, 消息的發送順序隱式進行了同步.
- JAVA 采用共享內存模型, 線程間的通信過程對外完全透明.
- 通信: 交換信息的機制. 有兩種常見的方式:
2.2 JMM 的抽象結構
- JAVA 中的內存存儲.
- 堆內存: 存放實例域, 靜態域, 數組元素. 在線程間共享.
- 棧內存: 存放局部變量, 方法定義參數和異常處理器參數.
-
不會共享, 也不會有內存可見性問題. 不受JMM 的影響.
-
- JMM 決定一個線程對共享變量的寫入何時對另一個線程可見.
- 定義了線程的本地內存和主內存之間的抽象關系.
- 主內存負責存儲共享變量.
- 本地內存涵蓋了緩存,寫緩存,寄存器及其他優化. 它會存儲該線程讀寫共享變量的副本.
- 兩個線程間的通信過程, 必須經過主內存.
2.3 處理器和內存的交互, 內存屏障(Memory Barriers / Memory Fence)
- CPU 會使用寫緩存區來臨時保存需要向內存寫入的數據.
- 避免由于處理器停頓下來等待向內存寫入數據而產生的延遲, 從而保證了指令流水線持續運行.
- 同時, 通過以批處理的方式刷新寫緩存, 以及合并對同一內存地址的多次寫, 減少了對內存總線的占用.
-
但是, 每個CPU 上的寫緩存區, 僅對該CPU 可見.
- 該特性會對內存操作的執行順序產生影響: CPU 對內存的讀寫操作的執行順序, 不一定與內存實際發生的讀寫順序一致.
- 由此, CPU 允許對寫-讀操作進行重排序(因為本來也無法保證其順序性, 且重排序能夠提升性能).
- 在適當的位置插入內存屏障來禁止特定類型的CPU 重排序.
屏障類型 | 指令示例 |
---|---|
LoadLoad Barries | Load1; LoadLoad; Load2 |
StoreStore Barries | Store1; StoreStore; Store2 |
Load?Store Barries | Load1; LoadStore; Store2 |
StoreLoad Barries | Store1; StoreLoad; Load2 |
- 其中, StoreLoad 同時具有其它3個屏障的效果, 執行它的開銷很大, 因為它需要把寫緩存區的數據全部刷新到內存中(Buffer Fully Flush).
2.4 happens-before
2.4.1 happens-before 的定義
- 闡述操作之間的內存可見性:
- 如果一個操作的結果需要對另一個操作可見, 那么兩個操作之間必須要存在happends-before 關系.
- 兩個操作可以是一個線程內, 或者在不同的線程間.
- 存在happens-before 關系, 并不意味著Java 平臺的具體實現必須要按照關系指定的順序來執行.
- 重排序后的執行結果與按happens-before 關系執行的結果一致時, 該重排序是合法的.
- 如果一個操作的結果需要對另一個操作可見, 那么兩個操作之間必須要存在happends-before 關系.
- 目的: 在不改變程序結果的前提下, 盡可能提高程序執行的并行度.
- as-if-serial: 保證單線程內程序執行結果不被改變, happens-before 保證正確同步的多線程程序的執行結果不被改變.
2.4.2 happens-before 規則:
- 程序順序規則: 一個線程中的每個操作, happens-before 于該線程中的任意后續操作.
- 監視器鎖規則: 對一個鎖的解鎖, happens-before 于隨后對該鎖的加鎖.
- volatile 變量規則: 對一個volatile 域的寫, happens-before 于任意后續對這個volatile 域的讀.
- 傳遞性: A happens-before B, B happens-before C, 則A happens-before C.
- start() 規則: 線程A 執行
ThreadB.start()
, 則該操作happens-before 于線程B 中的任意操作. - join() 規則: 如果線程A 執行
ThreadB.join()
并成功返回, 則線程B 中的任意操作happens-before 于線程A 的ThreadB.join()
操作的成功返回.
2.4.3 程序順序規則
- 兩個具有happens-before 關系的操作, 僅僅要求前一個操作(的執行結果)對后一個操作可見.
- 而不要求前一個操作要在后一個操作之前執行.
- 如果A happens-before B, 但A 和B 之間不存在數據依賴性, 則可能會進行重排序, 使得B 在A 之前執行.
2.5 重排序
2.5.1 數據依賴性
- 如果兩個操作訪問同一變量, 且有一個是寫操作, 則這兩個操作存在數據依賴性.
- 它僅針對單個CPU 中執行的指令序列和單個線程中執行的操作.
2.5.2 as-if-serial 語義
- 無論如何重排序, 單線程程序的執行結果不能被改變.
- 為了遵守該語義, 編譯器和CPU 不會對存在數據依賴關系的操作做重排序.
- 造成了一個幻覺: 單線程程序是按程序的順序來執行的.
2.5.3 從源碼到指令序列的重排序
- 從JAVA 源代碼到最終實際執行的指令序列, 會經歷3種重排序:
- 編譯器優化重排序: 在不改變單線程程序語義的前提下, 重新安排語句的執行順序.
- 指令級并行的重排序: 如果不存在數據依賴性, CPU 可以改變語句對應機器指令的執行順序.
- 采用ILP(指令級并行技術) 來將多條指令重疊執行.
- 內存系統的重排序: 由于CPU 私用緩存和讀/寫緩沖區, 加載和存儲操作看起來是在亂序執行.
- 1 屬于編譯器重排序, 2和3 屬于處理器重排序.
2.5.4 JMM 的設計初衷
- 程序員希望內存模型易于理解和編程, 所以需要一個強內存模型.
- 編譯器和CPU 則希望內存模型對其有最小的束縛, 方便做優化來提高性能. 所以需要一個弱內存模型.
- 重排序會導致多線程程序出現內存可見性問題.
- JMM 的編譯器重排序規則會禁止特定類型的重排序.
- JMM 的處理器重排序規則會在生成指令序列時, 插入特定類型的內存屏障來禁止特定類型的重排序.
- JMM 屬于語言級的內存模型. 它確保在不同的編譯器和處理器平臺上, 通過禁止特定類型的編譯器和處理器重排序, 對外提供一致的內存可見性保證.
2.5.5 JMM 對待重排序的策略
- 對于會改變程序執行結果的重排序, JMM 要求編譯器和CPU 必須禁止這種重排序.
- 對于不會改變程序執行結果的重排序, JMM 不做任何要求, 即允許這種重排序.
- 例如, 若認定鎖只會被單線程訪問, 則消除之; 若volatile 只會被單線程訪問, 則已普通變量對待之.
2.5.6 控制依賴關系
- 前序操作是條件語句(if, while...), 則后續操作和前序之間就產生了控制依賴關系.
- 當代碼中存在控制依賴性時, 會影響指令序列執行的并行度.
- 采用猜測(Speculation) 執行來克服控制相關性對并行度的影響.
- 例如, CPU 會執行后續操作,并將其計算結果保存到重排序緩存(Reorder Buffer: ROB) 的硬件緩存中,如果條件為真, 直接使用該結果順序執行.
-
在多線程中, 對存在控制依賴的操作重排序, 可能會改變程序的執行結果.
2.6 順序一致性
2.6.1 數據競爭與順序一致性
- 數據競爭:
- 兩個線程對同一個變量, 分別進行讀和寫, 且沒有通過同步對讀寫進行排序.
- 包含數據競爭的代碼, 執行結果不定.
- 順序一致性:
- 正確同步的程序, 其執行結果與該程序在順序一致性內存模型中的執行結果.
- 同步指的是對同步原語(synchronized, volatile和final)的使用.
2.6.2 順序一致性內存模型
未同步程序在JMM 中的執行, 整體是無序的, 其執行結果無法預知. 而在順序一致性模型中, 所有線程看到的是一個一致的整體執行順序.
- 對外提供了極強的內存可見性保證.
- 一個線程中的所有操作必須按照程序的順序執行.
- 不管程序是否同步, 所有線程都只能看到一個單一的操作執行順序. 每個操作都必須原子執行并立刻對所有線程可見.
- 一個單一的全局內存, 通過左右搖擺的開關連接到任意一個(僅一個)線程.
- 例如, 兩個線程A 和B, 分別有操作A1, A2, A3, B1, B2, B3.
- 在使用監視器鎖來正確同步(A 先B 后)時, 執行順序為: A1 -> A2 -> A3 -> B1 -> B2 -> B3.
- 在未同步時, 可能的執行順序是: A1 -> B1 -> B2 -> A2 -> A3 ->B3.
- 未同步的多線程程序, 在順序一致性模型中雖然整體執行順序是無序的, 但所有線程都只能看到一個一致的整體執行順序(因為每個操作立即對任意線程可見).
- 如果A 看到的是: A1 -> B1 -> B2 -> A2 -> A3 ->B3, 那么B 看到的也一定是.
- 而未同步程序在JMM 中, 不但整體的執行順序是無序的, 且所有線程看到的操作執行順序也可能不一致(本地內存不會及時的刷新到主內存中).
2.6.3 未同步程序的執行特性
- 對于未同步或未正確同步的多線程程序, JMM 只提供最小安全性:
- 線程執行時讀取到的值, 要么是之前某個線程寫入的值, 要么是默認值(0, false, null).
- JMM 保證線程讀操作讀取到的值不會無中生有(Out of Thin Air).
- JVM 在堆上分配對象時, 首先會對內存空間進行清零, 然后在其上分配對象(內部會同步這兩個操作).
- 在已清零的內存空間(Pre-zeroed Memory)分配對象時, 域的默認初始化已經完成了.
- JMM 不保證對64 位的long/double 型變量的寫操作具有原子性.
- CPU 和內存間的數據傳遞, 通過總線事務來確保所有CPU 對內存的訪問以串行化的方式進行.
- 任意時刻, 最多只能有一個CPU 可以訪問內存, 確保了單個總線事務之中的內存讀寫操作具有原子性.
- 在一些32 位CPU 上, 保證64 位數據寫操作的原子性, 會產生較大的開銷.
- JMM 可能會將一個64 位寫操作分拆為兩個32 位的寫, 從而被分配到不同的總線事務上, 不再具有原子性.
- JDK 1.5 以后, 保證了讀的原子性, 而寫允許被分拆.