Java並行程式設計 | 從程序、執行緒到並行問題範例解決

2022-10-04 12:00:17

計劃寫幾篇文章講述下Java並行程式設計,幫助一些初學者成體系的理解並行程式設計並實際使用,而不只是碎片化的瞭解一些Synchronized、ReentrantLock等技術點。在講述的過程中,也想融入一些相關技術、概念的發展歷史,這樣便於看到其演化過程而更好地進行理解。文字描述上希望是更通俗些,如果閱讀者能在寥寥文字中稍有所得就很滿足了。

什麼是程序?

在日常使用計算機的過程中我們會用各類的軟體來處理各種事物,比如聽歌、看視訊、寫檔案等等。對於相對簡單的軟體對應於Windows作業系統就是一個任務,用計算機術語上說也是一個程序;當然對於複雜的軟體在啟動的時候也有啟動多個程序。切實感受的話,如果熟悉的 Ctrl+Alt+Del 控制檯工作管理員上就能看到,如下圖:





途中也可看到每一個程序都有著顯示作業系統分配使用的對應CPU、記憶體、磁碟等資源的資訊,這也是常可以聽說到的一句話:程序是資源分配的最小單位 。

如果回到 Java 中,最開始程式設計時執行的 Main 函數其實就是執行一個控制檯程序。也是另外聽到的描述 程序是正在執行的程式的範例 。專業一點定義來說 程序是具有一定獨立功能的程式關於某個資料集合上的一次執行活動 。

歷史角度上說,程序最先是60年代初由 麻省理工學院的MULTICS系統和IBM公司的CTSS/360系統 引出的。

從程序到執行緒

如果回到60年代,計算機其實是沒有執行緒的;隨著各行業系統軟體發展,程序很多缺陷開始凸顯,比如程序是有分配資源,在程序進行切換/建立等時候其實時間也好、記憶體空間也好耗費都非常的大。於是開始了有輕型程序等一些設計概念,大約到了80年代左右,執行緒(Threads)正式開始出現。

從歷史發展可以看到執行緒解決程序承擔分配資源等過重的作用而產生的,所以有些作業系統裡面一直也有稱之為輕量級程序,在程序(Process)單詞上加上Lightweight 輕量執行緒(Lightweight Process),也有說法叫核心執行緒(Kernel thread)。

同一個程序往往包含多個執行緒,是計算機作業系統進行運算排程的最小單位。多執行緒之間是可以共用同一程序的資源的。存在共用,這其實就代表了 其存在競爭關係;比如:多個執行緒同時變更同一個變數的場景。在Java程式設計體系下,如何解決這種並行使用資源的問題,指的就是Java並行程式設計。

什麼是並行問題?

用簡單程式碼來舉例演示下並行的問題,定義一個變數 val 分別使用單執行緒/多執行緒的方式來對 int val 執行 1000000 次 加1 的操作。系統在執行加1操作,底層其實包含了讀取val值 和 修改val值的兩個指令。因此在多執行緒執行的條件下,沒有使用到Java並行程式設計技巧,將會在操作執行 變更val變數上產生並行操作。

單執行緒結果當然會是 1000000,多執行緒CPU執行由於執行次數較大大概率結果會是 小於(<) 1000000。下圖為筆者執行的結果, 「more threads val is 240799 」 。當然執行多次不一定是 240799,但一般都會小於(<) 1000000 讀者可以試試。



顯然多執行緒並行的帶來的這種不確定結果,不是程式設計設計所想要的。

為什麼產生並行問題

IntStream.range(0, 1000).forEach(i -> { val +=1; });
要詳細闡述並行問題的產生,仔細分析下上述程式碼。計算機執行程式底層其實也是一條條指令在執行。對於val +=1 這行語句,編譯完後其實有4條語句。

  1. GETSTATIC 將靜態變數 val壓入棧中;
  2. ICONST_1 將常數1壓入棧中;
  3. IADD 執行加(+)運算操作;
  4. PUTSTATIC 將結果放回 val變數。



    可以看到執行 +1 這個操作其實是在獨立棧內進行,不同執行緒其實有不同的操作棧。

如果執行緒(1)還未執行完 PUTSTATIC 操作,另外一個執行緒(2)進行了 GETSTATIC ;這個時候執行緒(2)執行 +1 操作時,就不會使用執行緒(1)+1 執行完成後的結果。

當同樣執行到 PUTSTATIC 時,也不會考慮執行緒(1)情況 直接把自己運算結果寫進 val。這樣也就出現了並行問題,並非我們想象的多執行緒執行都能改變val的值。


怎麼解決這種並行問題?

設計初衷上說val+1操作的邏輯時希望在讀取val值上進行+1的操作,而非在+1過程中初始val值由於其他執行緒操作而改變。因此在計算機指令上就給到了一個指令 cmpxchg,在將棧裡面值交換到堆裡面val時,比較val初始值麼沒有變化執行成,否則執行失敗。如果指令執行失敗了,我們再重新進行新val值的計算直到完成一次成功操作。這也就是 解決Java並行一個基本演演算法 CAS(Compare-and-Swap)。

CAS演演算法有三個運算元,通過記憶體中的值(V)、預期原始值(A)、修改後的新值。

如果記憶體中的值和預期原始值相等, 就將修改後的新值儲存到記憶體中。

如果記憶體中的值和預期原始值不相等,說明共用資料已經被修改,放棄已經所做的操作,然後重新執行剛才的操作,直到重試成功。

Java中Unsafe 中的getAndAddInt就是使用的這個演演算法,不妨詳細解讀下其程式碼。



到這裡還涉及到一個執行緒變數修改同步問題,由於計算機結構複雜性,CPU、Mem等各級快取特性、不同作業系統、不同廠商硬體等等,其中有著很多快取/同步設計;為了遮蔽這些複雜性,java提供了volatile 關鍵字來進行保證。擷取一段The Java® Language Specification (Java SE 10 Edition)原文:



抓重點的理解:欄位被宣告為volatile,在這種情況下,Java記憶體模型確保所有執行緒都看到變數的一致值。

試一試,多執行緒效能更好?

按照前面解決的思路,修改下之前的程式碼進行測試下。另外將耗時也記錄一下:



是不是發現,val 的數值已經和單執行緒的一致了都是 1000,沒有並行問題了。效能上從這個例子可以看到,單執行緒耗時6ms,多執行緒耗時29ms。不用質疑結果是沒錯的,明顯多執行緒耗時更高。

可以看出多執行緒執行簡單程式並不一定能夠提升效能,因為其開啟執行緒有相關的開銷;同時看到其 複雜性高、維護成本高、可讀性降低 等缺陷。對於簡單業務邏輯場景,不建議用多執行緒。

在此基礎上,加上模擬下相關業務邏輯,模擬邏輯執行doSomeThings(),模擬實現邏輯就是執行緒休眠 1ms。相關程式碼,耗時記錄如下:



這個例子裡面 多執行緒效能優勢,與單執行緒的1914ms 相比多執行緒只需要 262ms。當然具體提升的數值和執行的機器、CPU等等有關係,筆者電腦是 4核8執行緒的情況。

本篇總結下,介紹了程序、執行緒以及相關發展史;展示了一個具體的並行問題;詳細分析了並行問題的發生原因以及解決辦法。最後對多執行緒並行程式進行了驗證,以及相關效能上的探究。

寫在最後,文章中使用的Unsafe 類的功能, 在實際程式設計中絕大部分情況下都不會使用 ;更多地使用 java.util.concurrent 下提供的功能。比如例子中的多執行緒操作整數加1,應該使用的是 AtomicInteger 。關於Java並行程式設計其他技巧後續文章中,接著進行講解。

歡迎長期關注公眾號/頭條號(Java研究者)