6實戰java高併發程序設計---Java 8/9/10與併發

2014年,Oracle發佈了新版本Java 8。對於Java來說,這顯然是一個具有里程碑意義的版本。它最主要的改進是增加了函數式編程的功能。就目前來說,Java最令人頭痛的問題,也是受到最多質疑的地方,應該就是Java煩瑣的語法。這樣我們不得不花費大量的代碼行數,來實現一些司空見慣的功能,以至於Java程序總是冗長的。但是,這一切將在Java 8的函數式編程中得到緩解。

嚴格來說,函數式編程與我們的主題並沒有太大關係,我似乎不應該在這裏提及它。但是,在Java 8中新增的一些與並行相關的API,卻以函數式編程的範式出現,爲了能讓大家更好地理解這些功能,我會先簡要地介紹一下Java 8中的函數式編程。


6.1 Java 8的函數式編程簡介

6.1.1 函數作爲一等公民

在理解函數作爲一等公民這句話時,讓我們先來看一種非常常用的互聯網語言JavaScript,相信大家對它都不會陌生。JavaScript並不是嚴格意義上的函數式編程,不過,它也不是屬於嚴格的面向對象編程。但是,如果你願意,那麼你既可以把它當作面嚮對象語言,也可以把它當作函數式語言,因此,把JavaScript稱爲多範式語言可能更加合適。

注意這裏each()函數的參數,這是一個匿名函數,在遍歷所有的li節點時,會彈出li節點的文本內容。將函數作爲參數傳遞給另外一個函數,這是函數式編程的特性之一。

這也是一段JavaScript代碼,在這段代碼中,注意函數f1的返回值,它返回了函數f2。在倒數第2行,返回f2函數並賦值給result,實際上,此時的result就是一個函數,並且指向f2。對result的調用,就會打印n的值。

一個函數可以作爲另外一個函數的返回值,也是函數式編程的重要特點。

6.1.2 無副作用

函數的副作用指的是函數在調用過程中,除給出了返回值之外,還修改了函數外部的狀態

比如,函數在調用過程中修改了某一個全局狀態。函數式編程認爲,函數的副用作應該被儘量避免

可以想象,如果一個函數肆意修改全局或者外部狀態,當系統出現問題時,我們可能很難判斷究竟是哪個函數引起的問題,這對於程序的調試和跟蹤是沒有好處的。如果函數都是顯式函數,那麼函數的執行顯然不會受到外部或者全局信息的影響,因此,對於調試和排錯是有益的。

注意:顯式函數指函數與外界交換數據的唯一渠道就是參數和返回值,顯式函數不會去讀取或者修改函數的外部狀態。與之相對的是隱式函數,隱式函數除參數和返回值外,還會讀取外部信息,或者修改外部信息

6.1.3 聲明式的(Declarative)

函數式編程是聲明式的編程方式。命令式(Imperative)程序設計喜歡大量使用可變對象和指令。我們總是習慣於創建對象或者變量,並且修改它們的狀態或值,或者喜歡提供一系列指令,要求程序執行。這種編程習慣在聲明式的函數式編程中有所變化。對於聲明式的編程範式,你不再需要提供明確的指令操作,所有的細節指令將會更好地被程序庫封裝,你要做的只是提出你的要求,聲明你的用意即可

請看下面一段程序,這是一段傳統的命令式編程,爲了打印數組中的值,我們需要進行一個循環,並且每次需要判斷循環是否結束。在循環體內,我們要明確地給出需要執行的語句和參數。

與之對應的聲明式編程代碼如下:

可以看到,變量數組的循環體居然消失了!println()函數似乎在這裏也沒有指定任何參數,在此,我們只是簡單地聲明瞭用意。有關循環及判斷循環是否結束等操作都被簡單地封裝在程序庫中。

6.1.4 不變的對象

在函數式編程中,幾乎所有傳遞的對象都不會被輕易修改。請看以下代碼:

代碼第2行看似對每一個數組成員執行了加1的操作。但是在操作完成後,在最後一行打印arr數組所有的成員值時,你還是會發現,數組成員並沒有變化!在使用函數式編程時,這種狀態是一種常態,幾乎所有的對象都拒絕被修改。這非常類似於不變模式。

6.1.5 易於並行

由於對象都處於不變的狀態,因此函數式編程更加易於並行。實際上,你甚至完全不用擔心線程安全的問題。我們之所以要關注線程安全,一個很重要的原因是當多個線程對同一個對象進行寫操作時,容易將這個對象“寫壞”。但是,由於對象是不變的,因此,在多線程環境下,也就沒有必要進行任何同步操作了。這樣有利於並行化,同時,在並行化後,由於沒有同步和鎖機制,其性能也會比較好。

6.1.6 更少的代碼

通常情況下,函數式編程更加簡明扼要

請看下面這個例子,對於數組中每一個成員,首先判斷是否是奇數,如果是奇數,則執行加1,並最終打印數組內所有成員。


6.2 函數式編程基礎

在正式進入函數式編程之前,有必要先了解一下Java 8爲支持函數式編程所做的基礎性的改進,這裏將簡要介紹一下FunctionalInterface註釋、接口默認方法和方法句柄。

6.2.1 FunctionalInterface註釋

Java 8提出了函數式接口的概念。所謂函數式接口,簡單地說,就是隻定義了單一抽象方法的接口。比如下面的定義:

註釋FunctionalInterface用於表明IntHandler接口是一個函數式接口,該接口被定義爲只包含一個抽象方法handle(),因此它符合函數式接口的定義。如果一個函數滿足函數式接口的定義,那麼即使不標註爲@FunctionalInterface,編譯器依然會把它看作函數式接口。這有點像@Override註釋,如果你的函數符合重載的要求,無論你是否標註了@Override,編譯器都會識別這個重載函數,但如果你進行了標註,而實際的代碼不符合規範,那麼就會得到一個編譯錯誤的提示。

這裏需要強調的是,函數式接口只能有一個抽象方法,而不是只能有一個方法

這裏需要強調的是,函數式接口只能有一個抽象方法,而不是只能有一個方法。這分兩點來說明:首先,在Java 8中,接口運行存在實例方法(參見下節的“接口默認方法”);其次,任何被java.lang.Object實現的方法,都不能視爲抽象方法,因此NonFunc接口不是函數式接口,因爲equals()方法在java.lang.Object中已經實現。

 

函數式接口的實例可以由方法引用或者lambda表達式進行構造,我們將在後面進一步舉例說明。

6.2.2 接口默認方法(接口裏面竟然可以定義的方法裏面可以有方法體,實現類可以不重寫接口裏的默認方法 ,實現類可以直接拿來用,不需要重寫方法,有繼承的味道)

在Java 8之前的Java版本,接口只能包含抽象方法。但從Java 8開始,接口也可以包含若干個實例方法。這一改進使得Java 8擁有了類似於多繼承的能力。一個對象實例,將擁有來自多個不同接口的實例方法。

注意上述代碼中Mule實例同時擁有來自不同接口的實現方法,這在Java 8之前是做不到的。從某種程度上說,這種模式可以彌補Java單一繼承的一些不便。但同時也要知道,它也將遇到和多繼承相同的問題,如圖6.2所示。如果IDonkey也存在一個默認的run()方法,那麼同時實現它們的Mule就會不知所措,因爲它不知道應該以哪個方法爲準。

6.2.3 lambda表達式

lambda表達式可以說是函數式編程的核心。lambda表達式即匿名函數,它是一段沒有函數名的函數體,可以作爲參數直接傳遞給相關的調用者,lambda表達式極大地增強了Java語言的表達能力

和匿名對象一樣,lambda表達式也可以訪問外部的局部變量,如下所示:

上述代碼可以編譯通過,正常執行並輸出6。與匿名內部對象一樣,在這種情況下,外部的num變量必須聲明爲final,這樣才能保證在lambda表達式中合法地訪問它

奇妙的是,對於lambda表達式而言,即使去掉上述的final定義,程序依然可以編譯通過!但千萬不要以爲這樣你就可以修改num的值了。實際上,這只是Java 8做了一個小處理,它會自動地將在lambda表達式中使用的變量視爲final

6.2.4 方法引用

方法引用是Java 8中提出的用來簡化lambda表達式的一種手段。它通過類名和方法名來定位一個靜態方法或者實例方法。

方法引用在Java 8中的使用非常靈活。總的來說,可以分爲以下幾種。

● 靜態方法引用:ClassName::methodName。

● 實例上的實例方法引用:instanceReference::methodName。

● 超類上的實例方法引用:super::methodName。

● 類型上的實例方法引用:ClassName::methodName。

● 構造方法引用:Class::new。

● 數組構造方法引用:TypeName[]::new。

首先,方法引用使用“::”定義,“::”的前半部分表示類名或者實例名,後半部分表示方法名稱。如果是構造函數,則使用new表示

對於第一個方法引用“User::getName”,表示User類的實例方法。在執行時,Java會自動識別流中的元素(這裏指User實例)是作爲調用目標還是調用方法的參數。在“User::getName”中,顯然流內的元素都應該作爲調用目標,因此實際上,在這裏調用了每一個User對象實例的getName()方法,並將這些User的name作爲一個新的流。同時,對於這裏得到的所有name,使用方法引用System.out::println進行處理。這裏的System.out爲PrintStream對象實例,因此,這裏表示System.out實例的println方法。系統也會自動判斷,流內的元素此時應該作爲方法的參數傳入,而不是調用目標。

如果一個類中存在同名的實例方法和靜態函數,那麼編譯器就會感到很困惑,因爲此時,它不知道應該使用哪個方法進行調用。它既可以選擇同名的實例方法,將流內元素作爲調用目標,也可以使用靜態方法,將流元素作爲參數。


6.3 一步一步走入函數式編程

lambda表達式。表達式由“->”分割,左半部分表示參數,右半部分表示實現體。因此,我們也可以簡單地理解lambda表達式只是匿名對象實現的一種新的方式。實際上,也是這樣的。下圖使用lambda表達式,並且使用方法引用

addThen()


6.4 並行流與並行排序

Java 8可以在接口不變的情況下,將流改爲並行流。這樣,就可以很自然地使用多線程進行集合中的數據處理。

6.4.1 使用並行流過濾數據

接着,使用函數式編程統計給定範圍內所有的質數。

上述代碼是串行的,將它改造成並行計算非常簡單,只需要將流並行化即可。parallel()方法得到一個並行流,然後在並行流上進行過濾,此時,PrimeUtil.isPrime()函數會被多線程併發調用,應用於流中的所有元素。記住下面的IntStream和parallel兩個方法

6.4.2 從集合得到並行流

在函數式編程中,我們可以從集合得到一個流或者並行流。下面這段代碼試圖統計集合內所有學生的平均分:

從集合對象List中,我們使用stream()方法可以得到一個流。如果希望將這段代碼並行化,則可以使用parallelStream()函數。

可以看到,將原有的串行方式改造成並行執行是非常容易的。

6.4.3 並行排序

除了並行流,對於普通數組,Java 8也提供了簡單的並行功能。比如,對於數組排序,我們有Arrays.sort()方法,當然這是串行排序,但在Java 8中可以使用新增的Arrays.parallelSort()方法直接使用並行排序。比如,你可以這樣使用:

除了並行排序,Arrays中還增加了一些API用於數組中數據的賦值,比如:

當然,以上過程是串行的。但是隻要使用setAll()方法對應的並行版本,你就可以很快將它執行在多個CPU上。


6.5 增強的Future:CompletableFuture

CompletableFuture是Java 8新增的一個超大型工具類。爲什麼說它大呢?因爲它實現了Future接口,而更重要的是,它也實現了CompletionStage接口。CompletionStage接口也是Java 8中新增的,它擁有多達約40種方法!是的,你沒有看錯,這看起來完全不符合設計中所謂的“單方法接口”原則,但是在這裏,它就這麼存在了。這個接口擁有如此衆多的方法,是爲函數式編程中的流式調用準備的。通過CompletionStage接口,我們可以在一個執行結果上進行多次流式調用,以此可以得到最終結果。比如,你可以在一個CompletionStage接口上進行如下調用:

 

6.5.1 完成了就通知我

CompletableFuture和Future一樣,可以作爲函數調用的契約。向CompletableFuture請求一個數據,如果數據還沒有準備好,請求線程就會等待。而讓人驚喜的是,我們可以手動設置CompletableFuture的完成狀態。

上述代碼在第1~17行定義了一個AskThread線程。它接收一個CompletableFuture作爲其構造函數,它的任務是計算CompletableFuture表示的數字的平方,並將其打印。代碼第20行,我們創建一個CompletableFuture對象實例。第21行,我們將這個對象實例傳遞給這個AskThread線程,並啓動這個線程。此時,AskThread在執行到第12行代碼時會阻塞,因爲CompletableFuture中根本沒有它所需要的數據,整個CompletableFuture處於未完成狀態。第23行用於模擬長時間的計算過程。當計算完成後,可以將最終數據載入CompletableFuture,並標記爲完成狀態(第25行)。當第25行代碼執行後,表示CompletableFuture已經完成,因此AskThread就可以繼續執行了。

6.5.2 異步執行任務

通過CompletableFuture提供的進一步封裝,我們很容易實現Future模式那樣的異步調用。比如:

6.5.3 流式調用

在前文中我已經提到,CompletionStage的40個接口是爲函數式編程做準備的。在這裏,就讓我們看一下,如何使用這些接口進行函數式的流式API調用。

我們在第15行執行CompletableFuture.get()方法,目的是等待calc()函數執行完成。由於CompletableFuture異步執行的緣故,如果不進行這個等待調用,那麼主函數不等calc()方法執行完畢就會退出,隨着主線程的結束,所有的Daemon線程都會立即退出,從而導致calc()方法無法正常完成

6.5.4 CompletableFuture中的異常處理

如果CompletableFuture在執行過程中遇到異常,那麼我們可以用函數式編程的風格來優雅地處理這些異常。CompletableFuture提供了一個異常處理方法exceptionally():

6.5.5 組合多個CompletableFuture

CompletableFuture還允許你將多個CompletableFuture進行組合。一種方法是使用thenCompose()方法,一個CompletableFuture可以在執行完成後,將執行結果通過Function接口傳遞給下一個CompletionStage實例進行處理(Function接口返回新的CompletionStage實例):

另外一種組合多個CompletableFuture的方法是thenCombine()方法

方法thenCombine()首先完成當前CompletableFuture和other的執行。接着,將這兩者的執行結果傳遞給BiFunction(該接口接收兩個參數,並有一個返回值),並返回代表BiFunction實例的CompletableFuture對象。

上述代碼中,首先生成兩個CompletableFuture實例(第6~7行),接着使用thenCombine()方法組合這兩個CompletableFuture,將兩者的執行結果進行累加(由第9行的(i, j) -> (i + j)實現),並將其累加結果轉爲字符串輸出。

6.5.6 支持timeout的 CompletableFuture

在JDK 9以後CompletableFuture增加了timeout功能。如果一個任務在給定時間內沒有完成,則直接拋出異常。


6.6 讀寫鎖的改進:StampedLock

StampedLock是Java 8中引入的一種新的鎖機制,可以認爲它是讀寫鎖的一個改進版本。讀寫鎖雖然分離了讀和寫的功能,使得讀與讀之間可以完全併發。但是,讀和寫之間依然是衝突的。讀鎖會完全阻塞寫鎖,它使用的依然是悲觀的鎖策略,如果有大量的讀線程,它也有可能引起寫線程的“飢餓”。


6.7 原子類的增強

在之前的章節中已經提到了原子類的使用,無鎖的原子類操作使用系統的CAS指令,有着遠遠超越鎖的性能,是否有可能在性能上更上一層樓呢?答案是肯定的。Java 8引入了LongAdder類,它在java.util.concurrent.atomic包下,因此,可以推測,它也使用了CAS指令

6.7.1 更快的原子類:LongAdder

LongAdder使用了熱點分離的方法,類似concurrentHashMap的多段鎖,降低鎖粒度


6.8 ConcurrentHashMap的增強

在JDK 1.8以後,ConcurrentHashMap有了一些API的增強,其中很多增強接口與lambda表達式有關,這些增強接口大大方便了應用的開發。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章