JVM 併發性: Java 和 Scala 併發性基礎

發表於2014-05-19

處理器速度數十年來一直持續快速發展,並在世紀交替之際走到了終點。從那時起,處理器製造商更多地是通過增加核心來提高晶片效能,而不再通過增加時鐘速率來提高晶片效能。多核系統現在成為了從手機到企業伺服器等所有裝置的標準,而這種趨勢可能繼續並有所加速。開發人員越來越需要在他們的應用程式程式碼中支援多個核心,這樣才能滿足效能需求。

在本系列文章中,您將瞭解一些針對 Java 和 Scala 語言的併發程式設計的新方法,包括 Java 如何將 Scala 和其他基於 JVM 的語言中已經探索出來的理念結合在一起。第一期文章將介紹一些背景,通過介紹 Java 7 和 Scala 的一些最新技術,幫助瞭解 JVM 上的併發程式設計的全景。您將瞭解如何使用 Java ExecutorService 和 ForkJoinPool 類來簡化併發程式設計。還將瞭解一些將併發程式設計選項擴充套件到純 Java 中的已有功能之外的基本 Scala 特性。在此過程中,您會看到不同的方法對併發程式設計效能有何影響。後續幾期文章將會介紹 Java 8 中的併發性改進和一些擴充套件,包括用於執行可擴充套件的 Java 和 Scala 程式設計的 Akka 工具包。

Java 併發性支援

在 Java 平臺誕生之初,併發性支援就是它的一個特性,執行緒和同步的實現為它提供了超越其他競爭語言的優勢。Scala 基於 Java 並在 JVM 上執行,能夠直接訪問所有 Java 執行時(包括所有併發性支援)。所以在分析 Scala 特性之前,我首先會快速回顧一下 Java 語言已經提供的功能。

Java 執行緒基礎

在 Java 程式設計過程中建立和使用執行緒非常容易。它們由 java.lang.Thread 類表示,執行緒要執行的程式碼為 java.lang.Runnable 例項的形式。如果需要的話,可以在應用程式中建立大量執行緒,您甚至可以建立數千個執行緒。在有多個核心時,JVM 使用它們來併發執行多個執行緒;超出核心數量的執行緒會共享這些核心。

Java 5:併發性的轉折點

Java 從一開始就包含對執行緒和同步的支援。但線上程間共享資料的最初規範不夠完善,這帶來了 Java 5 的 Java 語言更新中的重大變化 (JSR-133)。Java Language Specification for Java 5 更正並規範化了 synchronized 和 volatile 操作。該規範還規定不變的物件如何使用多執行緒。(基本上講,只要在執行建構函式時不允許引用 “轉義”,不變的物件始終是執行緒安全的。)以前,執行緒間的互動通常需要使用阻塞的 synchronized 操作。這些更改支援使用 volatile 線上程間執行非阻塞協調。因此,在 Java 5 中新增了新的併發集合類來支援非阻塞操作 — 這與早期僅支援阻塞的執行緒安全方法相比是一項重大改進。

執行緒操作的協調難以讓人理解。只要從程式的角度讓所有內容保持一致,Java 編譯器和 JVM 就不會對您程式碼中的操作重新排序,這使得問題變得更加複雜。例如:如果兩個相加操作使用了不同的變數,編譯器或 JVM 可以安裝與指定的順序相反的順序執行這些操作,只要程式不在兩個操作都完成之前使用兩個變數的總數。這種重新排序操作的靈活性有助於提高 Java 效能,但一致性只被允許應用在單個執行緒中。硬體也有可能帶來執行緒問題。現代系統使用了多種快取記憶體級別,一般來講,不是系統中的所有核心都能同樣看到這些快取。當某個核心修改記憶體中的一個值時,其他核心可能不會立即看到此更改。

由於這些問題,在一個執行緒使用另一個執行緒修改的資料時,您必須顯式地控制執行緒互動方式。Java 使用了特殊的操作來提供這種控制,在不同執行緒看到的資料檢視中建立順序。基本操作是,執行緒使用 synchronized 關鍵字來訪問一個物件。當某個執行緒在一個物件上保持同步時,該執行緒將會獲得此物件所獨有的一個鎖的獨佔訪問。如果另一個執行緒已持有該鎖,等待獲取該鎖的執行緒必須等待,或者被阻塞,直到該鎖被釋放。當該執行緒在一個 synchronized 程式碼塊內恢復執行時,Java 會保證該執行緒可以 “看到了” 以前持有同一個鎖的其他執行緒寫入的所有資料,但只是這些執行緒通過離開自己的 synchronized 鎖來釋放該鎖之前寫入的資料。這種保證既適用於編譯器或 JVM 所執行的操作的重新排序,也適用於硬體記憶體快取。一個 synchronized 塊的內部是您程式碼中的一個穩定性孤島,其中的執行緒可依次安全地執行、互動和共享資訊。

在變數上對 volatile 關鍵字的使用,為執行緒間的安全互動提供了一種稍微較弱的形式。synchronized 關鍵字可確保在您獲取該鎖時可以看到其他執行緒的儲存,而且在您之後,獲取該鎖的其他執行緒也會看到您的儲存。volatile 關鍵字將這一保證分解為兩個不同的部分。如果一個執行緒向volatile 變數寫入資料,那麼首先將會擦除它在這之前寫入的資料。如果某個執行緒讀取該變數,那麼該執行緒不僅會看到寫入該變數的值,還會看到寫入的執行緒所寫入的其他所有值。所以讀取一個 volatile 變數會提供與輸入 一個 synchronized 塊相同的記憶體保證,而且寫入一個volatile 變數會提供與離開 一個 synchronized 塊相同的記憶體保證。但二者之間有很大的差別:volatile 變數的讀取或寫入絕不會受阻塞。

抽象 Java 併發性

同步很有用,而且許多多執行緒應用程式都是在 Java 中僅使用基本的 synchronized 塊開發出來的。但協調執行緒可能很麻煩,尤其是在處理許多執行緒和許多塊的時候。確保執行緒僅在安全的方式下互動 避免潛在的死鎖(兩個或更多執行緒等待對方釋放鎖之後才能繼續執行),這很困難。支援併發性而不直接處理執行緒和鎖的抽象,這為開發人員提供了處理常見用例的更好方法。

java.util.concurrent 分層結構包含一些集合變形,它們支援併發訪問、針對原子操作的包裝器類,以及同步原語。這些類中的許多都是為支援非阻塞訪問而設計的,這避免了死鎖的問題,而且實現了更高效的執行緒。這些類使得定義和控制執行緒之間的互動變得更容易,但他們仍然面臨著基本執行緒模型的一些複雜性。

java.util.concurrent 包中的一對抽象,支援採用一種更加分離的方法來處理併發性:Future<T> 介面、Executor 和ExecutorService 介面。這些相關的介面進而成為了對 Java 併發性支援的許多 Scala 和 Akka 擴充套件的基礎,所以更詳細地瞭解這些介面和它們的實現是值得的。

Future<T> 是一個 T 型別的值的持有者,但奇怪的是該值一般在建立 Future 之後才能使用。正確執行一個同步操作後,才會獲得該值。收到Future 的執行緒可呼叫方法來:

  • 檢視該值是否可用
  • 等待該值變為可用
  • 在該值可用時獲取它
  • 如果不再需要該值,則取消該操作

Future 的具體實現結構支援處理非同步操作的不同方式。

Executor 是一種圍繞某個執行任務的東西的抽象。這個 “東西” 最終將是一個執行緒,但該介面隱藏了該執行緒處理執行的細節。Executor 本身的適用性有限,ExecutorService 子介面提供了管理終止的擴充套件方法,併為任務的結果生成了 FutureExecutor 的所有標準實現還會實現ExecutorService,所以實際上,您可以忽略根介面。

執行緒是相對重量級的資源,而且與分配並丟棄它們相比,重用它們更有意義。ExecutorService 簡化了執行緒間的工作共享,還支援自動重用執行緒,實現了更輕鬆的程式設計和更高的效能。ExecutorService 的 ThreadPoolExecutor 實現管理著一個執行任務的執行緒池。

應用 Java 併發性

併發性的實際應用常常涉及到需要與您的主要處理邏輯獨立的外部互動的任務(與使用者、儲存或其他系統的互動)。這類應用很難濃縮為一個簡單的示例,所以在演示併發性的時候,人們通常會使用簡單的計算密集型任務,比如數學計算或排序。我將使用一個類似的示例。

任務是找到離一個未知的輸入最近的已知單詞,其中的最近 是按照Levenshtein 距離 來定義的:將輸入轉換為已知的單詞所需的最少的字元增加、刪除或更改次數。我使用的程式碼基於 Wikipedia 上的 Levenshtein 距離 文章中的一個示例,該示例計算了每個已知單詞的 Levenshtein 距離,並返回最佳匹配值(或者如果多個已知的單詞擁有相同的距離,那麼返回結果是不確定的)。

清單 1 給出了計算 Levenshtein 距離的 Java 程式碼。該計算生成一個矩陣,將行和列與兩個對比的文字的大小進行匹配,在每個維度上加 1。為了提高效率,此實現使用了一對大小與目標文字相同的陣列來表示矩陣的連續行,將這些陣列包裝在每個迴圈中,因為我只需要上一行的值就可以計算下一行。

清單 1. Java 中的 Levenshtein 距離計算

如果有大量已知詞彙要與未知的輸入進行比較,而且您在一個多核系統上執行,那麼您可以使用併發性來加速處理:將已知單詞的集合分解為多個塊,將每個塊作為一個獨立任務來處理。通過更改每個塊中的單詞數量,您可以輕鬆地更改任務分解的粒度,從而瞭解它們對總體效能的影響。清單 2 給出了分塊計算的 Java 程式碼,摘自 示例程式碼 中的 ThreadPoolDistance 類。清單 2 使用一個標準的 ExecutorService,將執行緒數量設定為可用的處理器數量。

清單 2. 在 Java 中通過多個執行緒來執行分塊的距離計算

清單 2 中的 bestMatch() 方法構造一個 DistanceTask 距離列表,然後將該列表傳遞給 ExecutorService。這種對 ExecutorService 的呼叫形式將會接受一個 Collection<? extends Callable<T>> 型別的引數,該參數列示要執行的任務。該呼叫返回一個 Future<T> 列表,用它來表示執行的結果。ExecutorService 使用在每個任務上呼叫 call() 方法所返回的值,非同步填寫這些結果。在本例中,T 型別為DistancePair— 一個表示距離和匹配的單詞的簡單的值物件,或者在沒有找到惟一匹配值時近表示距離。

bestMatch() 方法中執行的原始執行緒依次等待每個 Future 完成,累積最佳的結果並在完成時返回它。通過多個執行緒來處理 DistanceTask 的執行,原始執行緒只需等待一小部分結果。剩餘結果可與原始執行緒等待的結果併發地完成。

併發性效能

要充分利用系統上可用的處理器數量,必須為 ExecutorService 配置至少與處理器一樣多的執行緒。您還必須將至少與處理器一樣多的任務傳遞給ExecutorService 來執行。實際上,您或許希望擁有比處理器多得多的任務,以實現最佳的效能。這樣,處理器就會繁忙地處理一個接一個的任務,近在最後才空閒下來。但是因為涉及到開銷(在建立任務和 future 的過程中,在任務之間切換執行緒的過程中,以及最終返回任務的結果時),您必須保持任務足夠大,以便開銷是按比例減小的。

圖 1 展示了我在使用 Oracle 的 Java 7 for 64-bit Linux® 的四核 AMD 系統上執行測試程式碼時測量的不同任務數量的效能。每個輸入單詞依次與 12,564 個已知單詞相比較,每個任務在一定範圍的已知單詞中找到最佳的匹配值。全部 933 個拼寫錯誤的輸入單詞會重複執行,每輪執行之間會暫停片刻供 JVM 處理,該圖中使用了 10 輪執行後的最佳時間。從圖 1 中可以看出,每秒的輸入單詞效能在合理的塊大小範圍內(基本來講,從 256 到大於 1,024)看起來是合理的,只有在任務變得非常小或非常大時,效能才會極速下降。對於塊大小 16,384,最後的值近建立了一個任務,所以顯示了單執行緒效能。

圖 1. ThreadPoolDistance 效能

perform1

Fork-Join

Java 7 引入了 ExecutorService 的另一種實現:ForkJoinPool 類。ForkJoinPool 是為高效處理可反覆分解為子任務的任務而設計的,它使用 RecursiveAction 類(在任務未生成結果時)或 RecursiveTask<T> 類(在任務具有一個 T 型別的結果時)來處理任務。RecursiveTask<T> 提供了一種合併子任務結果的便捷方式,如清單 3 所示。

清單 3. RecursiveTask<DistancePair> 示例

圖 2 顯示了清單 3 中的 ForkJoin 程式碼與 清單 2 中的 ThreadPool 程式碼的效能對比。ForkJoin 程式碼在所有塊大小中穩定得多,僅在您只有單個塊(意味著執行是單執行緒的)時效能會顯著下降。標準的 ThreadPool 程式碼僅在塊大小為 256 和 1,024 時會表現出更好的效能。

圖 2. ThreadPoolDistance 與 ForkJoinDistance 的效能對比

perform2

這些結果表明,如果可調節應用程式中的任務大小來實現最佳的效能,那麼使用標準 ThreadPool 比 ForkJoin 更好。但請注意,ThreadPool的 “最佳效能點” 取決於具體任務、可用處理器數量以及您系統的其他因素。一般而言,ForkJoin 以最小的調優需求帶來了優秀的效能,所以最好儘可能地使用它。

Scala 併發性基礎

Scala 通過許多方式擴充套件了 Java 程式語言和執行時,其中包括新增更多、更輕鬆的處理併發性的方式。對於初學者而言,Future<T> 的 Scala 版本比 Java 版本靈活得多。您可以直接從程式碼塊中建立 future,可向 future 附加回撥來處理這些 future 的完成。清單 4 顯示了 Scala future 的一些使用示例。該程式碼首先定義了 futureInt() 方法,以便按需提供 Future<Int>,然後通過三種不同的方式來使用 future。

清單 4. Scala Future<T> 示例程式碼

清單 4 中的第一個示例將回撥閉包附加到一對 future 上,以便在兩個 future 都完成時,將兩個結果值的和列印到控制檯上。回撥是按照建立它們的順序直接巢狀在 future 上,但是,即使更改順序,它們也同樣有效。如果在您附加回撥時 future 已完成,該回撥仍會執行,但無法保證它會立即執行。原始執行執行緒會在 Thread sleep 3000 行上暫停,以便在進入下一個示例之前完成 future。

第二個示例演示了使用 Scala for comprehension 從 future 中非同步提取值,然後直接在表示式中使用它們。for comprehension 是一種 Scala 結構,可用於簡潔地表達複雜的操作組合(mapfilterflatMap 和 foreach)。它一般與各種形式的集合結合使用,但 Scala future 實現了相同的單值方法來訪問集合值。所以可以使用 future 作為一種特殊的集合,一種包含最多一個值(可能甚至在未來某個時刻之前之後才包含該值)的集合。在這種情況下,for 語句要求獲取 future 的結果,並在表示式中使用這些結果值。在幕後,這種技術會生成與第一個示例完全相同的程式碼,但以線性程式碼的形式編寫它會得到更容易理解的更簡單的表示式。和第一個示例一樣,原始執行執行緒會暫停,以便在進入下一個示例之前完成 future。

第三個示例使用阻塞等待來獲取 future 的結果。這與 Java future 的工作原理相同,但在 Scala 中,一個獲取最大等待時間引數的特殊Await.result() 方法呼叫會讓阻塞等待變得更為明顯。

清單 4 中的程式碼沒有顯式地將 future 傳遞給 ExecutorService 或等效的物件,所以如果沒有使用過 Scala,那麼您可能想知道 future 內部的程式碼是如何執行的。答案取決於 清單 4 中最上面一行:import ExecutionContext.Implicits.global。Scala API 常常為程式碼塊中頻繁重用的引數使用 implicit 值。future { } 結構要求 ExecutionContext 以隱式引數的形式提供。這個 ExecutionContext 是 JavaExecutorService 的一個 Scala 包裝器,以相同方式用於使用一個或多個託管執行緒來執行任務。

除了 future 的這些基本操作之外,Scala 還提供了一種方式將任何集合轉換為使用並行程式設計的集合。將集合轉換為並行格式後,您在集合上執行的任何標準的 Scala 集合操作(比如 mapfilter 或 fold)都會自動地儘可能並行完成。(本文稍後會在 清單 7 中提供一個相關示例,該示例使用 Scala 查詢一個單詞的最佳匹配值。)

錯誤處理

Java 和 Scala 中的 future 都必須解決錯誤處理的問題。在 Java 中,截至 Java 7,future 可丟擲一個 ExecutionException 作為返回結果的替代方案。應用程式可針對具體的失敗型別而定義自己的 ExecutionException 子類,或者可連鎖異常來傳遞詳細資訊,但這限制了靈活性。

Scala future 提供了更靈活的錯誤處理。您可以通過兩種方式完成 Scala future:成功時提供一個結果值(假設要求一個結果值),或者在失敗時提供一個關聯的 Throwable。您也可以採用多種方式處理 future 的完成。在 清單 4 中,onSuccess 方法用於附加回撥來處理 future 的成功完成。您還可以使用 onComplete 來處理任何形式的完成(它將結果或 throwable 包裝在一個 Try 中來適應兩種情況),或者使用 onFailure 來專門處理錯誤結果。Scala future 的這種靈活性擴充套件到了您可以使用 future 執行的所有操作,所以您可以將錯誤處理直接整合到程式碼中。

這個 Scala Future<T> 還有一個緊密相關的 Promise<T> 類。future 是一個結果的持有者,該結果在某個時刻可能可用(或不可用 — 無法內在地確保一個 future 將完成)。future 完成後,結果是固定的,不會發生改變。promise 是這個相同契約的另一端:結果的一個一次性、可分配的持有者,具有結果值或 throwable 的形式。可從 promise 獲取 future,在 promise 上設定了結果後,就可以在該 future 上設定此結果。

應用 Scala 併發性

現在您已熟悉一些基本的 Scala 併發性概念,是時候來了解一下解決 Levenshtein 距離問題的程式碼了。清單 5 顯示了 Levenshtein 距離計算的一個比較符合語言習慣的 Scala 實現,該程式碼基本上與 清單 1 中的 Java 程式碼類似,但採用了函式風格。

清單 5. Scala 中的 Levenshtein 距離計算

清單 5 中的程式碼對每個行值計算使用了尾部遞迴 distanceByRow() 方法。此方法首先檢查計算了多少行,如果該數字與檢查的單詞中的字元數匹配,則返回結果距離。否則會計算新的行值,然後遞迴地呼叫自身來計算下一行(將兩個行陣列包裝在該程式中,以便正確地傳遞新的最新的行值)。Scala 將尾部遞迴方法轉換為與 Java while 迴圈等效的程式碼,所以保留了與 Java 程式碼的相似性。

但是,此程式碼與 Java 程式碼之間有一個重大區別。清單 5 中的 for comprehension 使用了閉包。閉包並不總是得到了當前 JVM 的高效處理(參閱Why is using for/foreach on a Range slow?,瞭解有關的詳細資訊),所以它們在該計算的最裡層迴圈上增加了大量開銷。如上所述,清單 5 中的程式碼的執行速度沒有 Java 版本那麼快。清單 6 重寫了程式碼,將 for comprehension 替換為新增的尾部遞迴方法。這個版本要詳細得多,但執行效率與 Java 版本相當。

清單 6. 為提升效能而重新構造的計算程式碼

清單 7 給出的 Scala 程式碼執行了與 清單 2 中的 Java 程式碼相同的阻塞的距離計算。bestMatch() 方法找到由 Matcher 類例項處理的特定單詞塊中與目標文字最匹配的單詞,使用尾部遞迴 best() 方法來掃描單詞。*Distance 類建立多個 Matcher 例項,每個對應一個單詞塊,然後協調匹配結果的執行和組合。

清單 7. Scala 中使用多個執行緒的一次阻塞距離計算

清單 7 中的兩個 *Distance 類顯示了協調 Matcher 結果的執行和組合的不同方式。ParallelCollectionDistance 使用前面提到的 Scala 的並行集合 feature 來隱藏平行計算的細節,只需一個簡單的 foldLeft 就可以組合結果。

DirectBlockingDistance 更加明確,它建立了一組 future,然後在該列表上為每個結果使用一個 foldLeft 和巢狀的阻塞等待。

效能再分析

清單 7 中的兩個 *Distance 實現都是處理 Matcher 結果的合理方法。(它們不僅合理,而且非常高效。示例程式碼 包含我在試驗中嘗試的其他兩種實現,但未包含在本文中。)在這種情況下,效能是一個主要問題,所以圖 3 顯示了這些實現相對於 Java ForkJoin 程式碼的效能。

圖 3. ForkJoinDistance 與 Scala 替代方案的效能對比

perform3

圖 3 顯示,Java ForkJoin 程式碼的效能比每種 Scala 實現都更好,但 DirectBlockingDistance 在 1,024 的塊大小下提供了更好的效能。兩種 Scala 實現在大部分塊大小下,都提供了比 清單 1 中的 ThreadPool 程式碼更好的效能。

這些效能結果僅是演示結果,不具權威性。如果您在自己的系統上執行計時測試,可能會看到不同的效能,尤其在使用不同數量的核心的時候。如果希望為距離任務獲得最佳的效能,那麼可以實現一些優化:可以按照長度對已知單詞進行排序,首先與長度和輸入相同的單詞進行比較(因為編輯距離總是不低於與單詞長度之差)。或者我可以在距離計算超出之前的最佳值時,提前退出計算。但作為一個相對簡單的演算法,此試驗公平地展示了兩種併發操作是如何提高效能的,以及不同的工作共享方法的影響。

在效能方面,清單 7 中的 Scale 控制程式碼與 清單 2 和 清單 3 中的 Java 程式碼的對比結果很有趣。Scala 程式碼短得多,而且(假設您熟悉 Scala!)比 Java 程式碼更清晰。Scala 和 Java 可很好的相互操作,您可以在本文的 完整示例程式碼 中看到:Scala 程式碼對 Scala 和 Java 程式碼都執行了計時測試,Java 程式碼進而直接處理 Scala 程式碼的各部分。得益於這種輕鬆的互操作性,您可以將 Scala 引入現有的 Java 程式碼庫中,無需進行通盤修改。最初使用 Scala 為 Java 程式碼實現高水平控制常常很有用,這樣您就可以充分利用 Scala 強大的表達特性,同時沒有閉包或轉換的任何重大效能影響。

清單 7 中的 ParallelCollectionDistance Scala 程式碼的簡單性非常具有吸引力。使用此方法,您可以從程式碼中完全抽象出併發性,從而編寫類似單執行緒應用程式的程式碼,同時仍然獲得多個處理器的優勢。幸運的是,對於喜歡此方法的簡單性但又不願意或無法執行 Scala 開發的人而言,Java 8 帶來了一種執行直接的 Java 程式設計的類似特性。

結束語

現在您已經瞭解了 Java 和 Scala 併發性操作的基礎知識,本系列下一篇文章將介紹 Java 8 如何改進對 Java 的併發性支援(以及從長遠來講,可能對 Scala 的併發性支援)。Java 8 的許多改動您看起來可能都很熟悉(Scala 併發性特性中使用的許多相同的概念都包含在 Java 8 中),所以您很快就能夠在普通的 Java 程式碼中使用一些 Scala 技術。請閱讀下一期文章,瞭解應該如何做。

相關文章