Java8 新特性併發篇(一) | 執行緒與執行器

犬小哈發表於2019-01-21

本文翻譯整理自 winterbe.com/posts/2015/…

歡迎進入 Java8 併發篇系列。此係列大概包括三篇教程:

本篇博文是此係列的第一篇,接下來的 15 分鐘裡,我將會通過一些簡單易懂的示例程式碼來教會你,如何在 Java8 中進行併發程式設計,學會如何通過 Thread, RunableExecutor 來並行執行程式碼。

關於 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 的目的是用來替代我們直接手動建立 ThreadExecutors 支援執行非同步任務,通常管理著一個執行緒池,執行緒池內部執行緒會得到複用,避免了頻繁建立執行緒,銷燬執行緒而帶來的額外的系統開銷。

// 建立一個只包含一個執行緒的執行緒池
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 程式沒有終止

Java8 新特性併發篇(一) | 執行緒與執行器

注意: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 還支援另外一種任務型別 —— CallableCallableRunnable 類似,唯一不同的是,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 中使用執行緒和執行器,並演示了相關示例程式碼。除此之外,我們還學習了 CallableFuture, 超時設定,任務的批量提交 invokeAll()invokeAny() API。最後, 瞭解瞭如何在 Java8 中使用任務排程 ScheduledExecutor,希望你能有所收穫。

GitHub 地址

github.com/weiwosuoai/…

小哈的微信公眾號,歡迎關注

Java8 新特性併發篇(一) | 執行緒與執行器

相關文章