轉(zhuǎn)自?http://blog.dyngr.com/blog/2016/09/15/java-forkjoinpool-internals/
前言
Java 1.7 引入了一種新的并發(fā)框架—— Fork/Join Framework。
本文的主要目的是介紹 ForkJoinPool 的適用場景,實現(xiàn)原理,以及示例代碼。
TLDR;?如果覺得文章太長的話,以下就是結(jié)論:
ForkJoinPool?不是為了替代?ExecutorService,而是它的補充,在某些應(yīng)用場景下性能比?ExecutorService?更好。(見?Java Tip: When to use ForkJoinPool vs ExecutorService?)
ForkJoinPool?主要用于實現(xiàn)“分而治之”的算法,特別是分治之后遞歸調(diào)用的函數(shù),例如 quick sort 等。
ForkJoinPool?最適合的是計算密集型的任務(wù),如果存在 I/O,線程間同步,sleep()?等會造成線程長時間阻塞的情況時,最好配合使用?ManagedBlocker。
使用
首先介紹的是大家最關(guān)心的 Fork/Join Framework 的使用方法,如果對使用方法已經(jīng)很熟悉的話,可以跳過這一節(jié),直接閱讀原理。
用一個特別簡單的求整數(shù)數(shù)組所有元素之和來作為我們現(xiàn)在需要解決的問題吧。
問題
計算1至1000的正整數(shù)之和。
解決方法
For-loop
最簡單的,顯然是不使用任何并行編程的手段,只用最直白的?for-loop?來實現(xiàn)。下面就是具體的實現(xiàn)代碼。
不過為了便于橫向?qū)Ρ龋矠榱俗尨a更加 Java Style,首先我們先定義一個 interface。
publicinterfaceCalculator{longsumUp(long[] numbers);}
這個 interface 非常簡單,只有一個函數(shù)?sumUp,就是返回數(shù)組內(nèi)所有元素的和。
再寫一個?main?方法。
publicclassMain{publicstaticvoidmain(String[] args){long[] numbers = LongStream.rangeClosed(1,1000).toArray();? ? ? ? Calculator calculator =newMyCalculator();? ? ? ? System.out.println(calculator.sumUp(numbers));// 打印結(jié)果500500}}
接下來就是我們的 Plain Old For-loop Calculator,簡稱?POFLC?的實現(xiàn)了。(這其實是個段子,和主題完全無關(guān),感興趣的請見文末的彩蛋)
publicclassForLoopCalculatorimplementsCalculator{publiclongsumUp(long[] numbers){longtotal =0;for(longi : numbers) {? ? ? ? ? ? total += i;? ? ? ? }returntotal;? ? }}
這段代碼毫無出奇之處,也就不多解釋了,直接跳入下一節(jié)——并行計算。
ExecutorService
在 Java 1.5 引入?ExecutorService?之后,基本上已經(jīng)不推薦直接創(chuàng)建?Thread?對象,而是統(tǒng)一使用?ExecutorService。畢竟從接口的易用程度上來說?ExecutorService?就遠(yuǎn)勝于原始的?Thread,更不用提?java.util.concurrent?提供的數(shù)種線程池,F(xiàn)uture 類,Lock 類等各種便利工具。
使用?ExecutorService?的實現(xiàn)
publicclassExecutorServiceCalculatorimplementsCalculator{privateintparallism;privateExecutorService pool;publicExecutorServiceCalculator(){? ? ? ? parallism = Runtime.getRuntime().availableProcessors();// CPU的核心數(shù)pool = Executors.newFixedThreadPool(parallism);? ? }privatestaticclassSumTaskimplementsCallable{privatelong[] numbers;privateintfrom;privateintto;publicSumTask(long[] numbers,intfrom,intto){this.numbers = numbers;this.from = from;this.to = to;? ? ? ? }@OverridepublicLongcall()throwsException{longtotal =0;for(inti = from; i <= to; i++) {? ? ? ? ? ? ? ? total += numbers[i];? ? ? ? ? ? }returntotal;? ? ? ? }? ? }@OverridepubliclongsumUp(long[] numbers){? ? ? ? List> results =newArrayList<>();// 把任務(wù)分解為 n 份,交給 n 個線程處理intpart = numbers.length / parallism;for(inti =0; i < parallism; i++) {intfrom = i * part;intto = (i == parallism -1) ? numbers.length -1: (i +1) * part -1;? ? ? ? ? ? results.add(pool.submit(newSumTask(numbers, from, to)));? ? ? ? }// 把每個線程的結(jié)果相加,得到最終結(jié)果longtotal =0L;for(Future f : results) {try{? ? ? ? ? ? ? ? total += f.get();? ? ? ? ? ? }catch(Exception ignore) {}? ? ? ? }returntotal;? ? }}
如果對?ExecutorService?不太熟悉的話,推薦閱讀《七天七并發(fā)模型》的第二章,對 Java 的多線程編程基礎(chǔ)講解得比較清晰。當(dāng)然著名的《Java并發(fā)編程實戰(zhàn)》也是不可多得的好書。
ForkJoinPool
前面花了點時間講解了?ForkJoinPool?之前的實現(xiàn)方法,主要為了在代碼的編寫難度上進(jìn)行一下對比。現(xiàn)在就列出本篇文章的重點——ForkJoinPool?的實現(xiàn)方法。
publicclassForkJoinCalculatorimplementsCalculator{privateForkJoinPool pool;privatestaticclassSumTaskextendsRecursiveTask{privatelong[] numbers;privateintfrom;privateintto;publicSumTask(long[] numbers,intfrom,intto){this.numbers = numbers;this.from = from;this.to = to;? ? ? ? }@OverrideprotectedLongcompute(){// 當(dāng)需要計算的數(shù)字小于6時,直接計算結(jié)果if(to - from <6) {longtotal =0;for(inti = from; i <= to; i++) {? ? ? ? ? ? ? ? ? ? total += numbers[i];? ? ? ? ? ? ? ? }returntotal;// 否則,把任務(wù)一分為二,遞歸計算}else{intmiddle = (from + to) /2;? ? ? ? ? ? ? ? SumTask taskLeft =newSumTask(numbers, from, middle);? ? ? ? ? ? ? ? SumTask taskRight =newSumTask(numbers, middle+1, to);? ? ? ? ? ? ? ? taskLeft.fork();? ? ? ? ? ? ? ? taskRight.fork();returntaskLeft.join() + taskRight.join();? ? ? ? ? ? }? ? ? ? }? ? }publicForkJoinCalculator(){// 也可以使用公用的 ForkJoinPool:// pool = ForkJoinPool.commonPool()pool =newForkJoinPool();? ? }@OverridepubliclongsumUp(long[] numbers){returnpool.invoke(newSumTask(numbers,0, numbers.length-1));? ? }}
可以看出,使用了?ForkJoinPool?的實現(xiàn)邏輯全部集中在了?compute()?這個函數(shù)里,僅用了14行就實現(xiàn)了完整的計算過程。特別是,在這段代碼里沒有顯式地“把任務(wù)分配給線程”,只是分解了任務(wù),而把具體的任務(wù)到線程的映射交給了?ForkJoinPool?來完成。
原理
如果你除了?ForkJoinPool?的用法以外,對?ForkJoinPoll?的原理也感興趣的話,那么請接著閱讀這一節(jié)。在這一節(jié)中,我會結(jié)合?ForkJoinPool?的作者 Doug Lea 的論文——《A Java Fork/Join Framework》,盡可能通俗地解釋 Fork/Join Framework 的原理。
我一直以為,要理解一樣?xùn)|西的原理,最好就是自己嘗試著去實現(xiàn)一遍。根據(jù)上面的示例代碼,可以看出?fork()?和?join()?是 Fork/Join Framework “魔法”的關(guān)鍵。我們可以根據(jù)函數(shù)名假設(shè)一下?fork()?和?join()?的作用:
fork():開啟一個新線程(或是重用線程池內(nèi)的空閑線程),將任務(wù)交給該線程處理。
join():等待該任務(wù)的處理線程處理完畢,獲得返回值。
以上模型似乎可以(?)解釋 ForkJoinPool 能夠多線程執(zhí)行的事實,但有一個很明顯的問題
當(dāng)任務(wù)分解得越來越細(xì)時,所需要的線程數(shù)就會越來越多,而且大部分線程處于等待狀態(tài)。
但是如果我們在上面的示例代碼加入以下代碼
System.out.println(pool.getPoolSize());
這會顯示當(dāng)前線程池的大小,在我的機器上這個值是4,也就是說只有4個工作線程。甚至即使我們在初始化 pool 時指定所使用的線程數(shù)為1時,上述程序也沒有任何問題——除了變成了一個串行程序以外。
publicForkJoinCalculator(){? ? pool =newForkJoinPool(1);}
這個矛盾可以導(dǎo)出,我們的假設(shè)是錯誤的,并不是每個?fork()?都會促成一個新線程被創(chuàng)建,而每個?join()?也不是一定會造成線程被阻塞。Fork/Join Framework 的實現(xiàn)算法并不是那么“顯然”,而是一個更加復(fù)雜的算法——這個算法的名字就叫做?work stealing?算法。
work stealing 算法在 Doung Lea 的論文中有詳細(xì)的描述,以下是我在結(jié)合 Java 1.8 代碼的閱讀以后——現(xiàn)有代碼的實現(xiàn)有一部分相比于論文中的描述發(fā)生了變化——得到的相對通俗的解釋:
基本思想
ForkJoinPool?的每個工作線程都維護(hù)著一個工作隊列(WorkQueue),這是一個雙端隊列(Deque),里面存放的對象是任務(wù)(ForkJoinTask)。
每個工作線程在運行中產(chǎn)生新的任務(wù)(通常是因為調(diào)用了?fork())時,會放入工作隊列的隊尾,并且工作線程在處理自己的工作隊列時,使用的是?LIFO?方式,也就是說每次從隊尾取出任務(wù)來執(zhí)行。
每個工作線程在處理自己的工作隊列同時,會嘗試竊取一個任務(wù)(或是來自于剛剛提交到 pool 的任務(wù),或是來自于其他工作線程的工作隊列),竊取的任務(wù)位于其他線程的工作隊列的隊首,也就是說工作線程在竊取其他工作線程的任務(wù)時,使用的是?FIFO?方式。
在遇到?join()?時,如果需要 join 的任務(wù)尚未完成,則會先處理其他任務(wù),并等待其完成。
在既沒有自己的任務(wù),也沒有可以竊取的任務(wù)時,進(jìn)入休眠。
下面來介紹一下關(guān)鍵的兩個函數(shù):fork()?和?join()?的實現(xiàn)細(xì)節(jié),相比來說?fork()?比?join()?簡單很多,所以先來介紹?fork()。
fork
fork()?做的工作只有一件事,既是把任務(wù)推入當(dāng)前工作線程的工作隊列里。可以參看以下的源代碼:
publicfinalForkJoinTaskfork(){? ? Thread t;if((t = Thread.currentThread())instanceofForkJoinWorkerThread)? ? ? ? ((ForkJoinWorkerThread)t).workQueue.push(this);elseForkJoinPool.common.externalPush(this);returnthis;}
join
join()?的工作則復(fù)雜得多,也是?join()?可以使得線程免于被阻塞的原因——不像同名的?Thread.join()。
檢查調(diào)用?join()?的線程是否是 ForkJoinThread 線程。如果不是(例如 main 線程),則阻塞當(dāng)前線程,等待任務(wù)完成。如果是,則不阻塞。
查看任務(wù)的完成狀態(tài),如果已經(jīng)完成,直接返回結(jié)果。
如果任務(wù)尚未完成,但處于自己的工作隊列內(nèi),則完成它。
如果任務(wù)已經(jīng)被其他的工作線程偷走,則竊取這個小偷的工作隊列內(nèi)的任務(wù)(以?FIFO?方式),執(zhí)行,以期幫助它早日完成欲 join 的任務(wù)。
如果偷走任務(wù)的小偷也已經(jīng)把自己的任務(wù)全部做完,正在等待需要 join 的任務(wù)時,則找到小偷的小偷,幫助它完成它的任務(wù)。
遞歸地執(zhí)行第5步。
將上述流程畫成序列圖的話就是這個樣子:
以上就是?fork()?和?join()?的原理,這可以解釋 ForkJoinPool 在遞歸過程中的執(zhí)行邏輯,但還有一個問題
最初的任務(wù)是 push 到哪個線程的工作隊列里的?
這就涉及到?submit()?函數(shù)的實現(xiàn)方法了
submit
其實除了前面介紹過的每個工作線程自己擁有的工作隊列以外,F(xiàn)orkJoinPool?自身也擁有工作隊列,這些工作隊列的作用是用來接收由外部線程(非?ForkJoinThread?線程)提交過來的任務(wù),而這些工作隊列被稱為?submitting queue?。
submit()?和?fork()?其實沒有本質(zhì)區(qū)別,只是提交對象變成了 submitting queue 而已(還有一些同步,初始化的操作)。submitting queue 和其他 work queue 一樣,是工作線程”竊取“的對象,因此當(dāng)其中的任務(wù)被一個工作線程成功竊取時,就意味著提交的任務(wù)真正開始進(jìn)入執(zhí)行階段。
總結(jié)
在了解了 Fork/Join Framework 的工作原理之后,相信很多使用上的注意事項就可以從原理中找到原因。例如:為什么在?ForkJoinTask?里最好不要存在 I/O 等會阻塞線程的行為?,這個我姑且留作思考題吧 :)
還有一些延伸閱讀的內(nèi)容,在此僅提及一下:
ForkJoinPool?有一個?Async Mode?,效果是工作線程在處理本地任務(wù)時也使用 FIFO 順序。這種模式下的?ForkJoinPool?更接近于是一個消息隊列,而不是用來處理遞歸式的任務(wù)。
在需要阻塞工作線程時,可以使用?ManagedBlocker。
Java 1.8 新增加的?CompletableFuture?類可以實現(xiàn)類似于 Javascript 的 promise-chain,內(nèi)部就是使用?ForkJoinPool?來實現(xiàn)的。
彩蛋
之所以煞有介事地取名為?POFLC,顯然是為了模仿?POJO?。而?POJO?——?Plain Old Java Object?這個詞是如何產(chǎn)生的,在 stackoverflow 上有個帖子討論過,摘錄一下就是
I’ve come to the conclusion that people forget about regular Java objects because they haven’t got a fancy name. That’s why, while preparing for a talk in 2000, Rebecca Parsons, Josh Mackenzie, and I gave them one: POJOs (plain old Java objects).
我得出一個結(jié)論:人們之所以總是忘記使用標(biāo)準(zhǔn)的 Java 對象是因為缺少一個足夠裝逼的名字(譯注:類似于 Java Bean 這樣的名字)。因此,在準(zhǔn)備2000年的演講時,Rebecca Parsons,Josh Mackenzie 和我給他們起了一個名字叫做 POJO (平淡無奇的 Java 對象)。