Java並發編程 | 從進程、執行緒到並發問題實例解決
計劃寫幾篇文章講述下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條語句。
- GETSTATIC 將靜態變數 val壓入棧中;
- ICONST_1 將常量1壓入棧中;
- IADD 執行加(+)運算操作;
- 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研究者)