框架:分治算法與工作竊取實戰(zhàn)解析)
1. 先搞清楚Fork/Join到底在解決什么問題1.1 分治思想從“一個人干不完”說起Fork/Join這個名字我最早在Java 7的并發(fā)工具包里見到時第一反應是“名字起得真直白”——Fork就是分叉Join就是合并。說白了它就是兩大思想組成的框架分治算法加工作竊取調度。分治這塊你小學就學過把一個大問題拆成若干個小問題小問題解決了大問題也就解決了。比如你要算一萬個數(shù)的總和最直覺的方式是寫個for循環(huán)從頭加到尾但如果有四個線程完全可以切成四段每段兩千五各算各的最后再把四段結果加起來。但分治思想本身并不新鮮幾十年前就有了經(jīng)典歸并排序就是這種思路。Fork/Join真正值得聊的是它怎么處理“拆了任務之后誰來執(zhí)行、執(zhí)行完怎么拼回來”的編排問題。Java里實現(xiàn)一個分治模型用普通的ExecutorService加Future也能做每次遞歸拆分任務用線程池submit再get結果。但你會發(fā)現(xiàn)坑很多任務拆得多了每個Future都要占內存線程切換頻繁而且大任務依賴小任務的結果容易形成層層阻塞跑起來又慢又耗資源。Fork/Join之所以能成為專門框架核心在于兩件事它用雙端隊列管理每個線程的任務允許線程從別人的隊列尾部偷任務來干也就是所謂的工作竊取Work-Stealing它提供了完整的任務狀態(tài)管理機制任務被Fork出去之后隨時可以Join回來狀態(tài)通知、異常傳播都不需要你操心。這套組合拳恰恰是并發(fā)分治場景里最容易踩坑的地方Fork/Join直接用框架幫你擋住了。1.2 工作竊取算法為什么它是Fork/Join的靈魂我一直覺得工作竊取是Fork/Join的精髓。它解決的是一個在并行分治場景里非常實際的問題任務拆得不均勻怎么辦比如某個遞歸分支跑得快某個分支跑得慢或者某個任務本身比較大拆開之后兩邊規(guī)模差很多。如果固定把任務分給幾個線程執(zhí)行快的線程只能閑著等慢的并行度空有一半性能還是上不去。工作竊取的思路是**給每個工作線程維護一個雙端隊列自己負責從隊頭取任務執(zhí)行別人執(zhí)行完自己隊列里的活之后沒活干就去別的線程的隊列尾部偷一個任務來跑。**這樣空閑線程不會干等負載天然就均衡了。這就好比一條流水線四條工位左邊工位干完自己的活兒了不等著下班而是從右邊工位堆疊的半成品里拿點來一起做整體效率一下就拉上去了。而且從隊尾偷、從隊頭取兩個線程操作同一隊列的兩端沖突概率極低大部分情況下連鎖都不用碰靠CAS就夠了。關鍵點在于“偷小不偷大”。Fork/Join里任務拆成Fork之后子任務是在不斷遞歸產(chǎn)生小任務的。一個線程把自己的子任務Fork出去后它會先處理自己隊頭東西當它發(fā)現(xiàn)別的隊列有活時優(yōu)先偷尾部——因為尾部的任務往往是最近Fork出來的、粒度更小的任務拿最小的任務去偷能盡量保證偷過來的任務快速完成減少偷取過程中的協(xié)作成本。這部分原理在各路資料里寫得很繞我自己的經(jīng)驗是理解到“雙端隊列隊頭執(zhí)行隊尾偷取任務粒度遞進”這個level就夠了再往深里鉆設計框架的人其實是為了把無謂競爭壓到最低。2. Fork/Join的核心類與參數(shù)選擇2.1 四個關鍵類ForkJoinPool、ForkJoinTask、RecursiveTask、RecursiveAction剛上手Fork/Join的人容易被一堆類名繞暈。抽出主線來看它其實只有四個角色需要你掌握ForkJoinPool線程池的總調度容器維護工作線程、任務隊列、公共池引用。它跟普通線程池最大的區(qū)別在于支持工作竊取并且每個線程都自帶雙端隊列。ForkJoinTask所有Fork/Join任務的抽象基類核心方法就是fork()和join()以及更高級的invokeAll()。RecursiveTask繼承自ForkJoinTask代表一個有返回結果的遞歸任務適合求和、最值、排序這類需要返回值的問題。RecursiveAction也是ForkJoinTask的子類代表無返回值的遞歸任務適合遍歷目錄、批量處理數(shù)據(jù)、數(shù)組賦值這類只關注副作用side effect的場景。另外還有個細節(jié)挺容易忽視ForkJoinTask實現(xiàn)了Future接口所以你在任務里調用get()也能拿到結果。但它跟普通Future有個關鍵區(qū)別——它的join()方法在等待任務完成時不會像Future那樣傻傻阻塞一個線程等另一個任務完成而是會在當前線程里直接執(zhí)行被等待的任務。這個設計對等待型任務非常友好后面排查“線程卡死”那塊我們會再來回看這一點。invokeAll()是另一個容易被低估的方法。它接受兩個(left, right)子任務內部做了兩個動作先fork右邊的任務再在當前線程直接在本地執(zhí)行左邊的任務。這樣一來右邊的任務會由別的線程偷走或者排隊等待執(zhí)行但左邊任務不用額外占用一個隊列槽位。如果任務拆分得粒度很細用invokeAll()比手動fork()再join()要高效得多。2.2 參數(shù)怎么定線程數(shù)、拆分閾值、隊列容量用Fork/Join十個有八個會栽在參數(shù)設置上。參數(shù)就三個但每一個都直接影響性能線程數(shù)、拆分閾值、隊列容量嚴格來說沒有配置式設置由池自己管理但任務粒度會間接決定隊列長度。線程數(shù)怎么選ForkJoinPool默認用Runtime.getRuntime().availableProcessors()作為并行度。對于純CPU密集型任務并行度CPU核數(shù)是最穩(wěn)妥的起點。如果任務里偶爾有IO可以適度調大比如核數(shù)×2。不建議圖省事直接調成幾十上百因為Fork/Join工作線程一旦創(chuàng)建它是常駐的不干活也在占著資源。閾值怎么定這是Fork/Join最容易犯糊涂的地方。閾值太小任務拆得太多每個任務對象本身的開銷、線程調度開銷可能比計算本身還貴閾值太大任務根本拆不動整個框架退化成單線程遞歸。我的經(jīng)驗是先用一個不會讓任務拆分超過兩層或三層的初始值跑一遍再逐步調小觀察曲線最低點。如果任務處理的是數(shù)組20萬以下基本可以讓線程跑框架里經(jīng)典示范是求和的閾值設在1萬以內單個元素級別的任務也可以但那個屬于極端場景非必要不建議。隊列長度其實你很難直接設置它是動態(tài)擴容的。但你可以通過控制“同時Fork出去的子任務數(shù)量”來間接影響隊列積壓。我的習慣是每次遞歸只fork一個子任務另一個用本地線程執(zhí)行這樣每個工作線程的隊列積壓量始終受控不會出現(xiàn)幾千個任務對象擠在一個隊列里的恐怖狀況。這個習慣在沒有invokeAll()的年代被很多人當作“Fork/Join的黃金準則”到現(xiàn)在依然適用。我要再次提個醒**并行度不是越大越好任務切分次數(shù)也絕不是越多越好。**我曾經(jīng)在某臺8核機器上跑數(shù)組求和默認并行度是8閾值從10萬調到1萬再從1萬調到1000數(shù)據(jù)反而是閾值在1萬時最快。原因是任務對象太多導致GC壓力暴增工作線程之間搶任務隊列尾部的CAS沖突也多了開銷超過了并行帶來的收益。這種“并行反而不如串行”的怪現(xiàn)象具體分析放第4章來說。3. 實戰(zhàn)用Fork/Join實現(xiàn)百萬級數(shù)組求和3.1 目標定義與實現(xiàn)思路講完原理接下去進入實戰(zhàn)環(huán)節(jié)。我挑了一個最經(jīng)典的題目對一個長度為百萬級的long數(shù)組求和。為什么選這道題因為它邏輯簡單核心就一個“累加”所有性能差異都直接反映在“任務拆分調度合并”這套機制上非常適合觀察Fork/Join框架自身的成本。選哪件事用Fork/Join做有幾條判斷標準問題能不能遞歸切分切出來的子問題是否相互獨立子問題的結果能不能合并符合這三條的典型任務有很多——求和、找最大值、歸并排序、大數(shù)組統(tǒng)計類運算、目錄文件遍歷匯總。反過來如果任務本身就不能拆或者子任務之間有大量共享狀態(tài)和鎖操作那Fork/Join不僅幫不上忙還會拖垮你。實戰(zhàn)環(huán)境就是一臺普通的8核機器16G內存Linux環(huán)境。為了更貼近真實開發(fā)我沒用公共池公共池的并行度是CPU核數(shù)而是用自定義的ForkJoinPool這樣容易控制并行度做對比。3.2 核心代碼SumTask實現(xiàn)定義一個SumTask繼承RecursiveTaskLong核心就是compute()方法。邏輯分三步先判斷任務是否已經(jīng)小到可以不拆如果能拆就把數(shù)組一分為二fork出子任務等兩邊結果之后相加如果足夠小就老老實實算。這個“拆到多大就不拆了”的臨界值就是傳說中的閾值THRESHOLD。import java.util.concurrent.ForkJoinPool; import java.util.concurrent.ForkJoinTask; import java.util.concurrent.RecursiveTask; import java.util.concurrent.ThreadLocalRandom; public class ForkJoinSumDemo { static class SumTask extends RecursiveTaskLong { private static final int THRESHOLD 100_000; private final long[] array; private final int start; private final int end; SumTask(long[] array, int start, int end) { this.array array; this.start start; this.end end; } Override protected Long compute() { int length end - start; if (length THRESHOLD) { long sum 0; for (int i start; i end; i) { sum array[i]; } return sum; } int mid start (end - start) / 2; SumTask left new SumTask(array, start, mid); SumTask right new SumTask(array, mid, end); left.fork(); long rightResult right.compute(); long leftResult left.join(); return leftResult rightResult; } } public static void main(String[] args) { int size 1_000_000; long[] arr new long[size]; for (int i 0; i size; i) { arr[i] ThreadLocalRandom.current().nextLong(1, 100); } // 單線程直接求和 long start System.nanoTime(); long serialSum 0; for (long v : arr) { serialSum v; } long serialCost System.nanoTime() - start; // 默認并行度 ForkJoinPool pool new ForkJoinPool(); start System.nanoTime(); long parallelSum pool.invoke(new SumTask(arr, 0, arr.length)); long parallelCost System.nanoTime() - start; pool.shutdown(); System.out.println(serialSum serialSum); System.out.println(parallelSum parallelSum); System.out.printf(serial cost : %.2f ms%n, serialCost / 1_000_000.0); System.out.printf(parallel cost : %.2f ms%n, parallelCost / 1_000_000.0); } }注意上面用了個小技巧拆任務時只fork一邊另一邊在當前線程執(zhí)行。這樣設計的好處我在前面提過——避免當前線程傻等同時減少隊列槽位被無關子任務占滿的可能。你會在很多標準實現(xiàn)里看到寫成SumTask left new SumTask(...); SumTask right new SumTask(...); invokeAll(left, right);這也可以。兩種寫法其實是同一個策略的不同表達。invokeAll(left, right)內部做的事就是先fork一個再在當前線程執(zhí)行另一個手動寫出來更好展示背后邏輯理解框架的人看了也不會覺得突兀。3.3 性能對比與數(shù)據(jù)分析我實測幾次取中間值結果大致是這樣數(shù)組長度100萬數(shù)值范圍1-99執(zhí)行方式耗時約說明單線程for循環(huán)2.1 ms基準值最樸素方式Fork/Join8并行度閾值10萬1.4 ms約1.5倍提升符合預期Fork/Join8并行度閾值1萬1.2 ms拆得更細提升略多Fork/Join8并行度閾值1002.8 ms拆得太細性能反降Fork/Join并行度2閾值10萬3.0 ms并行度不足接近串行有幾個值得聊的點。差距為什么只有1.5倍而不是8倍這很正常。求和這種任務是典型的高帶寬內存密集型任務單線程for循環(huán)已經(jīng)能非常有效地把CPU緩存和內存帶寬吃滿了。把任務拆到多個線程并行其實是在跟同一個內存帶寬搶資源收益自然有限。換成純CPU計算任務比如斐波那契、加密運算、復雜數(shù)學公式并行度高帶來的加速比會好看得多。這告訴我們不是所有任務都適合Fork/Join計算密度越高的任務越能體現(xiàn)出框架價值。閾值的影響為什么會呈“倒U型”曲線波動來自三個開銷源的此消彼長任務對象創(chuàng)建開銷、線程調度與隊列競爭開銷、任務合并開銷。閾值太大拆出來的任務少并行度利用不充分閾值太小任務對象太多GC和CAS都成了瓶頸。后期我做對比時發(fā)現(xiàn)當閾值等于1萬時百萬數(shù)組只拆了大概7層就到葉子了任務是128個符合8核并行能力閾值調到100時任務直接上萬GC暫停肉眼可感。盡量讓任務數(shù)量保持在并行線程數(shù)的整數(shù)倍附近是比較穩(wěn)妥的選擇。3.4 不只是寫代碼Fork/Join在JDK里的“隱藏應用”如果你覺得Fork/Join只能用來寫求和這類私有工具那就低估它了。你在日常開發(fā)里很可能已經(jīng)間接用過它只是完全沒意識到。**Arrays.parallelSort()底層就用了一個叫ForkJoinPool.commonPool()的公共池來并行做排序。Stream.parallel()的默認執(zhí)行線程池也是這個公共池只不過嚴格來說它是Java流的并行實現(xiàn)底層主要靠Spliterator拆分任務最終會借助Fork/Join來完成子任務的執(zhí)行。CompletableFuture**里的異步鏈路也有Fork/Join的影子。你可以做一個實驗用Stream的parallel()對一個百萬數(shù)組求和再看任務列表里線程名你會發(fā)現(xiàn)線程前綴是ForkJoinPool.commonPool-worker-x用的就是公共池。這驗證了一件事——標準庫并沒有給Stream另起爐灶而是復用了Fork/Join的調度體系。這也從側面說明Fork/Join不是小眾玩具而是Java并發(fā)工具箱里被官方重用的大件。4. 常見問題與排查技巧實錄4.1 性能不升反降問題出在哪Fork/Join寫著簡單但跑起來“變慢”是新手最常見的事故。我把這種問題總結成三句話第一任務拆得不夠粗。這是默認閾值過高導致的。如果你把百萬數(shù)組求和閾值設成50萬那任務只被拆一次一半任務在一個線程上跑最終性能約等于單線程看不出任何加速。第二任務拆得太細GC壓力爆炸。當一個任務拆出來的對象數(shù)以萬計時JVM開始頻繁Minor GCGC本身要暫停所有工作線程你并行計算的收益全被暫停耗掉了。這時候性能曲線會掉頭向下比你不用Fork/Join還慢。唯一的解法就是調大閾值讓任務數(shù)回歸到可控范圍。第三并行度設置不匹配。給你CPU核數(shù)為8的機器設并行度為16線程多了切換開銷反而上升設成2并行度不夠空閑核在嘆氣。想要精確匹配可以先用Runtime.getRuntime().availableProcessors()獲取物理核數(shù)再根據(jù)任務CPU/IO比例調整。排查這類性能問題時我的順序是先看閾值然后看并行度再看任務拆分是否合理最后用jvisualvm看GC曲線。大多數(shù)“我Fork/Join好慢”的問題最終都會落到“我閾值設置不合理”這五個字上。4.2 任務不執(zhí)行或線程全部阻塞這個現(xiàn)象很經(jīng)典程序啟動后某些任務一直不跑有的任務卡在join上出不來。通常原因有兩個。原因一任務沒有使用fork()而是直接互相join()。當線程A執(zhí)行任務甲甲需要等子任務乙的結果但你只是rightResult right.get()沒有先fork——這等于讓當前線程空等一個從未提交執(zhí)行的任務死鎖就這樣產(chǎn)生了。正確做法是fork之后調用join()或者用invokeAll()讓當前線程在join時能反過來執(zhí)行被等待任務打破相互等待的循環(huán)。原因二在自定義的ForkJoinPool里寫了阻塞調用把池里所有工作線程都占滿了。比如你池里只有8個線程但有16個任務在等待某個外部資源數(shù)據(jù)庫連接、Redis、鎖8個工作線程全部處于阻塞狀態(tài)剩下8個任務永遠得不到執(zhí)行機會。這個問題凡是線程池都會遇到但Fork/Join尤其容易忽視因為它的工作線程數(shù)量通常比較固定。我自己的習慣是如果任務里存在不可控的IO阻塞就不要用Fork/Join至少不要用并行度等于CPU核數(shù)的默認池。解決這類問題可以先用jstack看線程卡在哪如果看到一片F(xiàn)orkJoinPool管理線程處于WAITING狀態(tài)基本就能鎖定問題類型了。4.3 工作竊取引發(fā)的CPU飆高還有一類詭異問題任務全部完成了join()都返回了但CPU占用還是居高不下。有人懷疑是死循環(huán)但線程棧里看不到業(yè)務線程全是ForkJoinPool的工作線程。這大概率是碰到了公共池線程饑餓的變體。工作線程在處理完隊列任務之后會進入awaitWork()阻塞等待新任務喚醒。正常情況下它應該掛起但如果池里設置了commonMaxSpares公共池備用線程上限或合并了大量長尾任務它的喚醒機制可能會讓部分線程頻繁空轉CPU曲線就被“假活”拉高了。排查方法很簡單用jstack看那些ForkJoinPool線程如果大量處于RUNNABLE狀態(tài)且堆棧都指向同一個scan()方法那就是它在空轉等待池里塞入新任務。真遇到這種情況不需要太慌張。大多數(shù)場景下把自定義池的awaitQuiescence()或shutdown()好好調一調就行如果沒有特殊需求直接使用默認公共池讓JDK自行管理線程生命周期反而更省心。4.4 避坑清單幾個值得牢記的使用習慣總結一下我踩過坑之后沉淀下來的使用原則也算是一份速查表檢查項推薦做法反面典型閾值先從大往小調觀察性能拐點一開始就設為1任務對象爆炸并行度CPU密集型設為核數(shù)IO型調大1-2倍盲目設置64、128任務拆分優(yōu)先invokeAll或fork一半、本地執(zhí)行一半把所有子任務都fork出去阻塞操作避免在Fork/Join任務中做外部IO在任務里查數(shù)據(jù)庫異常處理join()可能拋出CancellationException要捕獲處理以為get()一定成功公共池直接使用ForkJoinPool.commonPool()就行它是全局的每個模塊都new一個池子還有個小技巧Fork/Join的invoke()返回的是第一個被提交的任務結果但如果你調動的是多個獨立任務最好還是拆分之后各自維護Future或者用一個匯總任務包住所有子任務免得把不同階段的邏輯混在一棵樹上。另外說個很實際的體會——Fork/Join的調試比普通多線程容易一截因為任務結構天生是樹任務棧的父子關系可以畫出來。我過去排查一個復雜并行查詢問題時就是用日志把每個子任務的fork/join時間打印出來根據(jù)時間線樹狀結構一眼就看出了瓶頸在哪個子分支上。如果你要把這份經(jīng)驗帶進團隊協(xié)作建議把任務拆分時的start和end也打出來調試時能把問題定位到具體的數(shù)組片段節(jié)省大量翻日志的時間。5. 從實戰(zhàn)到進階這幾類任務建議用Fork/Join重寫5.1 數(shù)據(jù)統(tǒng)計與聚合類任務最典型的就是批量日志統(tǒng)計、BI報表的DataFrame聚合、多維指標累加。這類任務規(guī)模大、數(shù)值計算多、各維度之間天然沒有依賴非常適合用Fork/Join切分到幾十個子任務并列計算最后再做匯總。我曾經(jīng)把一個單線程跑3秒的統(tǒng)計邏輯用Fork/Join拆到8線程后壓到800毫秒以內效果非常直觀。5.2 遞歸遍歷與目錄解析文件目錄遍歷是另一個高頻場景統(tǒng)計某個大目錄下所有文件夾大小、搜索特定類型文件數(shù)量。每個子目錄天然就是一個子任務不需要額外設計拆分邏輯RecursiveAction正好派上用場。不過要注意目錄遍歷里別在任務里直接做打印輸出會頻繁搶鎖打印獨占鎖的大坑我踩過一次。5.3 分治算法重寫歸并排序與快速排序歸并排序和Fork/Join是天生一對。單線程歸并排序對十萬級數(shù)據(jù)已經(jīng)很快但百萬級以上時并行歸并的優(yōu)勢會逐漸體現(xiàn)。代碼結構本質上就是invokeAll(left, right)然后等待兩邊排序完成后合并??焖倥判蛞差愃泼看芜x定基準把數(shù)據(jù)一分為二左右分區(qū)互不干擾并行化以后的分區(qū)操作可以讓大數(shù)組排序吞吐量提升一截。但這里要給你潑盆冷水別為了一道排序題把整個系統(tǒng)重構成Fork/Join。JDK已經(jīng)提供了Arrays.parallelSort()底層就是Fork/Join只要你確認自己的數(shù)據(jù)結構和排序需求滿足它的前置條件直接用就好。自己手寫并行排序多數(shù)情況下只適合教學學習或特種算法定制。6. 寫在最后我這個老用戶的一點心得Fork/Join框架上手不難難在能不能正確衡量“該不該用它”。我自己用過很多輪之后最大的感受是它解決的是結構化分治任務的并行問題不是所有多線程任務的銀彈。你一定要先判斷自己的任務是否符合“可遞歸拆、子任務獨立、結果可合并”這三個特征再決定入手。參數(shù)調優(yōu)不要靠猜靠“分而治之”的辦法固定其他變量只動閾值跑一組對比再固定閾值只動并行度跑一組對比。每次記錄熱度和耗時曲線自然告訴你最佳工作點在哪。學習階段多寫幾個小Demo比閱讀十篇原理文章有用得多像合計求和、數(shù)組最大值、遞歸目錄統(tǒng)計每一個都能幫你把fork、join、invokeAll、工作竊取之間的關系揉進手感里。最后再分享一個小技巧寫給團隊用的通用并行工具時務必把閾值和并行度做成可配置參數(shù)而不是常量寫死在類里。這樣換機器、換數(shù)據(jù)規(guī)模時不用改代碼重新編譯直接通過配置調整就能快速適配。這個習慣讓我在跨平臺部署時少走了很多彎路真心建議所有用Fork/Join做組件化開發(fā)的人都保留。