本文翻譯整理自 winterbe.com/posts/2015/…
歡迎進入 Java8 併發篇系列。此係列大概包括三篇教程:
本篇博文是此係列的第一篇,接下來的 15 分鐘裡,我將會通過一些簡單易懂的示例程式碼來教會你,如何在 Java8 中進行併發程式設計,學會如何通過 Thread
, Runable
和 Executor
來並行執行程式碼。
關於 JDK 中 併發 API 是在 JDK1.5 中被首次引入的,並且在後續的版本中得到不斷地增強。這篇文章中介紹的大部分名詞概念同樣適用於老版本,這一點你不用擔心。本文的著重點在程式碼演示,如何使用 lambda
表示式以及新特性相關。
如果你對 lambda
表示式不是很熟悉,我推薦你先閱讀我之前的文章:Java8 新特性指導手冊
★★★ 如果此教程有幫助到你, 去小哈的 GitHub 幫忙 Star 一下吧, 謝謝啦! 傳送門 ★★★
目錄
Threads 與 Runnables
可以肯定的是,所有的現代作業系統支援併發的手段無外乎程式和執行緒。程式通常可以理解為獨立執行的程式例項,打個比方,你啟動一個 Java 程式,這個時候作業系統就會建立一個新的程式,它可以與其他程式並行執行。而在這些程式的內部,我們可以通過執行緒來併發執行一段程式碼,一段業務邏輯。這樣做有啥好處?好處就是,我們可以最大程度的發揮機器多核 CPU 的優勢。
Java 從 1.0 版本就開始支援執行緒了。可以說是最基本的功能了,在老版本中,我們通常會實現 Runnable
介面,重寫 run()
方法, 在方法內編寫一段業務程式碼:
Runnable task = () -> {
String threadName = Thread.currentThread().getName();
System.out.println("Hello " + threadName);
};
task.run();
Thread thread = new Thread(task);
thread.start();
System.out.println("Done!");
複製程式碼
因為 Runnable 介面是一個函式式介面,我們直接採用 lambda 表示式來書寫它,內部的業務邏輯是列印當前的執行緒名,控制檯的輸出可能存在兩種情況:
Hello main
Hello Thread-0
Done!
複製程式碼
或者:
Hello main
Done!
Hello Thread-0
複製程式碼
我們無法預測 runnable
是在主執行緒執行完成後執行還是之前執行,因為這種順序的不確定性,使得在一個體量龐大的應用中,併發程式設計變得異常複雜。
我們可以線上程內部設定休眠時間,來模擬一個長時間執行的任務:
Runnable runnable = () -> {
try {
String name = Thread.currentThread().getName();
System.out.println("Foo " + name);
// 休眠一秒
TimeUnit.SECONDS.sleep(1);
System.out.println("Bar " + name);
}
catch (InterruptedException e) {
e.printStackTrace();
}
};
Thread thread = new Thread(runnable);
thread.start();
複製程式碼
執行上面的程式碼,你會發現兩條列印語句之間會間隔一秒。TimeUnit
在處理單位時間的時候,是一個很實用的列舉類,你也可以通過呼叫 Thread.sleep(1000)
來達到同樣的目的。
我們不得不承認,使用 Thread
類是很乏味的且容易出錯的。由於 Java 併發相關的 API 是在 2004 年 Java5 釋出的時候才被正式引入的。它們被放置在 java.util.concurrent
包下,裡面包含了很多處理併發程式設計有用的類。
這些併發 API 被引入後,在後續的 Java 版本中,又得到不斷的增強,Java8 甚至提供了新的併發類和方法來處理併發。
廢話不多說,接下來,讓我們進入併發 API 中最重要的執行器: Executor
.
執行器 Executor
設計 ExecutorService
的目的是用來替代我們直接手動建立 Thread
。Executors
支援執行非同步任務,通常管理著一個執行緒池,執行緒池內部執行緒會得到複用,避免了頻繁建立執行緒,銷燬執行緒而帶來的額外的系統開銷。
// 建立一個只包含一個執行緒的執行緒池
ExecutorService executor = Executors.newSingleThreadExecutor();
executor.submit(() -> {
String threadName = Thread.currentThread().getName();
System.out.println("Hello " + threadName);
});
// => Hello pool-1-thread-1
複製程式碼
Executors
類提供了便利的工廠方法用來不同型別的執行緒池。上面的示例中我們建立了一個只包含單執行緒的執行緒池 executor
。
程式碼的輸出結果與上面手動 new Runnable() 輸出一致,唯一不同的是,Java 程式沒有終止!
注意:
Executors
必須顯示的終止它,否則它們將持續監聽是否有新的任務需要執行。這也是為什麼我在研發組中推薦小組人員建立執行緒池務必要交給 Spring Ioc 容器管理的原因。
那麼,如何優雅的關閉 ExecutorService
呢?
ExecutorService
提供了兩個方法:
- 1.
shutdwon()
: 它會等待正在執行的任務執行完成後,再銷燬執行緒池; - 2.
shutdownNow()
: 它會立刻終止正在執行的任務,並銷燬執行緒池;
關閉 ExecutorService
最佳實踐:
try {
System.out.println("attempt to shutdown executor");
executor.shutdown();
// 指定關閉之前的等待時間 5s,達到“溫柔滴”關閉
executor.awaitTermination(5, TimeUnit.SECONDS);
}
catch (InterruptedException e) {
System.err.println("tasks interrupted");
}
finally {
if (!executor.isTerminated()) {
System.err.println("cancel non-finished tasks");
}
executor.shutdownNow();
System.out.println("shutdown finished");
}
複製程式碼
Callable 和 Future
除了 Runnable
, executor
還支援另外一種任務型別 —— Callable
。Callable
和 Runnable
類似,唯一不同的是,Callable
有返回值。
下面的示例程式碼中,我們通過 lambda 表示式定義了一個 Callable
:休眠 1s 鍾後,返回一個整數。
Callable<Integer> task = () -> {
try {
TimeUnit.SECONDS.sleep(1);
return 123;
}
catch (InterruptedException e) {
throw new IllegalStateException("task interrupted", e);
}
};
複製程式碼
Callable
也可以像 Runnable
一樣,作為入參,提交給 executor
。這樣的話,問題來了,我們怎麼取到返回值呢?因為 submit()
方法不會阻塞等待任務完成的。
雖然 executor
不能直接返回 Callable
結果,不過,executor
可以返回一個 Future
型別的結果,他可以在稍後的某個時機取出實際的返回值。
ExecutorService executor = Executors.newFixedThreadPool(1);
Future<Integer> future = executor.submit(task);
System.out.println("future done? " + future.isDone());
// 這裡會阻塞等待返回結果
Integer result = future.get();
System.out.println("future done? " + future.isDone());
System.out.print("result: " + result);
複製程式碼
上面的這段程式碼,Callable
提交給 executor
後,我們先通過呼叫 isDone()
檢查 future
是否執行完成,結果當然是 false
, 因為 future
在返回那個整數之前,會休眠 1s。
後面到執行 get()
方法,執行緒會阻塞等待返回結果。整個流程走完,我們再來看下控制檯的輸出:
future done? false
future done? true
result: 123
複製程式碼
你需要注意,如果你關閉 executor
, 所有為關閉的 future
都會丟擲異常。
executor.shutdownNow();
future.get();
複製程式碼
還有一點,你可能也注意到了,這次我們建立執行緒池並非使用 newSingleThreadExecutor()
。而是通過newFixedThreadPool(1)
來建立一個單執行緒執行緒池的 executor
。 它等同於使用newSingleThreadExecutor
,不過使用第二種方式我們動態的調整入參,比如傳入一個比 1 大的值來增加執行緒池的大小。
超時 Timeouts
上面提到了,future.get()
那裡會阻塞等待,直到 callable
返回值。我們試想一個最糟糕的情況,callable
持續執行導致你的程式沒有響應,這顯然是不能忍受的。我們可以通過傳入一個超時時間來避免發生這種情況:
ExecutorService executor = Executors.newFixedThreadPool(1);
Future<Integer> future = executor.submit(() -> {
try {
TimeUnit.SECONDS.sleep(2);
return 123;
}
catch (InterruptedException e) {
throw new IllegalStateException("task interrupted", e);
}
});
// 設定超時時間為 1s
future.get(1, TimeUnit.SECONDS);
複製程式碼
執行上面這段程式碼,會丟擲一個 TimeoutException:
Exception in thread "main" java.util.concurrent.TimeoutException
at java.util.concurrent.FutureTask.get(FutureTask.java:205)
複製程式碼
為什麼丟擲這個異常,原因很顯然,我們指定的超時時間是 1s, 而任務中光休眠就是 2s 了。
invokeAll
Executors
支援通過 invokeAll
方法一次性批量提交多個 callable
。這個方法的返回結果是一個 future
集合:
ExecutorService executor = Executors.newWorkStealingPool();
List<Callable<String>> callables = Arrays.asList(
() -> "task1",
() -> "task2",
() -> "task3");
executor.invokeAll(callables)
.stream()
.map(future -> {
try {
// 拿到每個 future 的返回值
return future.get();
}
catch (Exception e) {
throw new IllegalStateException(e);
}
})
.forEach(System.out::println); // for 迴圈輸出結果
複製程式碼
上面這段示例程式碼,我們先利用 Java8 的 Stream 流來處理 invokeAll()
方法返回的 future
集合,取出每個 future
的返回值將其對映到一個 List<String>
集合中,最後迴圈輸出結果。
invokeAny
批量提交的另外一種方式是 invokeAny()
, 同樣是批量提交,它與 invokeAll()
不同點在於:它會阻塞等待,當某個 callable
第一個執行完成,它會立刻返回執行結果,而不再等待那些正在執行中的 callable
了。
為了測試,我們定義一個方法,用來模擬建立擁有不同執行時間的 callable
:
Callable<String> callable(String result, long sleepSeconds) {
return () -> {
TimeUnit.SECONDS.sleep(sleepSeconds);
return result;
};
}
複製程式碼
然後,我們建立一組 callable
, 他們擁有不同的執行時間,1s 到 3s 的,通過invokeAny()
將這些 callable 提交給executor
,看看返回最快的 callable
的字串結果是哪個:
ExecutorService executor = Executors.newWorkStealingPool();
List<Callable<String>> callables = Arrays.asList(
callable("task1", 2),
callable("task2", 1),
callable("task3", 3));
String result = executor.invokeAny(callables);
System.out.println(result);
// => task2
複製程式碼
最快的是 task2, 它的執行時間最短,所以輸出是它。
上面這段示例程式碼中,建立 ExecutorService
的方式又變了,通過 Executors.newWorkStealingPool()
。這個工廠方法是 Java8 才引入的,返回值是一個 ForkJoinPool
型別的 executor
,它和指定固定大小的執行緒池不同,ForkJoinPools
允許指定一個並行因子來建立,預設的值為物理機可用的 CPU 核心數。
ForkJoinPools
是在 Java7 中才被引入的,這將會在後面系列的教程中做詳細介紹。敬請期待。
任務排程 ScheduledExecutor
上面我們已經學習瞭如何在一個 executor
中提交和執行一次任務。還有中場景是,我們需要多次執行一個常見的任務,這個時候,我們可以利用排程執行緒池。
ScheduledExecutorService
支援任務排程,持續執行或者延遲一段時間後再執行。
下面的程式碼示例,指定一個任務延遲 3 分鐘再執行:
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
Runnable task = () -> System.out.println("Scheduling: " + System.nanoTime());
ScheduledFuture<?> future = executor.schedule(task, 3, TimeUnit.SECONDS);
TimeUnit.MILLISECONDS.sleep(1337);
long remainingDelay = future.getDelay(TimeUnit.MILLISECONDS);
System.out.printf("Remaining Delay: %sms", remainingDelay);
複製程式碼
ScheduledExecutorService
會返回一個增強型別的 future
型別 —— ScheduleFuture
,它除了保有原有 Future
提供的所有方法外,還提供了 getDelay()
方法, 用來獲取距離目標時間的時間差,也就是剩餘的延遲時間。
為了保證排程任務的持續執行,executors
提供了兩個 API:
- 1.
scheduleAtFixedRate()
:固定週期執行一個任務, 不考慮任務的耗時,即使上個任務還沒有執行完畢,到了時間,下個任務依然會去執行;
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
Runnable task = () -> System.out.println("Scheduling: " + System.nanoTime());
// 第一次執行的延遲時間
int initialDelay = 0;
// 週期頻率
int period = 1;
// 指定任務立刻執行,且每次之間間隔為 1s
executor.scheduleAtFixedRate(task, initialDelay, period, TimeUnit.SECONDS);
複製程式碼
注意:
initialDelay
表示第一次執行的延遲時間,0 表示立即執行
- 2.
scheduleWithFixedDelay()
:固定延遲執行任務,就是說,必須要等到上個任務執行完成後才開始計時,達到設定的延遲時間後,下個任務才會被觸發;
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
Runnable task = () -> {
try {
TimeUnit.SECONDS.sleep(2);
System.out.println("Scheduling: " + System.nanoTime());
}
catch (InterruptedException e) {
System.err.println("task interrupted");
}
};
executor.scheduleWithFixedDelay(task, 0, 1, TimeUnit.SECONDS);
複製程式碼
總結一下:
scheduleWithFixedDelay()
和scheduleAtFixedRate()
最大的區別就是,scheduleWithFixedDelay()
需要等到前一個任務執行完成後才開始計延時,再觸發下一個任務。
如上面的示例程式碼中這個排程任務,設定了 1s 的延時,初始化延時為 0,假設理想情況下任務的耗時就是 2s, 則任務的排程週期為: 0s -> 3s -> 6s -> 9s ....
, 它更適用於當你無法預測任務的執行時長的場景中使用。
總結
這篇文章主要探討了如何在用 lambda
表示式,在 Java8 中使用執行緒和執行器,並演示了相關示例程式碼。除此之外,我們還學習了 Callable
和 Future
, 超時設定,任務的批量提交 invokeAll()
和 invokeAny()
API。最後, 瞭解瞭如何在 Java8 中使用任務排程 ScheduledExecutor
,希望你能有所收穫。