序言
上一次提到了Java 1.5中提供新的多線程模型,在大多數情況下,這已經能夠滿足日常開發的需要。但是偶爾也許覺得那一套模型還是覺得欠缺點什么,于是乎,Java 7/8中又提供了新的多線程模型。
Java 8中提供了并行流以及**ForkJoinPool **(FJP)和lambda(據說Java 8的lambda只是語法糖,沒有深究過)
ForkJoinPool / ForkJoinTask
這一套工具是由Java 7提供的。要使用這種方法之前,應該有所了解函數式編程,如果有過JavaScript或者其他一個腳本語言的開發,應該對此不會陌生。另外,通過這種方法,比較難確定實際上是否使用了超過一個線程,因為這是由流的具體實現決定的。最后,在默認情況下是通過ForkJoinPool.commonPool()
實現并行的。這個通用池由JVM來管理,并且被JVM進程內的所有線程共享。(以下示例代碼若非特別說明均要求Java 7及以上,部分代碼出于簡潔,使用了lambda表達式,因此需要Java 8及以上才可以運行。可以將lambda表達式用匿名內部類替代,即可在Java 7下編譯通過)
多線程經常會伴隨著并行計算(并行不等于并發),雖然并不絕對,但是通常與并行或多或少存在著聯系。而并行計算的特點在于將較為復雜、龐大的任務,拆解成互不相干、較為簡單、小型的任務,最后將各個小任務的結果匯總、分析、處理,得到原本大任務的結果。這樣做的目的無非是提高效率,充分利用硬件計算資源,有效規避瓶頸效應。
接下來以計算y! - x!為例,展示代碼:
// 計算y! - x!的值
class MyJob extends RecursiveTask<Integer> {
private int y;
private int x;
public MyJob(int x, int y) {
this.x = x;
this.y = y;
}
@Override
protected Integer compute() {
if (x > 2) { // 先計算x的階乘
MyJob subJobX = new MyJob(x - 1, -1);
subJobX.fork();
x *= subJobX.join();
if (y == -1) { // 判斷是否為遞歸計算
return x; // 遞歸計算則返回階乘結果
} else if (y > 2) { // 計算y的階乘
MyJob subJobY = new MyJob(y - 1, -1);
subJobY.fork();
y *= subJobY.join();
return y - x; // 輸出最后任務的結果
} else { // 正常輸入,不會進入這個分支
System.err.println("Error.");
return 0;
}
} else {
return 2;
}
}
}
public class ForkJoinExample {
public static void main(String[] args) throws ExecutionException, InterruptedException {
ForkJoinPool forkJoinPool = new ForkJoinPool();
MyJob job = new MyJob(4, 10);
Future<Integer> result = forkJoinPool.submit(job);
while (!result.isDone()) {
System.err.println("Waiting for result");
Thread.sleep(0, 1);
}
forkJoinPool.shutdown();
System.err.println("The results is " + result.get());
}
}
輸出結果:
Waiting for result
Waiting for result
The results is 3628776
在這個例子中,我把計算n * (n - 1)作為最小的任務。因此先判斷x是否大于2,如果不大于2,則fork出一個分支,計算(x - 1)!的值,y同理。最后得到x!與y!的值,相減,得出最后的結果。代碼中,fork出來的分支中,參數y我作為一個標志,如果為-1則不是原始調用,而是fork的子任務,只需要負責計算傳遞進來x的階乘即可。
這段代碼中,你看不出任務在哪里完成(哪個線程)、由誰完成、什么時候完成。正如前面所述,甚至你很難看出來是否是大于一個線程在執行。
當然你可以嘗試在compte方法中增加System.err.printf("%s is running.%n",Thread.currentThread().getName());
的語句來查看輸出,到底有幾個線程在運行。不過根據我的實踐來說,一般情況下,應該只有一個線程在運行。這不是說代碼有問題,主要有兩個因素:
- 代碼設計不合理,我的代碼中,計算x的階乘與y的階乘是分開的,并沒有一起fork,因此本質上并發性其實沒有顯示出來。我的代碼其實相當于先計算x!,然后計算y!,最后計算y! - x!
- 由于例子中代碼的計算量很小,以當前CPU的計算能力有盈余。針對這種情況,可以嘗試多開幾個任務同時并行查看輸出結果。
當然我覺得上面的例子不好,因此想了另一張場景,并用代碼演示一下。
比如現在需要制作一個網絡爬蟲,爬什么呢,就爬簡述首頁推薦文章每篇文章的字數。代碼中涉及網絡請求和正則表達式的部分就不說明了,其中用了我自己寫的一個小工具類Spider.java和HttpRequester.java。
class JianshuSpiderJob extends RecursiveTask<List<String>> {
private static final String HOST = "http://www.lxweimin.com";
private String url;
public JianshuSpiderJob() {
this(HOST);
}
protected JianshuSpiderJob(String url) {
this.url = url;
}
protected List<String> requestHomepage() throws IOException {
List<String> result = new ArrayList<>();
Spider.newHost(new URL(this.url)).get((responseCode, responseHeaders, responseStream) -> { // 請求簡書主頁
if (responseCode == 200) {
Pattern indexPattern = Pattern.compile("(/p/[a-z0-9]+)\">([^<>]+)</a></h4>");
Matcher indexMatcher = indexPattern.matcher(responseStream.toString());
// 從這里開始派分子任務
List<JianshuSpiderJob> subJobs = new ArrayList<>();
while (indexMatcher.find()) {
String subUrl = indexMatcher.group(1);
String subTitle = indexMatcher.group(2);
JianshuSpiderJob subJob = new JianshuSpiderJob(subUrl);
subJob.fork();
subJobs.add(subJob);
result.add(String.format("%s=%s", subTitle, subUrl));
}
// 銜接子任務的結果
for (JianshuSpiderJob job : subJobs) {
List<String> list = job.join();
if (list.size() > 0) {
String[] subResult = list.get(0).split("=");
for (int i = 0; i < result.size(); i++) {
if (result.get(i).indexOf(subResult[0]) > 0) {
String[] localResult = result.get(i).split("=");
result.remove(i);
result.add(String.format("%s=%s", localResult[0], subResult[1]));
break;
}
}
}
}
} else { // 網絡錯誤
System.err.println("There is an error when trying to get homepage.");
}
return responseCode;
});
return result; // 返回最終結果
}
protected List<String> requestSubPage() throws IOException {
List<String> result = new ArrayList<>();
Map<String, String> requestHeader = new HashMap<>();
requestHeader.put("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.71 Safari/537.36");
// 獲取文章頁面的詳細信息
Spider.newHost(new URL(HOST + this.url))
.setRequestHeaders(requestHeader)
.get((responseCode, responseHeaders, responseStream) -> { // 請求具體的文章頁
String html = responseStream.toString();
Pattern contextPattern = Pattern.compile("\"slug\":\"([a-z0-9]+)\".*?\"wordage\":(\\d+)");
Matcher contextMatcher = contextPattern.matcher(html);
if (contextMatcher.find())
result.add(String.format("%s=%s", contextMatcher.group(1), contextMatcher.group(2)));
return responseCode;
});
return result;
}
@Override
protected List<String> compute() {
try {
System.err.printf("%s is running.%n", Thread.currentThread().getName()); // 顯示當前工作線程
if (HOST.equals(this.url)) {
return this.requestHomepage();
} else {
return this.requestSubPage();
}
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
}
public class ForkJoinExample {
public static void main(String[] args) throws ExecutionException, InterruptedException {
ForkJoinPool forkJoinPool = new ForkJoinPool();
JianshuSpiderJob job = new JianshuSpiderJob();
forkJoinPool.submit(job);
List<String> result = job.get();
// 輸出結果
System.err.println("Article\tWords");
for (String str : result) {
String[] s = str.split("=");
if (s.length > 1)
System.err.printf("%s\t%s%n", s[0], s[1]);
else
System.err.println("Err -> " + str);
}
forkJoinPool.shutdown();
}
}
這里例子相比較之前那個計算階乘比較有典型,因為網絡請求本身是阻塞。一個請求可能幾毫秒就可以返回,也可以幾秒鐘才返回,甚至等了幾秒鐘以后,連接被中斷,請求失敗。例子中,并沒有考慮網絡異常的情況下。
運行結果:
ForkJoinPool-1-worker-1 is running.
ForkJoinPool-1-worker-1 is running.
ForkJoinPool-1-worker-0 is running.
ForkJoinPool-1-worker-2 is running.
ForkJoinPool-1-worker-0 is running.
ForkJoinPool-1-worker-3 is running.
ForkJoinPool-1-worker-0 is running.
ForkJoinPool-1-worker-2 is running.
ForkJoinPool-1-worker-4 is running.
ForkJoinPool-1-worker-3 is running.
ForkJoinPool-1-worker-6 is running.
ForkJoinPool-1-worker-4 is running.
ForkJoinPool-1-worker-3 is running.
ForkJoinPool-1-worker-4 is running.
ForkJoinPool-1-worker-2 is running.
ForkJoinPool-1-worker-0 is running.
ForkJoinPool-1-worker-7 is running.
ForkJoinPool-1-worker-4 is running.
ForkJoinPool-1-worker-3 is running.
ForkJoinPool-1-worker-0 is running.
ForkJoinPool-1-worker-4 is running.
Article Words
又一年輕姑娘離世:請記住這些“1+1=死神”的藥物! 2862
報告大王,新概念英語一至四冊全套資源(視頻、音頻、電子書)已被我活捉! 861
模式學習|找到最適合你的學習“套路” 4009
我們女孩子真不容易,既要貌美如花,又要賺錢養家。 1603
二十歲出頭的你,別急著想要出人頭地 2787
別怕,誰的大學不迷茫 1649
這10年,多少人從郭敬明到咪蒙 3143
老實人浪起來,你我都招架不住 2165
如果沒有回報,我會堅持寫作多久 2595
簡書早報161030——《不負責任吐槽,四本買完看完就后悔的暢銷書》 2029
時而自信,時而自卑,如何改變這種雙重人生? 2855
有哪些小樂器,是學習起來非常方便的? 4259
窮人,最可怕的是總說自己窮 1838
時光回去,只愿未曾遇到你(五十七) 3109
親愛的,千萬別把孩子養得“輸不起”! 2580
你憑什么詆毀我的愛豆!向語言暴力Say No! 2177
史上最全36個虐腹動作:想要馬甲線,人魚線的朋友練起來 776
兩只蝸牛的愛情 3382
比文招親【星言夙駕,說于桑田】十二 2493
《簡書歷史月刊003·三千年來誰著史》上線 1118
從結果來看,整個過程最多的時候使用了8個線程來完成這個任務。每次運行具體使用的線程數都不一樣,讀者也可以將這段代碼復制過去,并引用連接中的兩個類,看看結果如何。(由于運行結果依賴于簡書服務器返回的結果,隨著時間推移,程序結果很可能不正確,望知悉)
在整個代碼中,程序員并不知道具體任務是如何分配,程序員的關注點只在業務邏輯本身上,而不用關心有關于線程調度的問題。具體的調度交給里FJP。
ForkJoinTask 中拋出異常
而在ForkJoinTask,可能會引發Unchecked Exception,因此可以調用ForkJoinTask.isCompletedAbnormally()
來判斷是否任務在執行中出現異常。如果返回值為Throwable
類型則表明在執行過程中出現Unchecked Exception;若返回值為CancellationException
則表明任務在執行過程中被取消;如果任務還沒有結束或者正常完成,沒有異常,則返回null。
ForkJoinPool / ForkJoinTask 與 Executor 關聯
從類繼承圖上可以看到,ForkJoinPool 間接繼承了Executor,因此可以認為兩者師出同門,只不過后者提供更加便捷API,使程序員將關注點更加集中在業務上。既然兩者師承一派,那么很多地方是一樣或類似的,這里著重說一下不同的地方。
區別 | Executor | ForkJoinPool |
---|---|---|
接受的對象 | Runnable和Callable的實例 | Runnable、Callable和ForkJoinTask的實例 |
調度模式 | 處于后面等待中的任務需要等待前面任務執行后才有機會被執行,是否被執行取決于具體的調度規則 | 采用work-stealing模式幫助其他線程執行任務,即ExcuteService解決的是并發問題,而ForkJoinPool解決的是并行問題。 |
對于了解類UNIX系統的人來說,對于fork這個詞應該不會陌生。這里fork的含義基本相同,即一個大任務分支出多個小任務執行,而小任務的執行過程中可能還會分支出更小的任務,如此往復,直到分支出來的任務是原子任務。
而join是等待剛才fork出去的分支,返回結果。順序與fork正好相反,執行結果不斷的join向上,最后那個大任務的結果就出來了。
其實FJP中還有一個Actor模型,但是我沒用過,就不介紹了,感興趣的可以善用搜索引擎。
Java 8 中的Stream
這個Stream不同于OIO中的Stream,不是一種輸出/輸出流,其本身不包含任何數據,更像一種迭代器。這在后面的例子中會提現出來,這個Stream允許并行的對集合類型進行迭代操作,并且依托于lambda表達式,可以用極為簡便的代碼完成對集合的CRUD操作。而Stream之所以能夠提供并行迭代的,是因為其內部使用了FJP的模型(以下代碼若非特別說明均需要Java 8及以上)
一般來說,使用一個Stream的流程是:
- 取得一個數據源 source
- 數據轉換
- CRUD操作
- 返回新的Stream
Stream不會改變數據源,每次都會返回一個新的數據集合。而數據源可以是這些來源:
- Collection 對象
- Collection.stream()
- Collection.parallelStream()
- 數組
- Arrays.stream(T array)
- Stream.of()
- BufferedReader
- BufferedReader.lines()
- java.util.stream.IntStream.range()
- java.nio.file.Files.walk()
- java.util.Spliterator
- Random.ints()
- BitSet.stream()
- Pattern.splitAsStream(java.lang.CharSequence)
- JarFile.stream()
Stream的操作大致分為兩大列:
- Intermediate,一個Stream后面可以跟隨任意個Intermediate操作。其主要目的過過濾、映射數據,值得一提的是intermediate是lazy的,因此只有調用相關方法后才會進行相關Stream的真正操作(例如打開文件等)
- Terminal,一個Stream只能有一個Terminal。一旦執行操作后,這個Stream就已經結束了,因此Terminal一定是一個Stream的最后一個操作。Terminal的調用才會真正開始Stream的遍歷,并且會產生一個結果。
Stream的使用方法
理論安利的半天,看看比較直觀的代碼,比如從隨機數中找到大于x的值:(輸出大于50的數字)
public class StreamExample {
public static void main(String[] args) {
IntStream stream = new Random().ints(0, 100).limit(50); // 構造Stream,生成50個[0,100)之間隨機數,這行代碼結束的時候,數字還沒有生成
stream.filter(value -> value > 50) // 此時隨機數還沒有生成
.forEach(System.out::println); // 直到要輸出的時候,才從數據源獲取數據
}
}
代碼有沒有很簡潔?傳統方法需要各種各樣的for循環,這里全部沒有了。首先要格外強調的是:
- Stream是延遲操作的
- Stream本身是不包含任何數據
- Stream的數據均來自于數據源
- Stream只有執行Terminal操作時,才從數據源上獲取數據
- Stream的(輸入)數據源可以是無窮大的
- Stream的輸出不能是無窮的,必須是一個有限集合
這里舉個例子,說明一下數據源可以是無限的。常規的集合,數組、列表等都是有限集合,集合可以是非常大(受限于硬件限制),但必定有限。什么是無限的集合?數學上有個概念,叫自然數,定義是所有正整數加上0的集合,而正整數這個子集合是無窮的。那么在Java中如何表示這個無限集合 自然數呢?
class NaturalNumber implements Supplier<BigInteger> {
private BigInteger num;
public NaturalNumber() {
this.num = BigInteger.valueOf(-1);
}
@Override
public BigInteger get() {
this.num = this.num.add(BigInteger.ONE);
return this.num;
}
}
這樣就構造了一個無限的自然數集合,通過Stream.generate()
方法來構建與這個無限集合相關的Stream對象,Stream每次獲取值或調用get方法,無窮無盡。另外還有一個更簡便的無窮的自然數集合,只有一句話:
Stream.iterate(0, val -> val + 1);
不過實際上這個有有窮的集合,受限于Integer
數據類型的限制,最大只能到Integer.MAX_VALUE
那么什么是輸出不能是無窮的呢?有輸入,就可以輸出,為什么不能無限輸出呢?以這個自然數發生器來看個例子:
public class StreamExample {
public static void main(String[] args) {
Stream.generate(new NaturalNumber()).forEach(System.err::println);
}
}
編譯沒有問題,運行起來也沒有問題。但是...似乎程序永遠也不會停下來,因為Stream能夠得到無窮的輸入,那么就可以無盡的輸出。永不停歇,大多數情況下,我們不希望程序會這樣,同樣以這個自然數發生器為例,可能我希望計算從m到n自然數的累加值。但是數據源是無限的,怎么辦?
public class StreamExample {
static class FinalFieldHelper<T> {
private T obj;
public FinalFieldHelper(T obj) {
this.obj = obj;
}
public T value() {
return this.obj;
}
public void value(T obj) {
this.obj = obj;
}
}
public static void main(String[] args) {
final int m = 10000, n = 100000;
final FinalFieldHelper<BigInteger> result = new FinalFieldHelper<>(BigInteger.ZERO);
Stream.generate(new NaturalNumber()).limit(n).skip(m).forEach(bigInteger -> result.value(bigInteger.add(result.value())));
System.err.printf("from %d to %d -> %s%n",m,n,result.value().toString());
}
}
是的,正如你所見的那樣,使用limit方法,將一個無限集合截取成有限集合,然后再進行操作。因為對于無限集合而言,調用任何一個Terminal操作都會導致程序掛起。(FinalFieldHelper
是一個輔助類,因為內部類訪問外部類的變量必須是final的,所以在這里我無法更新result的值,用了這么個類變通一下)
這里介紹一下Stream的一些常用方法
方法 | 用途 |
---|---|
distinct | 去除重復對象,其結果依賴于具體對象的equals方法 |
filter | 過濾數據源中的結果,產生新的Stream,參數為過濾的方法 |
map | 對于Stream中包含的元素使用給定的轉換函數進行轉換操作,新生成的Stream只包含轉換生成的元素。這個方法有三個對于原始類型的變種方法,分別是:mapToInt,mapToLong和mapToDouble。這三個方法也比較好理解,比如mapToInt就是把原始Stream轉換成一個新的Stream,這個新生成的Stream中的元素都是int類型。之所以會有這樣三個變種方法,可以免除自動裝箱/拆箱的額外消耗; |
flatMap | 和map類似,不同的是其每個元素轉換得到的是Stream對象,會把子Stream中的元素壓縮到父集合中; |
peek | 生成一個包含原Stream的所有元素的新Stream,同時會提供一個消費函數(Consumer實例),新Stream每個元素被消費的時候都會執行給定的消費函數; |
limit | 對一個Stream進行截斷操作,獲取其前N個元素,如果原Stream中包含的元素個數小于N,那就獲取其所有的元素; |
skip | 返回一個丟棄原Stream的前N個元素后剩下元素組成的新Stream,如果原Stream中包含的元素個數小于N,那么返回空Stream; |
下面就這些常用方法,寫一些對應的例子
public class StreamTestCase {
private final Object[] source = new Object[]{"a", "b", null, "c", new String[]{"d1", "d2", "d3"}, "e", "a", "b", "c", "f"};
private void printForEach(String methodName, Stream stream) {
if (methodName != null)
System.err.printf("===%s Start===%n", methodName);
System.err.print('[');
stream.forEach(o -> {
if (o == null)
System.err.print(o);
else if (o instanceof Stream)
this.printForEach(null, (Stream) o);
else if (o instanceof String || o instanceof Boolean)
System.err.print(o);
else {
Object[] obj = (Object[]) o;
System.err.print('[');
for (Object oo : obj) {
System.err.print(oo);
System.err.print(' ');
}
System.err.print(']');
}
System.err.print(' ');
});
System.err.println(']');
if (methodName != null)
System.err.printf("===%s Finish===%n", methodName);
}
@Test
public void distinctTest() {
this.printForEach("distinctTest", Stream.of(source).distinct()); // 去掉重復元素,后面的abc就被去掉了
}
@Test
public void peekTest() {
this.printForEach("piikTest", Stream.of(source).peek(o -> System.err.println("Peek -> " + o))); // 一定要有終端方法,peek才會被調用
}
@Test
public void filterTest() {
this.printForEach("filterTest", Stream.of(source).filter(o -> o != null && o instanceof String)); // 過濾掉了null和數組
}
@Test
public void limitTest() {
this.printForEach("limitTest", Stream.of(source).limit(4)); // 截取前四個元素
}
@Test
public void skipTest() {
this.printForEach("skipTest", Stream.of(source).skip(4)); // 去掉前四個元素
}
@Test
public void mapTest() {
// 將源對象根據自定義規則進行類型轉換
// 我的轉化規則是null保持不變
// 其他元素非String的轉換為True
// String類型,首字母Ascii碼為偶數的為True 其余false
this.printForEach("mapTest", Stream.of(source).map(o -> {
if (o == null) return null;
if (o.getClass().isArray()) return Boolean.TRUE;
return (o.toString().charAt(0) & 1) == 0;
}));
}
@Test
public void flatMapTest() {
this.printForEach("flatMapTest", Stream.of(source).flatMap((Function<Object, Stream<?>>) o -> {
if (o == null) return null;
if (o.getClass().isArray()) return Stream.of(Boolean.TRUE);
return Stream.of((o.toString().charAt(0) & 1) == 0);
}));
}
}